
Reproduces aiohttp CWE-444 request smuggling via rejected WebSocket upgrades, with Python/Rust payloads and Docker lab demonstrating proxy access-control bypass.
Request smuggling via rejected WebSocket upgrade in aiohttp < 3.14.2.
When a reverse proxy forwards Connection: Upgrade + Upgrade: websocket
headers, the vulnerable aiohttp parser skips the request body and interprets
trailing bytes as a pipelined request — bypassing edge access controls.
Fixed in aiohttp 3.14.2 (commit 6ae358f).
Author: João Victor Botelho (JV Botelho) — https://glitchedcat.com
Copy-paste the Python version (zero dependencies, stdlib only):
curl -O https://raw.githubusercontent.com/JVBotelho/cve-2026-69243-poc-aiohttp-smuggling/main/poc.py
python3 poc.py <proxy-host> <proxy-port> <backend-host>
Or build the Rust version (zero dependencies, std only):
git clone https://github.com/JVBotelho/cve-2026-69243-poc-aiohttp-smuggling
cd cve-2026-69243-poc-aiohttp-smuggling/poc
cargo build --release
./target/release/cve-2026-69243-poc <proxy-host> <proxy-port> <backend-host>
Prebuilt binaries will be published to Releases via the release workflow.
Both produce byte-identical payloads (enforced by a CI parity test). If the lab is running:
python3 poc.py nginx-upgrade 80 backend-vuln
Expected output: 1 HTTP response (WebSocket upgrade rejected). Then verify:
# Backend processed 2 requests (/ws + smuggled /admin):
docker logs backend-vuln | grep -c '"path".*"/admin"'
# Nginx only logged 1 request (the /ws):
docker exec nginx-upgrade cat /logs/nginx-upgrade.access.log | grep -c '/admin'
The backend count > 0 and Nginx count = 0 means CWE-444 split confirmed. This PoC sends a single TCP segment — the body is the smuggled request; Nginx treats it as body, aiohttp treats it as a second request.
_http_parser.pyx returns 2 (skip body)
on upgrade detection before the body is consumed (line ~863). Body bytes
remain in _message_tail and are fed back to the parser in web_protocol.py
finish_response (line ~771).location /admin { deny all; },
the smuggled /admin still reaches the backend because Nginx routing
decisions are made on the outer request only.await request.read() returns 0 bytes on upgrade
requests in 3.14.1 — the body is withheld below the handler layer. Patch,
or strip upgrade headers at the proxy on routes that must not switch
protocols.git clone https://github.com/JVBotelho/cve-2026-69243-poc-aiohttp-smuggling
cd cve-2026-69243-poc-aiohttp-smuggling
docker compose up -d
# Run the PoC:
docker compose run --rm --entrypoint /app/poc attacker nginx-upgrade 80 backend-vuln
Services:
| Container | Purpose |
|---|---|
backend-vuln | aiohttp 3.14.1 (vulnerable), READ_BODY=false |
backend-vuln-read | aiohttp 3.14.1, READ_BODY=true (proves handler can't help) |
backend-patched | aiohttp 3.14.2 (fixed) |
nginx-upgrade | Forwards upgrade headers (deny all on /admin) |
nginx-default | No upgrade forwarding (neutralizes the bug) |
nginx-strip | Connection "" strip (neutralizes the bug) |
attacker | Rust binaries: reproduce, fase2, poc |
Full Phase 1-3 findings in findings/.
Connection: close to the backend or strip Connection/Upgrade
headers are not vulnerable. The config that exposes the split is the
canonical WebSocket map snippet from Nginx's own proxying documentation./ws response. Evidence of
smuggling is in the backend logs, not in the attacker response — a blind,
one-way primitive in this topology. Other proxy topologies were not tested.Transfer-Encoding: chunked does NOT smuggle — the raw chunk-size line
(e.g. 3e) hits the parser as an invalid method and the connection dies.
Through Nginx it works, but via normalization: Nginx de-chunks the body
and forwards a synthesized Content-Length, so the backend is exploited
through the same CL path. The --chunked flag demonstrates the proxy path.WebSocketResponse. Most apps that don't use
WebSockets on a route will reject by default (framework returns 404
or falls through to the next handler).poc/ # Rust cargo project
├── Cargo.toml
├── src/main.rs # CLI binary
├── src/lib.rs # Library + unit tests
├── tests/parity.rs # Cross-language payload parity test
└── fuzz/ # cargo-fuzz targets
poc.py # Python PoC (copy-paste from blog)
attacker/ backend/ frontend/ # Docker lab services
docker-compose.yml # 7-service lab
findings/ # Research notes (Phase 1-3)
.github/workflows/
├── ci.yml # Build, test, clippy, parity, integration, fuzz
└── release.yml # Cross-compile + GitHub Release
See findings/fase3-deteccao.md for the full analysis. Summary, with the
caveats that matter in production:
X-Forwarded-For/Proxy Protocol, or logging an
upstream connection ID + per-connection request sequence. Client IP +
time window alone is weak (NAT, keep-alive, concurrency).content_length vs bytes from
request.read()): fires on 3.14.1, quiet on 3.14.2. Detects, does not
mitigate. Scope it (upgrade-candidate routes, small bodies, non-101
statuses) — the naive version buffers every body in memory.reqlen above header baseline on WebSocket endpoints — low
confidence alone (cookies/JWT/tracing headers are noisy), but the only
edge signal when the client sends no Content-Length (chunked ingress).