# Traefik: Cross-user response poisoning via proxied CONNECT on Traefik's shared backend keep-alive pool

Traefik forwards H2/H3 CONNECT bodies unframed to HTTP/1.1 backends; a keep-alive non-2xx pools the desynced socket, letting an attacker poison another user's response.

- Payout: Research
- Severity: high
- Program: Traefik
- Found by: xclow3n
- Disclosed: 2026-08-06
- CVE: CVE-2026-71324
- Write-up quality: 9/10
- Original: https://github.com/advisories/GHSA-3ccp-42pg-hgv6
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-3ccp-42pg-hgv6
- Tags: area:cloud domain:infra impact:data-leak impact:integrity kind:research platform:ghsa sev:high tech:go tech:python technique:parser-differential technique:source-review vuln:cache-deception vuln:request-smuggling

**Target:** HTTP reverse proxy (httputil.ReverseProxy over shared net/http.Transport)
**Root cause:** net/http writes a CONNECT body unframed (no Content-Length/Transfer-Encoding) and returns the socket to the shared idle pool after a keep-alive non-2xx, while trailing bytes are parsed by the backend as a pipelined request whose response is still pending. Traefik's default proxy forwards client CONNECT without rejection and sanitizePath only rewrites the target, not the defect.
**Steps:**
1. Attacker opens an HTTP/2 or HTTP/3 `CONNECT` to Traefik and sends a raw HTTP/1.1 request such as `GET /delay/2?tag=ATTACKERSMUGGLED` as the CONNECT body, then half-closes the stream (END_STREAM).
2. Traefik forwards the CONNECT (body included) to an HTTP/1.1 upstream over the shared `net/http.Transport`; default `sanitizePath` rewrites the target to `CONNECT /`.
3. Backend (Go net/http, gunicorn/Flask) answers a keep-alive non-2xx (e.g. 405) without draining the body and parses the trailing GET as a pipelined request, queuing its response on the socket.
4. net/http returns the socket to Traefik's shared idle pool once the CONNECT body reaches EOF.
5. Victim sends `GET /get?tag=VICTIMOWN`; Traefik reuses the pooled backend socket and the victim reads the queued `/delay` response instead of its own.
**How found:** Recognized Traefik's default proxy is `httputil.ReverseProxy` over a shared `http.Transport`, inheriting the same root cause as the Caddy `reverse_proxy` CONNECT pool poisoning; then tested whether Traefik's `sanitizePath` mitigation held by enumerating backend behavior for `CONNECT /` vs authority-form CONNECT across stock images.
**Exploitation:** Unauthenticated attacker needs only an H2/H3 frontend and a backend that keeps the connection alive on a non-2xx CONNECT. The smuggled request targets a slow endpoint so its response is still pending when the socket is pooled; a converging victim reusing that socket reads the attacker's smuggled response (or, with a bounded pool, another user's response). Cascade measured with `MaxIdleConnsPerHost 1`: 8 of 8 sequential victims read a response not their own (1 attacker's, 7 another user's).
**Escalation:** Bounded pool (`MaxIdleConnsPerHost 1`) turns one desync into a queue shift poisoning many sequential victims; ForwardAuth with `forwardBody: true` and `preserveRequestMethod: true` poisons the shared auth-client pool the same way.
**Insight:** Path-normalization mitigations don't fix CONNECT desync: the discriminator is whether the backend answers a keep-alive non-2xx and pipelines trailing bytes (Go net/http, gunicorn/Flask) versus closing (Apache, nginx, Tomcat, Node). Test both `CONNECT /` and authority-form, and check whether the proxy's own alternative implementation (FastProxy chunk-framing) avoids the bug — that isolates the defect to the httputil/net/http path.

## Proof of concept (copied exactly from the original report)

**Attacker H2/H3 CONNECT smuggling raw GET as body**

```
The attacker opens an H2 or H3 `CONNECT` to Traefik and sends a raw HTTP/1.1
`GET /delay/2?tag=ATTACKERSMUGGLED` as the CONNECT body, then half-closes the stream. Traefik
forwards the CONNECT to the Go/Python backend, the backend answers a keep-alive non-2xx, keeps the
socket, and parses the trailing GET as a pipelined request, so a response to it is queued on that
socket. `net/http` returns the socket to Traefik's shared pool. The victim then sends
`GET /get?tag=VICTIMOWN` on its own connection, Traefik reuses the pooled backend socket, and the
victim reads the queued `/delay` response instead of its own. `CROSS` means the victim received a
```

**Expected PoC output showing CROSS results**

```
== core: DEFAULT config, cross-user poisoning vs real off-the-shelf backends ==
  [core-go-h2] h2->h2 CROSS
  [core-go-h3] h3->h3 CROSS
  [core-go-x] h2->h3 CROSS
  [core-py-h2] h2->h2 CROSS
  [core-py-h3] h3->h3 CROSS
== mechanism: sanitizePath off -> stock Apache 405 (the direct Caddy analogue) ==
  [mech-ap-h2] h2->h2 CROSS
  [mech-ap-h3] h3->h3 CROSS
== controls: must NOT cross ==
  [ctl-apache] h2->h2 NO_CROSS
  [ctl-pooloff] h2->h2 NO_CROSS
  [ctl-kaoff] h2->h2 NO_CROSS
== safe variant: experimental FastProxy chunk-frames the CONNECT body ==
  [safe-fast] h2->h2 NO_CROSS
== cascade: bounded pool, one desync poisons a queue of victims ==
  smuggled=1 other_user=7 own=0 of 8 (cross-user poisoned=8)
RESULT: PASS
```

**Victim evidence: received smuggled /delay response**

```
The captured crossing (`poc/evidence/RELEASE_v3.6.23_victim.json`): the victim sent
`GET /get?tag=VICTIM_OWN` and received a 200 whose body is the response to
`GET /delay/2?tag=ATTACKER_SMUGGLED` with the echoed header `X-Smuggled: released-v3.6.23`, none of
which the victim sent.
```

