WebTransport Zombie Connections: RFC 9114 Trailer Frame Triggers Permanent Stream Leak & OOM in Microsoft Edge and Chromium
Background
WebTransport is a browser API that provides bidirectional, low-latency communication over HTTP/3. Under the hood, each WebTransport session is established as an HTTP/3 CONNECT stream — a persistent QUIC stream that the browser holds open for the duration of the session.
The quiche QUIC library, maintained by Google, is the shared H3 implementation used in both Chromium and Microsoft Edge. When a WebTransport session ends, the server is expected to close the CONNECT stream by sending a trailer HEADERS frame with END_STREAM=true. quiche’s stream state machine reads this flag and delivers a FIN, which causes the stream to be fully closed and removed from the active connection.
This post documents a bug in that state machine. When the server sends a trailer HEADERS frame with END_STREAM=0 — a violation of RFC 9114 §4.1, but one that a compliant implementation is expected to handle defensively — quiche enters a permanent zombie state. The stream is never closed, the QUIC connection is kept alive indefinitely, and the browser sends PING keepalives every ~15 seconds to maintain it. Because Edge and Chromium enforce no limit on simultaneous WebTransport connections, a single webpage can open thousands of zombie connections, growing Network Service memory by over 5,000% before triggering an OOM crash.
This vulnerability was reported to both Microsoft MSRC and Google Chrome VRP. Both vendors closed the report without a fix, citing availability-only impact. Given those decisions, I am publishing the full technical details here.
Disclosure Timeline
- August 16, 2026 — Report submitted to Microsoft MSRC
- August 16, 2026 — MSRC auto-acknowledgement, case opened
- August 26, 2026 — MSRC closed: “assessed as None severity — impact is limited to availability”
- August 16, 2026 — Report submitted to Google Chrome VRP in parallel
- August 16, 2026 — Chrome VRP closed same day by automated triage (
#sheepdog-wontfix-preliminary) - August 16, 2026 — Technical rebuttal submitted (comment #4)
- August 16, 2026 — Google replied 5 minutes later: “closed as incomplete or invalid — will not respond to further comments”
- September 3, 2026 — Public disclosure
What Is a WebTransport CONNECT Stream?
HTTP/3 WebTransport (RFC 9220) establishes sessions over extended CONNECT requests. The lifecycle of a CONNECT stream looks like this:
Client → Server: HEADERS (method: CONNECT, :protocol: webtransport)
Server → Client: HEADERS (status: 200) ← session open
[bidirectional data exchange]
Server → Client: HEADERS (trailers, END_STREAM=true) ← RFC 9114 §4.1
RFC 9114 §4.1 is explicit: “An HTTP message is complete when all the HEADERS frames associated with it have been received, and any DATA frames that have been sent carry the END_STREAM flag.” The final HEADERS frame — trailers — must carry END_STREAM=true to signal stream completion.
When END_STREAM=false appears on the trailer frame, the stream is in a state that the specification does not define. A hardened implementation rejects it immediately. quiche does not.
The Root Cause
File: third_party/quiche/src/quiche/quic/core/http/quic_spdy_stream.cc
Function: QuicSpdyStream::OnTrailingHeadersComplete()
|
|
When fin=false, trailers_decompressed_ is set to true but OnStreamFrame is never called. This means the sequencer never receives a FIN. The resulting state:
trailers_decompressed_ = true
sequencer()->IsClosed() = false ← FIN never arrived
This propagates up through the call chain:
QuicSpdyStream::IsDoneReading()
→ trailers_decompressed_=true
→ return sequencer()->IsClosed() ← always false [PERMANENT]
QuicSession::GetNumActiveStreams()
→ stream_map_.size() - num_draining - num_static - num_zombie
→ zombie CONNECT stream counted as active [PERMANENT]
QuicSession::ShouldKeepConnectionAlive()
→ GetNumActiveStreams() > 0 → true [PERMANENT]
→ Idle timeout never fires
→ Edge/Chromium sends QUIC PING every ~15s to maintain connection
The stream is permanently stuck. It is never removed from stream_map_. The QUIC connection is held open indefinitely.
Memory Exhaustion
Because the browser enforces no limit on simultaneous WebTransport connections, a single webpage can open thousands of zombie connections in a loop. Each zombie connection holds its stream state in memory for the lifetime of the process.
Measured impact (Edge 151.0.4129.86 / Windows 11 25H2):
| State | Network Service Memory |
|---|---|
| Baseline | 6.2 MB |
| After 5,000 zombies | 339.3 MB (+5,373%) |
| Per-connection cost | ~67 KB |
| OOM crash threshold | ~15,000 connections (~1 GB) |
At ~15,000 connections, the Network Service process crashes with an OOM condition. All browser tabs simultaneously lose network connectivity. The crash is unrecoverable without restarting the browser.
Heap dump verification is available via edge://memory-internals → Load trace → inspect Service: network.mojom.NetworkService → malloc allocator.
Proof of Concept
Requirements:
|
|
Browser launch:
|
|
Opening https://localhost:4433/ presents an interactive test page. Clicking “Test /zombie” causes wt.closed to never resolve — zombie state is active. “MASS Amplification (1000x)” opens 5,000 connections, growing Network Service memory from 6 MB to 339 MB.
Zombie verification — netlog:
|
|
Full PoC Server Code
The code below is long. It includes all test routes (
/zombie,/zombie-flood,/double-trailer,/datagram-zombie,/normal,/zombie-silent), the interactive HTML test page, and the mass amplification scenario.
|
|
Secondary Bug: Datagram State Confusion
After zombie state is established, server→client QUIC datagrams continue to be delivered to the JS WebTransport.datagrams.readable stream — even though the H3 layer considers the session closed. Client→server datagrams are correctly dropped. This creates an asymmetric cross-layer state desynchronization.
Observed in DevTools Console:
[PHASE A] pre-zombie datagram received ✓
[ZOMBIE] session entered zombie state
[PHASE B] datagrams received after zombie: 6/6 ✓ ← BUG
The session appears closed at the H3 level but remains a functional unidirectional data channel at the QUIC datagram layer. This is a persistent covert channel on a nominally closed session — data delivery continues without the session being counted as active by any browser API.
Real-World Attack Scenarios
Drive-by via ad or iframe: A malicious script embedded in an advertisement or iframe silently opens thousands of zombie WebTransport connections in a loop. The page visitor observes no UI indication. After ~40 seconds, the Network Service crashes and all open tabs lose network connectivity simultaneously.
Covert channel persistence: A server can continue delivering data to JS via datagrams on a zombie session. The session does not appear in the browser’s active connection list, does not trigger idle timeout, and persists across page navigations and unloads.
Cross-navigation survival: Zombie connections survive tab navigation, page reloads, and are unaffected by cookie, storage, or cache clearing — they are held at the Network Service process level, not the page level.
Via legitimate infrastructure: The attack is deployable through any compromised WebTransport-capable endpoint — CDN subdomains, ad networks, or third-party widget providers. No direct compromise of the visited page is required.
Vendor Response
Microsoft MSRC (case opened August 16, 2026):
“After careful review, this case is assessed as None severity. This case does not meet Microsoft’s definition of a security vulnerability and is below threshold for servicing. The reported behavior is a denial-of-service condition only. The affected code resides in the upstream quiche QUIC library rather than in Edge-specific code, and any reliability handling belongs to that upstream project.”
Google Chrome VRP (August 16, 2026):
Initial closure by automated triage (#sheepdog-wontfix-preliminary):
“Closing as infeasible as this issue does not meet security reporting criteria for Chrome. This describes a functional or stability issue without security impact. Please use the standard Chromium tracker.”
Technical rebuttal submitted (comment #4, 02:57 PM):
“This was closed as a stability issue, but the root cause is a security-relevant state machine violation: (1) RFC 9114 §4.1 requires trailer HEADERS to carry END_STREAM. Chrome silently accepts a violation and enters a permanent zombie state with no recovery path. (2) After zombie state, server→client QUIC datagrams continue to be delivered to JS on a session the H3 layer considers closed — cross-layer state desynchronization, a persistent covert channel on a logically closed session. (3) No connection limit enforcement: Chrome opens unlimited WebTransport connections, enabling memory amplification from a single webpage visit (measured: 6.2 MB → 339.3 MB at 5,000 connections). Requesting human security review.”
Google response five minutes later (comment #5, 03:02 PM):
“This issue has been closed as an incomplete or invalid report and we will not respond to further comments.”
The five-minute turnaround on a technical security rebuttal indicates the second response was also automated. The #sheepdog-wontfix-preliminary tag on the initial closure confirms no human review occurred before the report was closed.
Both vendors reached the same conclusion through different reasoning. MSRC’s position — that the code lives in upstream quiche — is technically accurate: the root cause is in quic_spdy_stream.cc, a shared library. Chrome VRP’s framing of it as a stability issue rather than a security issue reflects their current OOM policy for browser crashes.
Neither vendor indicated a fix is planned.
Fix
The fix is straightforward. OnTrailingHeadersComplete must handle the fin=false case explicitly:
|
|
Alternatively, a WebTransport-specific connection limit would cap the OOM impact regardless of the stream state bug.
Conclusion
A single-bit omission — END_STREAM=0 on a WebTransport trailer HEADERS frame — is sufficient to permanently strand a QUIC connection in quiche’s stream state machine. The browser holds the connection open, sends keepalives, and accumulates unbounded memory until an OOM crash takes down all network connectivity for the user. A secondary bug delivers datagrams to JS on sessions the H3 layer considers closed.
The root cause is in code that two major browser vendors share. Neither has committed to a fix.