Feb 12, 2026·6 min read·7 visits
A DoS vulnerability in `webtransport-go` allows attackers to hang server sessions indefinitely by withholding QUIC flow control credits during closure. This causes the server's `CloseWithError` function to block forever, leading to goroutine leaks and resource exhaustion. Fixed in v0.10.0.
In the world of async networking, shutting down a connection is supposed to be a graceful handshake. But for `webtransport-go` prior to v0.10.0, it became a death grip. A vulnerability in the session closure logic allows a malicious peer to perform a Denial of Service (DoS) by simply refusing to acknowledge the end of the conversation. By withholding QUIC flow control credits on the critical `CONNECT` stream, an attacker can force the server to wait indefinitely for a write permission that never comes. The result? A massive leak of goroutines and memory, silently suffocating the application while it politely waits to say 'goodbye'.
WebTransport is the cool new kid on the block, designed to replace the aging WebSockets with something built on the robust, multiplexed foundation of HTTP/3 and QUIC. It promises low latency, unreliable datagrams for gaming, and reliable streams for data. Ideally, it’s the future. But like many modern protocols, it relies heavily on the cooperative nature of its underlying transport: QUIC.
Here’s the thing about QUIC: it uses a credit-based flow control system. If I want to send you data, I have to wait until you tell me you have space for it. It's polite. It prevents fast senders from overwhelming slow receivers. But what happens when that politeness is weaponized? What if I, the malicious client, initiate a session, exchange some data, and then—right when you try to hang up—I simply stop processing? I don't close the connection. I just stop issuing 'credits'.
In webtransport-go (before v0.10.0), this scenario wasn't handled gracefully. It was a deadlock trap. The server, trying to be a good citizen, attempts to send a WT_CLOSE_SESSION capsule to formally end the session. It asks the underlying QUIC stream, 'Can I write this?' The stream says, 'Wait for credit.' And the server waits. And waits. And waits. Meanwhile, that goroutine is pinned, that memory is allocated, and the server is slowly bleeding out resources, all because it’s too polite to just slam the phone down.
To understand this bug, you have to look at how WebTransport layers over HTTP/3. A WebTransport session is essentially bootstrapped over an HTTP/3 CONNECT stream. This stream is the control channel. When you want to close the session, the protocol dictates sending a specific capsule: WT_CLOSE_SESSION.
The vulnerability lies in the assumption that the CONNECT stream is always writable during the shutdown phase. The code in CloseWithError was written synchronously: it constructs the close capsule and attempts to write it to the stream.
However, in the QUIC layer, writing to a stream is a blocking operation if the flow control window is exhausted. If the peer (the attacker) decides to set the MAX_STREAM_DATA for that stream to exactly the number of bytes already received and not a single byte more, the sender is paralyzed. The Go runtime sees a blocking network call and parks the goroutine, waiting for a signal that the window has updated. Since the attacker has no intention of sending that update, the signal never comes. The goroutine enters a comatose state, never to wake up, yet still consuming stack memory and referencing the session context.
The smoking gun resides in how CloseWithError interacts with the underlying stream writer. While the exact pre-patch code is complex, the logic flowed roughly like this:
func (s *Session) CloseWithError(code SessionErrorCode, msg string) error {
// ... logic to prepare close capsule ...
// CRITICAL FLAW: This Write call blocks if flow control is 0.
// There is no timeout here, and it relies on the peer.
if err := s.sendCloseSessionCapsule(code, msg); err != nil {
return err
}
// ... cleanup ...
return nil
}The fix, landing in commit 9d448b125754f4c83064afb2c586221214e55eec, fundamentally changes how stream closure and cancellation interact. The maintainers introduced smarter handling using the QUIC Stream Resets logic and decoupled the closure signaling from the strict flow control dependency.
Instead of blindly blocking, the patched version ensures that if the stream allows for partial delivery or if the write is blocked, the session tear-down can still proceed. It treats the WT_CLOSE_SESSION transmission as a "best effort" or at least one that shouldn't hold the application hostage. They effectively said: "If we can't tell them we're leaving, just leave."
Exploiting this is trivially easy for anyone who can craft raw QUIC frames. You don't need a buffer overflow or a heap groom. You just need to be stubborn.
Here is the attack chain:
CONNECT stream is established.CONNECT stream. Effectively, the attacker tells the server: "my receive buffer is full."CloseWithError. It tries to write the WT_CLOSE_SESSION capsule. The QUIC layer sees 0 credits. It blocks.This diagram illustrates the deadlock:
Repeat this 10,000 times, and the server's scheduler collapses under the weight of thousands of dormant goroutines.
This vulnerability is a classic asymmetric DoS. The attacker spends negligible resources—maintaining a few open UDP sockets is cheap. The server, however, pays a heavy price.
In Go, a goroutine is lightweight, starting at around 2KB of stack space. That sounds small. But associated with that goroutine is the Session object, buffers, and potentially application-level state attached to the context. If an attacker opens 50,000 connections and hangs them all, you aren't just losing 100MB of RAM. You are clogging the Go runtime scheduler and exhausting file descriptors (if the implementation uses them per connection, though QUIC is UDP, the internal maps grow).
This isn't just about crashing the server. It's about degrading it to the point of uselessness. Legitimate users will find the server unresponsive or extremely slow as the garbage collector struggles and the runtime manages an ever-growing list of blocked routines. It’s a silent, creeping death for the application.
The remediation is straightforward: Update to webtransport-go v0.10.0.
The patch effectively implements a "rude" shutdown when the polite one isn't possible. By enabling partial delivery and fixing the interaction between CancelWrite and Close, the library now correctly identifies when a stream is effectively dead or blocked and cleans up the resources without waiting for the peer's permission.
For developers unable to patch immediately (though you really should), mitigation is difficult. You could implement aggressive deadlines on the quic.Connection level, killing the entire UDP connection if it stalls, but that's a blunt instrument that might affect legitimate users on poor networks. The code fix is the only true solution.
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L| Product | Affected Versions | Fixed Version |
|---|---|---|
webtransport-go quic-go | < 0.10.0 | v0.10.0 |
| Attribute | Detail |
|---|---|
| CWE ID | CWE-400 (Uncontrolled Resource Consumption) |
| CVSS v3.1 | 5.3 (AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L) |
| Attack Vector | Network (QUIC Stream Manipulation) |
| Impact | Denial of Service (Goroutine/Memory Leak) |
| Status | Fixed in v0.10.0 |
| Exploit Maturity | Proof of Concept (Trivial) |
The product does not properly control the allocation and maintenance of a limited resource, enabling an actor to influence the amount of resources consumed, eventually leading to exhaustion.