The layout

Three services, only one of them reachable.

  • A custom TCP proxy on 8082. This is the only exposed port.
  • The app on 3000, internal.
  • A JWKS server on 5000, internal.

Everything I send has to go through the proxy, and the flag is at /admin/flag on the app. Three separate weaknesses stack into one request, so I’ll take them in the order I had to solve them: get the JWKS, forge a token, spend it.

The verifier trusts the token

Start at the end, because it tells you what you need. The flag route:

app.get('/admin/flag', requireAuth, (req, res) => {
  if (req.user && req.user.role === 'admin') {
    return res.json({ flag: FLAG });
  }
  return res.status(403).json({ error: 'admins only' });
});

So I need a token with role: admin. Look at how tokens are verified:

async function verifyToken(token) {
  const decoded = jwt.decode(token, { complete: true });
  const alg = decoded.header.alg;
  const { pem, hmacSecret } = await getKeys();
  if (alg === 'RS256') return jwt.verify(token, pem, { algorithms: ['RS256'] });
  if (alg === 'HS256') return jwt.verify(token, hmacSecret, { algorithms: ['HS256'] });
  throw new Error('unsupported alg');
}

This is the classic algorithm-confusion setup. The server reads the algorithm out of the token header, which the attacker writes, and then picks the key to match. RS256 verifies against the public PEM. HS256 verifies against a symmetric secret. And the secret is this:

const hmacSecret = (data.keys[0].n || '').toString();

The n field from the JWKS. In a normal RSA setup n is the public modulus, not a secret at all. Here it’s being used as an HMAC key. So if I know n, I can sign an HS256 token myself and the server will happily verify it with the same value. The only thing standing between me and an admin token is reading n, and n lives on an internal server I can’t reach.

The SSRF that can read it

The app has a fetch endpoint that would hand me the JWKS:

app.get('/debug/fetch', async (req, res) => {
  const xff = (req.headers['x-forwarded-for'] || '').toString();
  const fromProxy = xff.includes('127.0.0.1') || req.ip === '127.0.0.1';
  if (!fromProxy) return res.status(403).json({ error: 'forbidden' });
  const url = req.query.url;
  // ... fetch(url) and return the body
});

Point it at http://localhost:5000/.well-known/jwks.json and it returns the JWKS, n included. Two problems. It only answers requests that look like they came from the proxy, which I can fake with an X-Forwarded-For: 127.0.0.1 header. And the path starts with /debug, which the proxy blocks.

The proxy blocks the wrong string

The proxy pulls the path out of the request line and decides whether to block:

let checkPath = path || '';
try {
  if (/^https?:///i.test(checkPath)) {
    const u = new URL(checkPath);
    checkPath = u.pathname || '';
  }
} catch (_) {}
if (checkPath.toLowerCase().startsWith('/debug')) {
  // 403 forbidden
}

Then, if it didn’t block, it forwards the raw bytes I sent straight to the app on 3000.

Read the try carefully. If my request target is an absolute URI like http://host/debug/..., the proxy runs new URL() on it and reduces checkPath to the pathname, which is /debug/..., and blocks me. But if new URL() throws, the catch swallows it and checkPath keeps its original value, the full http://... string, which does not start with /debug. So it isn’t blocked.

The catch is that the proxy and the backend disagree about what a valid URL is. WHATWG new URL() in the proxy is strict. Node’s HTTP server on the app is not, and it’s perfectly happy to route an absolute-form request target by its path. That gap is the whole exploit. I need a request target that makes new URL() throw but that the backend still parses as /debug/fetch:

GET http:// /debug/fetch?url=http://localhost:5000/.well-known/jwks.json HTTP/1.1
Host: target:8082
X-Forwarded-For: 127.0.0.1
Connection: close

The space after http:// breaks the strict parser, so the proxy’s new URL() throws, checkPath stays as the raw string, and the /debug check passes it through. The backend receives the same bytes, parses out /debug/fetch, sees the forged X-Forwarded-For, and fetches the internal JWKS for me. It comes back through the proxy with n in it.

I sent this with a raw socket rather than a normal HTTP client, because the point is to control the exact request line and stop any library from normalizing my deliberately-broken URL.

Spending it

With n in hand, the rest is arithmetic. Forge an HS256 token, key it with n, set the role:

import jwt
secret = n_from_jwks
token = jwt.encode({'role': 'admin'}, secret, algorithm='HS256')

Then present it to the flag route, through the proxy, since that’s still the only door:

curl http://target:8082/admin/flag -H "Authorization: Bearer <token>"

The verifier reads alg: HS256 from my header, picks the HMAC branch, checks my token against n, which is exactly what I signed with, and the check passes. The route sees role: admin and returns { "flag": ... }.

Why it worked

None of the three bugs is enough alone. The JWT verifier lets the token choose its own algorithm, which is only dangerous because the HMAC secret is a value (n) that’s meant to be readable. Reading it needs the SSRF, which is only reachable because the proxy and the backend parse URLs differently. Pull any one of those and the chain breaks: pin the algorithm to RS256, or key the HMAC with something actually secret, or make the proxy and the app agree on what a URL is. It’s a good reminder that a parser differential between two hops is a vulnerability in the seam, even when neither service is wrong on its own terms.