The HTTP/2 CONTINUATION Flood: When Implementations Disagreed About Where to Count
Most of the protocol bugs I find interesting live in the same place: the spec describes what a valid message looks like, leaves one operational detail implicit, and two conforming implementations fill that detail in differently. Request smuggling is the boundary-of-a-message version of that. The HTTP/2 CONTINUATION flood, disclosed in 2024 by Bartek Nowotarski and coordinated through CERT/CC as VU#421644, is the same shape moved one layer down, into how a header block is framed. The implicit detail this time is not where a request ends. It is when you are allowed to stop trusting the client.
How HTTP/2 frames a header block
In HTTP/2 a request’s header fields do not arrive in one piece. They arrive as a HEADERS frame, optionally followed by any number of CONTINUATION frames, and the sequence is terminated by an END_HEADERS flag on the last frame in the run. RFC 9113 Section 6.10 is explicit that CONTINUATION frames carry a fragment of the field block and that the run continues until a frame sets END_HEADERS. The receiver has to buffer every fragment, because the whole thing is one HPACK-compressed field block and you cannot decode it until you have it.
There is a size control for this. SETTINGS_MAX_HEADER_LIST_SIZE (RFC 9113 Section 6.5.2) lets an endpoint advertise the largest field list it is willing to process. The spec describes it as an advisory limit on the uncompressed size of the header list. What the spec does not say, in so many words, is at which moment the receiver must enforce it. That omission is the whole vulnerability.
The flood
The attack is almost embarrassingly small to describe:
Step 1: open a stream, send a HEADERS frame, do NOT set END_HEADERS.
Step 2: send a CONTINUATION frame. Do not set END_HEADERS.
Step 3: send another CONTINUATION frame. Do not set END_HEADERS.
Step 4: keep going. Never set END_HEADERS.
The server is now holding a header block that never closes. If it accumulates fragments into a buffer and only checks the field-list size when the block is complete, that check never runs, because the attacker never completes the block. Memory climbs, or CPU burns on repeated HPACK state updates, and one cheap connection can pressure the whole process. A handful of these in parallel is a denial of service that costs the attacker almost nothing to mount.
The part I find worth dwelling on is the second-order effect. A normal request-flood at least produces requests, which produce log lines. The CONTINUATION flood produces no completed request on the affected path, because the request never finishes parsing. On several stacks that meant the traffic did not show up in access logs at all. The attack was not just cheap; on some servers it was quiet, which is the property that makes a denial of service worth studying rather than just patching.
Where the implementations disagreed
This is the interesting layer, and it is the reason one disclosure produced a long list of CVEs across unrelated codebases at once. Every affected server was reading the same RFC. They diverged on a single design decision: enforce the header-list limit incrementally, as each fragment arrives, or enforce it once, when the block is complete.
- An implementation that counts as it goes rejects the connection the moment the accumulated fragments cross the limit. The flood dies after a bounded amount of data. Safe.
- An implementation that counts at completion has written code that is correct for every well-formed request and never runs its own check against a request that is deliberately never completed. Vulnerable.
Both readings are defensible from the text. The spec talks about the size of “the header list,” which is a thing you have after decoding, and decoding happens after the block is complete, so “check at completion” is a natural reading. It is also the dangerous one, because it ties the safety check to an event the attacker controls and can simply withhold. The coordinated advisory named implementations across the ecosystem for exactly this reason. Apache httpd’s CVE-2024-27316 was the memory-exhaustion instance of it; the Go net/http and x/net/http2 fix addressed the same class; Node.js, Envoy, and several others shipped their own versions of the same correction in the same window.
The fix, and why it is the same fix as always
Every patch converged on the same idea: stop deferring the check to a moment the client controls. Concretely, three things, usually together:
- Enforce
SETTINGS_MAX_HEADER_LIST_SIZEagainst the running total of decoded fields during accumulation, not afterEND_HEADERS, and fail the stream or connection the instant it is exceeded. - Cap the number of CONTINUATION frames, or the total header-block octets, that will be accepted before
END_HEADERS, independent of the field-list limit. - Treat an oversized or over-long header block as a connection error, not a stream error, since a client abusing this is not going to behave better on the next stream.
That is the same principle that eventually tamed request smuggling, restated for the framing layer: do not let one side of a trust boundary defer a safety decision to input the other side fully controls. Smuggling happened because the front end and back end disagreed about where a message ended. The CONTINUATION flood happened because a single server disagreed with itself about when a limit applied, checking at a point the attacker could push out to infinity.
Why this keeps happening
I keep coming back to how ordinary the root cause is. Nobody misread the RFC. The RFC specified a limit and left the enforcement moment to the implementer, and reasonable implementers chose the reading that is clean for valid traffic and unsafe for adversarial traffic. The whole class of framing-layer denial of service in HTTP/2 (this, the earlier reset-stream floods, the header-table games) comes down to the same gap between “what a conforming message looks like” and “what an endpoint must do when a client sends conforming fragments forever.” A spec that only describes valid messages will keep producing this, because the security-relevant behavior is in the handling of the incomplete and the malicious, which is exactly the part a message-format specification tends not to nail down.
The lesson for anyone reading protocol code is small and repeatable. When you find a limit in a spec, do not ask whether the implementation enforces it. Ask when it enforces it, and then ask what happens if the client never reaches that moment. The web cache deception work was the same question asked about path parsing. Here it is asked about a counter. The answer is worth checking every time, because “at the end” and “as you go” look identical until someone declines to send you the end.