Category
HackTheBox Challenges
Web challenge writeups from HackTheBox — a broad mix of server- and client-side bugs: nginx cache poisoning, Next.js SSRF + Jinja2 SSTI, Go zip-slip session forgery, PHP POP chains and php-cgi argument injection, H2 SQL→RCE, Mongoose prototype pollution, Tornado object-walk gadget, and a TensorFlow Lambda-layer RCE.
Notes
16 totalCDNio — nginx extension-based cache poisoning steals the admin's profile
A weak Django-style regex (.*^profile) lets /profile.gif hit the authenticated profile JSON. nginx caches any *.gif response for 3 min regardless of auth, so make the admin bot fetch /profile.gif, then read the cached admin api_key from the same URL with no token.
Dark Runes — register-as-admin → entity-encoded iframe survives sanitize-html → markdown-pdf LFI
No admin row is seeded, so registering username 'admin' grants isAdmin. The PDF export pipeline stores HTML through sanitize-html (strips <iframe>) but node-html-markdown later decodes HTML entities, so a double-encoded <iframe src=file:///flag.txt> is reborn as a live tag and markdown-pdf (remarkable html:true) renders it, reading local files into the PDF.
Desires — zip-slip via symlink + Redis-before-login race to forge an admin session
Sessions are JSON files at /tmp/sessions/<user>/<id> loaded by username→id from Redis. A tar/zip symlink entry (sessions -> /tmp/sessions/<user>) turns extraction into write-what-where, planting a role:admin session file. Because login sets the Redis username→sessionID mapping BEFORE checking the password, a deliberately-failed login binds your forged file without overwriting it — then manual cookies hit /admin as admin.
Doxpit — Next.js Server-Action SSRF (CVE-2024-34351) → internal Jinja2 SSTI RCE
A Next.js redirect() Server Action trusts the Host header (CVE-2024-34351), so a forged Host + attacker redirect server turns the app into an SSRF proxy onto the internal AV service on localhost:3000. That Flask app feeds ?directory= into render_template_string with a brutal blacklist (no _ . [ ] {{ }} x \\); rebuild __globals__→__builtins__→__import__('os').popen via {% %} blocks, request|attr(), and GET params smuggling the banned strings.
Dusty Alleys — leak the secret vhost via Host-less HTTP/1.0, then SSRF the guardian's flag header back to itself
The /guardian route only exists on the random guardian.<SECRET_ALLEY> vhost. Leak the domain by sending an HTTP/1.0 request with no Host header so nginx's default_server reflects alley.<SECRET_ALLEY> via the header-echo /think endpoint. Then call /guardian?quote=http://localhost:1337/think — guardian fetches that URL with the flag in a Key request header, and /think echoes all headers, exposing the flag.
Flag Command — hidden game option exposed by the /api/options endpoint
A text-adventure game validates commands client-side, but the full command tree (including a 'secret' branch) is served by /api/options. Read the secret command from the API and type it into the game to print the flag.
JerryTok — Twig SSTI |sort('file_put_contents') drops a CGI script + .htaccess for RCE
A Symfony page builds a Twig template from ?location= (createTemplate), giving SSTI inside a disable_functions/open_basedir-hardened PHP. Twig's |sort accepts a 2-arg PHP callable comparator, so |sort('file_put_contents') writes arbitrary files: plant a .cgi /readflag script and a .htaccess enabling ExecCGI (AllowOverride All), chmod it via |sort('chmod') (octal!), then request the CGI to run outside the PHP sandbox.
Magical Palindrome — JSON object defeats a JS palindrome check (length<1000 + index math)
The checker rejects strings shorter than 1000 and compares string[i] vs string[len-i-1]. Send palindrome as an object {'NaN':'','0':''} so obj.length is undefined (undefined<1000 is false, passing the length gate), Array(undefined).keys() yields one i=0, and obj[0] === obj[NaN] === '' (both strings) — every check passes and the flag prints, all under nginx's 75-byte body cap.
NextPath — multi-value query bypasses includes() traversal filter, /proc/thread-self/root fixes the 100-char slice
/api/team?id= validates with /^[0-9]+$/m (multiline → a newline-separated digit line passes) and blocks '/' and '..' via String.includes — but Next.js turns repeated ?id= into an ARRAY, whose .includes() checks elements, not substrings. The read path is sliced to 100 chars, so naive ../ collapses; use the even-length /proc/thread-self/root symlink to land exactly on /flag.txt within the byte budget.
Pentest Notes — UNION SQLi in H2 → CREATE ALIAS Java UDF for RCE
The /note search concatenates the name into a native query (filtering only '$' and 'concat'). It's an H2 database, so beyond UNION SELECT you can stack 'CREATE ALIAS ... AS '<java>'' to define a Java UDF wrapping Runtime.exec, then UNION SELECT that function to run commands and read the randomly-named flag.
POP Restaurant — PHP unserialize POP chain (Pizza→Spaghetti→IceCream→ArrayHelpers) to call_user_func RCE
order.php runs unserialize(base64_decode($_POST['data'])) on attacker input. Chain four magic methods — Pizza::__destruct echoes $size->what → Spaghetti::__get invokes ($sauce)() → IceCream::__invoke foreach over $flavors → ArrayHelpers (extends ArrayIterator)::current() runs call_user_func($callback,$value). Set callback=passthru to execute commands, output reflected via Apache stdout.
Screencrack — gopher SSRF to unprotected Redis injects a Laravel queue job → command injection RCE
The screenshot feature fetches user URLs; gopher://127.0.0.1.nip.io:6379 bypasses the localhost check and speaks RAW Redis. RPUSH a hand-crafted Laravel job envelope onto laravel_database_queues:default whose rmFile job runs system(\"rm \".$uuid) — inject through $uuid for command execution when the queue:work worker picks it up.
Secure Notes — Mongoose $rename prototype pollution (CVE-2023-3696) poisons socket.remoteAddress for SSRF-less localhost bypass
/update passes req.body straight into findByIdAndUpdate, so a {$rename:{content:'__proto__._peername'}} update pollutes Object.prototype (CVE-2023-3696). Set Object.prototype._peername='x' and address='127.0.0.1' so net.Socket.remoteAddress getter returns 127.0.0.1 for the /flag route's localhost check — reading the flag with no real localhost request.
Spookifier — Mako SSTI via the ?text= font converter → local file read
The 'spookify' font tool renders user input through a Mako template (flask-mako), so ${7*7} → 49. Mako exposes Python directly, so ${open('../../flag.txt').read()} reads the flag — no import gymnastics needed.
Tornado Service — postMessage XSS bypasses PNA, then an update_tornado object-walk overwrites the USERS global
report_tornado sends a Selenium bot to an attacker page. PNA blocks direct fetches to 127.0.0.1, so window.open the app and postMessage a tornado whose status field is rendered with innerHTML (XSS in the loopback origin). That XSS POSTs /update_tornado, whose recursive update_tornados() does getattr-walking — {__class__:{__init__:{__globals__:{USERS:[...]}}}} rewrites the module-global USERS so you can log in and GET /stats for the flag.
WhyLambda — complaint innerHTML XSS + header-only CSRF → upload a TensorFlow Lambda-layer model for RCE
The complaint 'prediction' field is rendered via innerHTML (img onerror XSS) to a Selenium admin who logs in. CSRF is just an X-SPACE-NO-CSRF:1 header, so the XSS can hit authenticated endpoints. Upload a Keras .h5 whose Lambda layer embeds Python; test_model() calls keras.models.load_model() which executes the Lambda on load — os.system copies /app/flag.txt into the web-served models dir, then exfiltrate.