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 -sV -sC 10.128.180.39PORT STATE SERVICE VERSION22/tcp open ssh OpenSSH 8.2p1 Ubuntu53/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
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 dir -u http://10.128.180.39/ -w dir.txt \ --exclude-length 1491 -x bak,php~,old,save,swp,txtlogin.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:
/*| 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:
❯ printf "%s\n" MediaHub{2000..2026} > pass.txt
One candidate comes back different, and the JSON tells us we’ve cleared the first gate but hit a second:
{"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:
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:
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:
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:
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:
http://2130706433/; file:///var/www/user.txtThe first token satisfies the allow-list, the second makes curl read /var/www/user.txt, and the flag comes back in the output:
Second flag, box done. 🚩
Takeaways
-
Never ship editor backups.
login.php.bakhanded over the admin email and the exact password format. A.baksitting next to a.phpserves as plain text; keep them off the webroot entirely. -
Don’t read trust from the request.
verify_otp.phpbelieved a client-suppliedis_verified=true. Verification flags must be computed and stored server-side, never accepted from the body. -
Allow-lists must resolve, not string-match. Blocking
localhostwhile allowing2130706433is no block at all. Validate the resolved IP against internal ranges, and if you must shell out tocurl, pass a single validated URL with--, never attacker-splittable input.