Interceptor

Jul 10, 2026

|

5 min read

| Room ↗

The hints are a bit misleading, there is indeed interception but I expected the full to be only that, anyway.

Recon

The usual full service scan.

nmap: service & script scan
nmap -sV -sC 10.128.180.39
result (trimmed)
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.2p1 Ubuntu
53/tcp open domain ISC BIND 9.16.1 (Ubuntu Linux)
80/tcp open http Apache httpd 2.4.41 ((Ubuntu))
| http-cookie-flags:
| /: PHPSESSID: httponly flag not set
|_http-title: MediaHub
MediaHub login page
The MediaHub login: an email/password form

The login, and a tempting dead end

The login form submits as multipart form-data to api_login.php, which answers with JSON like {"ok": false, "error": "..."}. One “intercept” move is to catch the response and flip it to {"ok": true}, and it does make the page think we logged in, but the next request bounces us straight back to login.php:

NOTE

Faking the response doesn’t fake the session. The client believing ok:true is meaningless, because access is gated server-side by the PHPSESSID session, which only api_login.php sets after a real credential check. Tampering the JSON on the way back changes nothing the server tracks. We need actual creds, so it’s time to enumerate.

I also tried forging a multipart value by adding an “ok: true” but it didn´t work.

Content discovery: a leaked backup

gobuster maps the app, and there’s plenty: login.php, otp.php, dashboard.php, config.php, an uploads/ folder, even phpmyadmin/. Everything interesting redirects to login.php, so the app clearly gates on that session. The winning move is a second pass looking for editor backup files:

gobuster: hunt for source backups
gobuster dir -u http://10.128.180.39/ -w dir.txt \
--exclude-length 1491 -x bak,php~,old,save,swp,txt
the hit
login.php.bak (Status: 200) [Size: 2038]

login.php.bak serves its raw source, and the top of the file is a developer note that should never have shipped:

login.php.bak (the developer note)
/*
| Admin test account for staging environment
| Email: admin@mediahub.thm
|
| Password policy reminder:
| Admin password follows company format:
| MediaHub + any year
|
| TODO: remove before production deployment
*/

An admin email and the exact password recipe: MediaHub followed by a year.

Brute-forcing the admin

A password format that narrow is a tiny keyspace. I generated one candidate per year and let Burp Intruder try them against api_login.php:

build the candidate list
printf "%s\n" MediaHub{2000..2026} > pass.txt
Burp Intruder loaded with the MediaHub+year list
Sniper attack on api_login.php: email fixed to admin@mediahub.thm, password position fed the 27 MediaHub candidates

One candidate comes back different, and the JSON tells us we’ve cleared the first gate but hit a second:

login accepted, OTP required
{"ok":true,"message":"Login success. OTP required.","redirect":"otp.php"}

Bypassing the OTP with a forged field

otp.php wants a one-time code we don’t have, and guessing returns {"ok":false,"error":"Invalid OTP. Try again.","is_verified":false}. That is_verified field in the response is the tell. This time the trick that failed at login works here, because the verification endpoint trusts a value the client sends. Intercepting the verify_otp.php request and adding two fields to the multipart body, is_verified=true and ok=true, marks the session verified without ever knowing the code:

Burp intercept adding is_verified=true to the verify_otp.php multipart request
The verify_otp.php POST with injected multipart parts is_verified=true and ok=true

NOTE

Mass assignment on the verification step. Where api_login.php computed trust from real credentials, verify_otp.php reads is_verified straight out of the request and writes it to the session. So a field the attacker controls decides whether the OTP passed. Login was server-authoritative, OTP verification was not, and that inconsistency is the whole bug.

Forwarding it lands us on the dashboard as admin, with the first flag dropped into the top bar:

MediaHub admin dashboard after the OTP bypass
The authenticated dashboard: the first flag in the header, plus profile-picture upload and an Import Feed tool

The Import Feed: SSRF behind an allow-list

The dashboard’s Import Feed takes an RSS/Atom URL and “the server fetches it and returns the raw output”, a server-side fetcher, which is SSRF territory. External URLs report Internet not connected, and pointing it at localhost or private ranges is blocked. But the allow-list is filtering on the string, so the decimal encoding of 127.0.0.1 (2130706433) sails through and fetches the box’s own web server:

Import Feed fetching http://2130706433 returns the localhost Apache page
http://2130706433 (decimal 127.0.0.1) bypasses the allow-list and fetches localhost, returning the local Apache 400 page

curl argument injection to a local file read

Watching the responses, the fetch is clearly curl under the hood. Feeding it two things separated by a semicolon shows each token becomes its own curl target, and only the first is checked against the allow-list:

Import Feed with http://2130706433/; whoami producing two curl runs
Input '...; whoami' runs curl twice; the second errors with 'Could not resolve host: whoami', proving each token is passed to curl

whoami fails because curl treats it as a hostname, but that proves the primitive: I can add a second curl argument that isn’t vetted. curl speaks the file:// scheme, so a local-file URL reads straight off disk:

the payload
http://2130706433/; file:///var/www/user.txt

The first token satisfies the allow-list, the second makes curl read /var/www/user.txt, and the flag comes back in the output:

Import Feed reading /var/www/user.txt via curl file://
The file:// argument makes curl read /var/www/user.txt, disclosing the second flag (redacted)

Second flag, box done. 🚩

Takeaways

  • Never ship editor backups. login.php.bak handed over the admin email and the exact password format. A .bak sitting next to a .php serves as plain text; keep them off the webroot entirely.

  • Don’t read trust from the request. verify_otp.php believed a client-supplied is_verified=true. Verification flags must be computed and stored server-side, never accepted from the body.

  • Allow-lists must resolve, not string-match. Blocking localhost while allowing 2130706433 is no block at all. Validate the resolved IP against internal ranges, and if you must shell out to curl, pass a single validated URL with --, never attacker-splittable input.

Kill Chain

Reconnaissance T1595 · T1046
nmap: ssh, DNS, Apache MediaHub
PHPSESSID served without HttpOnly
Discovery T1595.003
gobuster maps the app
backup scan finds login.php.bak
Credential Access T1552.001 · T1110.001
dev note leaks admin + password format
Intruder brute MediaHub+year
Initial Access T1190
forge is_verified=true, skip the OTP
admin dashboard, first flag
Exploitation T1190
Import Feed SSRF via curl
decimal IP bypasses the allow-list
Collection T1005
curl argument injection, file:// read
/var/www/user.txt, second flag
web ssrf auth-bypass burp information-disclosure