Target: NexusCorp Employee Portal (domino.thm / 10.128.172.213) Room type: Web application chained-vulnerability room ("every weakness knocks over the next") Final result: Root shell, 5/5 flags
This writeup explains not just what was run, but why — every command is broken down piece by piece, and every vulnerability is explained conceptually so someone with zero prior security background can follow the logic, not just copy the commands.
Why we did this: Computers turn human-readable names like google.com into IP addresses using DNS (Domain Name System). But before checking the internet's DNS servers, every computer first checks a local file called /etc/hosts — a simple text file that manually maps a name to an IP. By adding this line, we told our attack machine: "whenever I ask for domino.thm, just use 10.128.172.213 — don't bother asking DNS."
Why it mattered for this room specifically: Many web applications check the Host: header of an incoming request and behave differently (or break entirely) if you access them by raw IP instead of their expected hostname — this is called virtual hosting. Browsing straight to http://10.128.172.213/ might serve a default Apache page, broken links, or a redirect loop, while http://domino.thm/ serves the real app correctly.
Command breakdown, piece by piece:
Why we did this first, before touching the web app at all: You never assume you know a target's full attack surface. A web app might be the "front door," but there could be other open doors — an exposed database, an admin SSH interface on a weird port, an internal API on a completely different port. Scanning every port (not just the common ones) is the professional default, not an optional extra step.
Command breakdown:
Result:
Only two ports open. This immediately told us: the entire attack surface is the web application on port 80. Port 22 (SSH) exists, but with no credentials yet, it's not a usable entry point — it becomes relevant later, once we have valid login details to use.
Why a second, more targeted scan: The first scan only tells you which ports are open. This second scan tells you what software is running on them and what version — critical, because specific software versions sometimes have known, public vulnerabilities (CVEs) you can look up and exploit directly.
Command breakdown:
Result:
What this told us: Both OpenSSH 9.6p1 and Apache 2.4.58 are recent, patched versions with no significant public unauthenticated exploits. This is actually useful information — it tells you not to waste time searching for a "magic exploit" against the web server software itself. The vulnerability isn't in Apache or SSH; it's going to be in the custom application code running on top of them. This matches the room's own briefing about "misconfigurations and logic flaws."
Before running any automated tool against the web app, we manually browsed it — looking at what a normal user would see, and reading the raw HTML source code.
Automated tools like directory brute-forcers are great at finding hidden pages that aren't linked anywhere. But they tell you nothing about what the app actually does — its login flow, its features, its logic. You build that mental model by browsing it yourself first. Skipping this step is one of the most common beginner mistakes — jumping straight to tools without understanding the target.
Every browser lets you see the raw HTML of a page with Ctrl+U (View Page Source). This shows you the actual code the server sent, including things that aren't visually obvious on the rendered page — HTML comments, hidden form fields, and crucially, the exact destination of every link (the href attribute).
On the login page, we found:
Three critical pieces of intel extracted from this alone:
Visiting /team.php (a public page, no login required) listed every employee with their full name and corporate email:
Why this matters: Combined with the firstname.lastname convention we found on the login page, we now have a complete, high-confidence list of real usernames — laura.hayes, michael.chen, sarah.johnson, etc. — without guessing or brute-forcing usernames at all. This is called user enumeration, and public "About Us" or "Our Team" pages are one of the most common real-world sources for it. Companies love showing off their staff; attackers love that they do.
We saved this list to a file:
Command breakdown:
We tested /forgot.php with a real username (sarah.johnson) and a fake one (fake.person):
| Input | Response |
|---|---|
| sarah.johnson (real) | "Reset link sent. Check your email for instructions." |
| fake.person (fake) | "No account found with that username." |
This is a real vulnerability, not just a recon technique — it has a name: Username Enumeration (CWE-203, "Observable Response Discrepancy").
What it is: The application behaves differently depending on whether an account exists, and that difference is visible to anyone, unauthenticated.
Why it matters: In isolation, this doesn't get you into anything. But it drastically narrows down an attacker's target list before a password attack. Instead of guessing at both usernames and passwords blind, you can independently confirm exactly which accounts are real, then focus every subsequent attack only on those.
How defenders should prevent it: Return the exact same message regardless of whether the account exists — e.g., always say "If that account exists, a reset email has been sent," never a distinguishing yes/no.
Real-world relevance: This exact bug class has appeared in bug bounty reports against real companies' password reset and login flows for years — it's cheap to introduce and easy to miss in code review because "it's just an error message."
Why: This finds pages and folders that exist on the server but aren't linked from anywhere we can click. The tool works by taking a giant list of common file/folder names (the "wordlist") and requesting each one, one at a time, checking whether the server responds with something other than "404 Not Found."
Command breakdown:
Result — six real directories found:
(A 301 status code means "Moved Permanently" — Apache is redirecting requests for the bare folder name to the folder name with a trailing slash, e.g. /admin → /admin/. This is standard, expected Apache behavior for directories, not a vulnerability by itself.)
Why /backup stood out immediately: Folders named "backup," "old," "test," or "dev" on a live production-style app are a huge red flag in real-world pentesting. They frequently contain forgotten config files, old source code, or credentials that were never meant to be publicly reachable, but were left there anyway — usually because a developer put them there "temporarily" and forgot.
Command breakdown:
This returned a raw Apache-generated file index (an actual HTML page listing filenames, sizes, and dates), because Apache's "directory listing" feature was left enabled on this folder — meaning if there's no index.html/index.php file inside a folder, Apache will just show you everything in it, like browsing a shared drive. This itself is a misconfiguration; production servers should disable this feature (Options -Indexes in Apache config) so folders without an index page return a 403 Forbidden instead of exposing their contents.
Found: README.txt and config.enc.
Result:
This told us exactly what we were dealing with: a file encrypted with AES-128 in ECB mode, and pointed us to another file — static/app.js — for the decryption key.
We first checked /static/ (also had directory listing enabled) and found app.js, then downloaded it:
(We had to use --output - here because plain curl detected some non-text-looking bytes in the response and refused to dump it straight to the terminal, as a safety measure to avoid messing up terminal display. --output - forces it to print anyway.)
Inside, we found this in plain, unobfuscated JavaScript comments:
This is a real, extremely common vulnerability: Hardcoded Secrets in Client-Side Code.
What it is: Any code that runs in a user's browser (JavaScript, in this case) is fully downloadable and readable by that user — there is no way to "hide" a secret inside JS that a browser has to execute. Developers sometimes forget this and put API keys, encryption keys, or other secrets directly into frontend code, assuming users won't look.
Why it matters: Anyone — not just this room's attacker — can view a website's JS files (they're just downloaded and interpreted by your browser) as easily as you'd open a text file.
How to prevent it: Secrets like encryption keys should only ever exist server-side, never shipped to the client.
Line-by-line explanation:
Result:
Two things to explain here:
Side note on why ECB mode itself is considered weak (not exploited directly in this room, but worth understanding): because ECB encrypts identical blocks of input into identical blocks of output every time, patterns in the original data can sometimes still be visible in the encrypted version (the classic example is an ECB-encrypted image where you can still make out the outline of the original picture). Modern applications should use modes like CBC or GCM instead, which incorporate randomness so identical inputs never produce identical outputs.
With 7 confirmed usernames and a known login endpoint, the next step was attempting to brute-force passwords.
Before running any brute-force tool, you must know exactly what text the application shows on a failed login — not guess it. We submitted a deliberately wrong password and got:
(No trailing period — a detail that mattered, explained below.)
Command breakdown, piece by piece:
Our first attempt used the full rockyou.txt (a famous leaked password list containing ~14.3 million real-world passwords). With 7 usernames, that's over 100 million total login attempts — completely impractical for a room with a limited session time. We killed it and instead used:
head -n 10000 takes just the first 10,000 lines of the file. Since rockyou.txt is roughly sorted by how frequently each password appeared in the real-world data breach it came from, the top 10,000 entries cover the overwhelming majority of genuinely weak/common passwords — exactly the kind a deliberately vulnerable lab machine is built to use.
Three valid accounts, all sharing the trivially weak password password. We proceeded with sarah.johnson.
Why brute-forcing worked here as an attack technique: brute-force/dictionary attacks are only effective when (a) there's no account lockout after repeated failures, and (b) at least one user has a weak, common password. Real production systems defend against this with account lockouts, rate limiting, CAPTCHAs, and multi-factor authentication — none of which were present here, by design, since this is a learning environment.
After logging in as sarah.johnson, the dashboard showed a link:
id=3 corresponded to Sarah's own account. The obvious question: what happens if we just change the number?
Result:
Flag 1 obtained: THM{1d0r_h0r1z0nt4l_4cc3ss_fl4g1}
What it is: An application exposes a direct, predictable reference to an internal object (in this case, a simple sequential database ID number) in a URL, and fails to check whether the person making the request is actually authorized to see that specific object.
Why it matters: The server correctly verified that someone was logged in (you needed a valid session to reach this page at all) — but it never checked whether the logged-in user owned the record they were requesting. It trusted the client-supplied id parameter completely.
How attackers use it: Simply changing a number, a filename, or any other identifier in a URL or parameter and observing whether you get access to data that isn't yours. This is one of the very first things any web app tester checks, because it's common and devastatingly simple to exploit — no special tools required, just changing a number in the address bar.
Specific type here — Horizontal IDOR: "horizontal" means the accessed user (laura.hayes) is at the same general privilege tier as other regular accounts in the system — you're moving sideways between peer accounts, not jumping from user to admin functionality directly. (Note: it happened to belong to an admin-role account, which is what made it valuable here, but the flaw itself — no ownership check on id — is the same regardless of whose data you land on.)
How defenders detect/prevent it: Every request for a specific resource should independently verify: "does the currently logged-in user's session actually have permission to access this specific record?" — not just "is someone logged in." This usually means checking session.user_id == requested_record.owner_id (or equivalent role-based logic) on the server, on every single request, not trusting anything the client sends.
Real-world relevance: IDOR is consistently one of the most commonly reported vulnerability classes in bug bounty programs across every industry — banking apps, healthcare portals, social media platforms — because it's easy to introduce (forgetting one if check) and easy to test for.
/admin/ returned a clean 403 Forbidden when visited as sarah.johnson. Unlike the IDOR bug, this endpoint does correctly check the logged-in user's role server-side. We needed to actually become an admin user at the session level, not just request admin data through a broken endpoint.
(typed into the browser's DevTools Console, F12 → Console tab)
Result:
Why this check matters: document.cookie is a browser JavaScript API that returns any cookies for the current site that are readable by JavaScript. Cookies can be marked with an HttpOnly flag by the server, which tells browsers "never let any JavaScript touch this cookie, only send it in HTTP requests." If a session cookie is HttpOnly, then even if an attacker manages to run malicious JavaScript on a page (via XSS), they cannot read the cookie value directly.
Here, the cookie was readable via document.cookie — confirming HttpOnly was not set. This meant that if we could get malicious JavaScript to run in someone else's browser while they're logged in, we could steal their session cookie directly.
The "Open Ticket" form (/support/create.php) accepts free-text Subject and Message fields — a classic place where user input later gets displayed somewhere else (in an admin's ticket review interface), which is exactly the pattern that enables Stored XSS.
We submitted this as the payload:
Payload breakdown, piece by piece:
What it is: "Stored" XSS means the malicious payload is saved persistently by the application (in this case, in a support ticket in a database) and gets served back out and executed whenever anyone else views that stored content — as opposed to "reflected" XSS, where the payload only executes immediately for whoever clicks a specially crafted link.
"Blind" specifically means: the attacker never sees the payload actually execute or interact with the victim's browser directly — they only see the side effect (in our case, an incoming HTTP request to our listening server) sometime later, possibly minutes, hours, or even days afterward, whenever the victim/system happens to view the stored content.
Why a custom Python listener instead of just python3 -m http.server: the built-in simple HTTP server logs each request's path, but doesn't print the full set of HTTP headers — and headers are exactly where useful data like cookies often show up (if the "attacker" happens to be a server-side bot making the request rather than a real browser executing JavaScript, as turned out to be the case here). This custom script explicitly prints every header of every incoming request, giving maximum visibility.
Line-by-line:
After submitting the ticket, the listener caught this incoming request:
A crucial detail: the c= query parameter was empty, but the real prize was elsewhere.
Look at the User-Agent header: python-requests/2.31.0. This tells us the "admin" reviewing the ticket wasn't a real human using a real browser — it was an automated Python script/bot fetching the ticket content. A Python requests-based bot has no JavaScript engine at all — it can't execute our <img onerror> payload, which is why document.cookie never got appended to the URL (c= stayed empty; there was no JavaScript execution to populate it).
However, that bot still made an authenticated HTTP request to review the ticket — using its own real, valid, server-signed session cookie, which showed up automatically in the Cookie: header of the request it sent to our server. This is technically closer to a Server-Side Request Forgery (SSRF)-flavored variant than classic browser-based XSS (the bot fetching an attacker-controlled URL while authenticated), but the practical outcome is identical: we captured a fully valid admin session.
Typed into the browser console (on any page of domino.thm), this overwrites the browser's current cookie for this site with the stolen admin cookie value. path=/ means "this cookie applies to every page on this site," matching how cookies are normally scoped.
Navigating to /admin/ afterward loaded successfully:
Flag 2 obtained: THM{bl1nd_x55_s3ss10n_h1j4ck_fl4g2}
How defenders prevent this class of bug entirely:
The app's dashboard mentioned fetching a JWT (JSON Web Token) from /api/auth/token.php for use with a separate "File Viewer" API. We fetched ours:
A JWT always has this exact structure: header.payload.signature, three parts separated by periods, each individually base64url-encoded.
Decoding the first two parts (base64 is easily reversible — it's an encoding, not encryption):
Header:
Payload:
Why anyone can read this: base64 is just a way of representing binary data as text — it has no secret key, no password, nothing preventing anyone from decoding it. alg: HS256 means the token is signed (not encrypted) using HMAC-SHA256, a symmetric algorithm — meaning the same secret key both creates and verifies the signature. Without that secret key, an attacker can read the payload freely, but shouldn't be able to forge a valid new signature for a modified payload.
What it is: The JWT specification technically allows an algorithm value of none, meaning "this token is intentionally unsigned, don't verify anything." This exists for legitimate niche internal use cases. The vulnerability occurs when a server's JWT-verification code blindly trusts whatever algorithm value is written inside the token itself (which the attacker fully controls, since they can just write a new one), rather than the server enforcing a single fixed, expected algorithm.
How we exploited it — building a forged token by hand:
Line-by-line explanation:
Testing it:
Before forging: "JWT token required." — no token supplied. After forging: "Missing name parameter" — a completely different error message, about the next step of server-side logic. This confirms the server accepted our unsigned, tampered token as valid — it moved past authentication entirely and started processing our (empty) name parameter instead. This is a genuine, working exploit.
With a working authenticated bypass, we tested whether this file-viewing endpoint would let us read arbitrary files off the server's disk:
Result: "Access denied: path must be within /var/www/html/" — the application does enforce a restriction on local file paths, limiting them to its own web root folder. Straightforward LFI was blocked.
The error message specifically said local paths must stay within /var/www/html/ — it said nothing about remote URLs. We tested whether the endpoint would fetch content from a completely different, attacker-controlled server instead of the local filesystem:
Our own local web server (port 8000) logged an incoming GET /test.txt request from the target machine's IP address — confirming the target server itself reached out and fetched our file. The local-path restriction check simply didn't apply to remote URLs at all — a classic oversight where a developer secures one input format (local paths) but forgets an entirely different one (remote URLs) is also possible through the same parameter.
The final question: does the application just display the fetched remote content as plain text, or does it actually execute it as PHP code? This distinction is everything — the first is a data leak, the second is full remote code execution.
Result:
This is real command execution, not an echo of our text — system("id") is a PHP function that runs a shell command and returns its output, and we got back an actual Linux id command result. The response format ({"output": ...}) even tells us the application's backend code is specifically designed to display command output as JSON — meaning the vulnerable code path is very likely using PHP's include() (or similar) on the fetched remote content, which doesn't just read a file's text but actually parses and executes any <?php ?> blocks found inside it. This is a well-documented, dangerous vulnerability class: RFI leading to RCE via include().
Getting the flag directly through this RCE:
Result: {"output":"THM{rf1_2_rc3_f00th0ld_fl4g3}\n"}
Flag 3 obtained: THM{rf1_2_rc3_f00th0ld_fl4g3}
One-off commands via system() calls work but are extremely slow for real exploration. We upgraded to a full interactive reverse shell:
Breakdown: nc (netcat) — a general-purpose networking tool, here used to listen for an incoming connection. -l = listen mode. -v = verbose (prints connection activity). -n = skip DNS resolution (avoids delays). -p 4444 = listen on port 4444.
Payload breakdown:
Triggering this payload the same way as before connected a fully interactive shell as the www-data user (the standard low-privilege account Apache/PHP runs as on Linux) back to our netcat listener.
A raw netcat reverse shell has real limitations — no tab completion, commands sometimes echo twice, Ctrl+C kills your entire connection instead of just the running command, and you can't use full-screen tools like nano. Fixing this is a standard, memorizable sequence:
This spawns a proper pseudo-terminal (PTY) — Python's pty module creates a fuller, more realistic terminal environment than the bare shell netcat initially gives you.
Then, on your local attacking machine (background the reverse shell first with Ctrl+Z):
Sets the TERM environment variable, telling terminal-aware programs what type of terminal they're running in — required for full-screen tools and proper screen-clearing/formatting to work correctly.
With a stable shell as www-data, we looked for the application's own configuration file — reasoning that any file allowing the app to connect to its database would necessarily contain a plaintext (or at least readable) database password somewhere.
Result:
The password D3v0ps!2024 strongly resembled the devops username pattern we'd already learned about (from the earlier config.enc decryption, which revealed a system_user: devops field).
We tested whether this database password was also reused as the actual Linux operating system login password for the devops user:
(su = "switch user" — prompts for that user's password and, if correct, starts a new shell session running as them.)
Entering D3v0ps!2024 when prompted succeeded — the shell prompt changed from www-data@... to devops@..., confirming the switch.
Flag obtained from devops's home directory:
Result: THM{s5h_cr3d_r3u53_l4t3r4l_fl4g4}
Flag 4 obtained: THM{s5h_cr3d_r3u53_l4t3r4l_fl4g4}
What it is: Using the exact same password across multiple, separate accounts or systems — in this case, a database service account's password happened to also be a real human user's actual login password.
Why it matters: Any single leaked credential (from a config file, a data breach, a log file, anywhere) becomes a potential key to every other account that shares it. This is one of the single most common real-world causes of full breaches — attackers routinely take credentials leaked from one system entirely unrelated to the actual target, and simply try them everywhere else (a technique called credential stuffing).
How defenders prevent it: Every account — service accounts and human accounts alike — should have completely unique credentials. Service account credentials specifically should never be reused for actual human logins, and should ideally be managed through a secrets manager rather than hardcoded in a config file at all.
Privilege escalation on Linux always follows a standard checklist. We worked through it methodically rather than guessing:
1. Sudo permissions:
Result: Sorry, user devops may not run sudo on tryhackme-2404. — dead end, devops has no sudo rights at all.
2. SUID binaries (files that run with the file owner's privileges regardless of who executes them):
Command breakdown: find / searches starting from the root of the filesystem. -perm -4000 matches files that have the SUID bit set (4000 in octal permission notation represents the SUID flag specifically). -type f restricts results to regular files (not directories). 2>/dev/null redirects any "permission denied" error messages (which would otherwise clutter the output, since we can't read every folder on the system) to /dev/null — a special file that simply discards anything written to it, effectively silencing those errors.
Result: only completely standard, stock Ubuntu system binaries (passwd, sudo, su, mount, etc.) — nothing custom, nothing exploitable here.
3. Static cron configuration files:
Result: only standard, unmodified Ubuntu system maintenance jobs (log rotation, package update checks, etc.) — again, nothing custom or planted.
Static configuration files don't tell the whole story — cron jobs can also be registered in places that are harder to statically enumerate, such as an individual user's personal crontab. The reliable way to catch these is to watch what actually runs, live, over time.
What pspy does and why it doesn't need root: it works by repeatedly scanning the /proc filesystem (a special Linux virtual filesystem that exposes live information about every currently running process) at a very high frequency, and printing every new process it spots — including who launched it (UID) and its full command line. Crucially, reading basic process information from /proc doesn't require any special privileges, which is why an unprivileged user can run this and still observe root-owned processes starting up.
After roughly a minute, this appeared:
A script, running automatically as root, on some kind of schedule we hadn't found in the static config files (it turned out to be registered elsewhere, not shown in the specific files we'd checked).
Result:
Understanding Linux file permission notation, since this is the entire key to this final step:
The permission string -rwxrwxr-- breaks into four parts:
The devops user we were currently logged in as is a member of the devops group, and that group has write permission on this file. This is the entire vulnerability: a script that root executes on a schedule can be edited by a non-root user.
The exploitation logic, explained conceptually: cron doesn't care who last edited a script — it simply runs whatever code is currently inside the file, using the permissions of whichever user account the cron job is configured to run as (here, root). If we can edit the file's contents, then whatever we add will execute as root the very next time the schedule triggers — we don't need to find any complex bug in the script's logic at all; we just need write access and patience.
(a fresh listener, on our local machine, on a new unused port)
Note the use of >> (append) rather than > (overwrite) — this adds our malicious line to the end of the existing legitimate script rather than destroying its original monitoring functionality. This is both good real-world practice (minimizing unnecessary disruption/detection) and simply the correct approach here, since we only need our line to also run, not to replace anything.
Within roughly a minute — matching the once-per-minute schedule pspy had already shown us — the cron daemon executed the modified script as root, which ran our appended reverse shell command, connecting back to our listener:
Full root access achieved.
Result: THM{pr1v3sc_cr0n_r00t_fl4g5}
Flag 5 obtained: THM{pr1v3sc_cr0n_r00t_fl4g5}
What it is: A scheduled task (cron job) runs as a highly privileged user (root), but the script file it executes has permissions loose enough that a lower-privileged user can modify its contents.
Why it's dangerous: cron jobs are, by definition, designed to run unattended and repeatedly, without any human reviewing what they do each time. This makes them an extremely reliable, low-effort privilege escalation vector once write access is found — you don't need to defeat any authentication or exploit any complex logic bug, you just wait for the next scheduled run.
How defenders prevent it: Scripts executed by privileged cron jobs should be owned by root with restrictive permissions (e.g., rwx------, meaning only root itself can read, write, or execute it) — no group or "other" write access whatsoever, regardless of how convenient it might seem to let a deployment/monitoring group manage the script.
Real-world relevance: This exact misconfiguration pattern (privileged scheduled task + loose file/folder permissions) is a recurring, real finding in professional penetration tests and is explicitly one of the checks covered in standard Linux privilege escalation methodology and tools like linpeas.sh and pspy.
| # | Stage | Vulnerability Class | Technique Used |
|---|---|---|---|
| — | Recon | — | nmap -p-, manual browsing, view-source |
| — | User enumeration | Observable Response Discrepancy (CWE-203) | Team page + forgot-password response diffing |
| — | Credential access | Weak/reused passwords | Hydra dictionary attack |
| 1 | Data disclosure | Horizontal IDOR | Changed ?id= parameter |
| 2 | Admin access | Blind Stored XSS → session hijacking (SSRF-adjacent) | Payload in support ticket, bot's cookie captured |
| 3 | Remote code execution | JWT alg:none forgery + RFI → RCE | Forged JWT, remote PHP execution via include()-style flaw |
| 4 | Lateral movement | Credential/password reuse | DB password from config.php reused as OS login |
| 5 | Privilege escalation | Group-writable root-owned cron script | Appended reverse shell to health_report.sh |
Each stage fed directly into the next — exactly matching the room's own framing ("every piece you find knocks over the next"). No single vulnerability alone would have gotten to root; it required chaining low-severity findings (a leaked JS comment, a loose file permission, a reused password) together with higher-severity ones (RCE).