Here is the detail from the HTTP/2 CONTINUATION Flood that I can’t get out of my head: the attack doesn’t show up in your access logs. Not “it’s hard to spot in the noise.” It isn’t there. Your web server can be pinned at 100% CPU, or climbing toward an out-of-memory kill, absorbing an attack over a single TCP connection — and when you go to the log to see what’s hitting you, there’s nothing. The last line was written before the attack started.
That’s the part that makes it worse than Rapid Reset, the record-breaking HTTP/2 attack from the year before. Rapid Reset at least announced itself: a firehose of requests, a botnet of thousands, something you could graph. CONTINUATION Flood, disclosed by Bartek Nowotarski on April 3, 2024 as CERT/CC’s VU#421644, can take a server down from one machine — sometimes from a single connection and a handful of frames — and leave no request-shaped evidence that it happened. To understand why, you have to understand a small, boring corner of how HTTP/2 sends headers.
Headers that arrive in pieces
In HTTP/1.1, a request’s headers are just text you read until a blank line. HTTP/2 doesn’t work that way. Everything is framed, and headers are compressed with HPACK and sent as a header block. The block starts with a HEADERS frame. But a single frame has a size ceiling, and real header sets — cookies, auth tokens, the usual bloat — can overflow it. So the protocol lets the block continue across more frames: HEADERS, then zero or more CONTINUATION frames, each carrying the next chunk of the compressed header data.
How does the receiver know the headers are finally complete? One bit. Each frame in the sequence has an END_HEADERS flag. While it’s unset, the sender is saying “more header data is coming, keep reading.” When a frame arrives with END_HEADERS set, the block is done and the server can finish assembling the request. This is RFC 9113 §6.10, and it’s entirely reasonable. Headers are variable-length; you need a way to say “not done yet.” The flag is that way.
Now sit with the shape of it. The request is not a request until END_HEADERS shows up. Everything before that flag is a promise that the real thing is still coming. And the server, holding a half-read header block, has to keep the fragments somewhere while it waits.
The frame that says “still more”
The attack is one sentence: open a stream, send a HEADERS frame without END_HEADERS, then send CONTINUATION frames forever and never set the flag.
The server never gets to complete the request, so it never gets to the part where it would dispatch it, respond, or — crucially — log it. But it can’t just ignore the incoming frames either. They’re valid HTTP/2. So it does what the spec implies: it keeps receiving and processing them. And that processing takes one of two bad shapes, depending on the implementation.
In the first, the server appends each fragment to the header block it’s building in memory, waiting for the end that never comes. The block grows without bound. Memory climbs. Eventually the process is killed by the OOM reaper, or it drags the whole box down with it. One connection, a stream of small frames, and the server eats itself.
In the second, the server is a little smarter about memory — maybe it enforces a maximum header size and refuses to keep fragments past it — but it still decodes every CONTINUATION frame as it arrives, running HPACK decompression, validating, then discarding. No memory growth, but the CPU spins on an endless stream of headers for a request that will never exist. Same outcome, different resource: availability gone.
Either way, the logging layer sits above all of this, waiting for a completed request to write a line about. It never comes. The attack lives entirely in the space before a request is a request, which is precisely the space your observability doesn’t cover.
Why it broke everything at once
The tell that this was a design-level problem, not a copied bug, is the CVE list. This wasn’t one vendor’s mistake. It was, roughly simultaneously: Apache httpd (CVE-2024-27316), Apache Tomcat (CVE-2024-24549), Apache Traffic Server (CVE-2024-31309), Node.js via nghttp2 (CVE-2024-27983 and CVE-2024-28182), Envoy (CVE-2024-27919 and CVE-2024-30255), Go’s net/http (CVE-2023-45288), the amphp HTTP server (CVE-2024-2653), Tempesta FW (CVE-2024-2758), and more. A pile of independent codebases, written by different people in different languages, all with the same hole in the same place.
That happens when the flaw is in how everyone reads the spec, not in what any one of them typed. RFC 9113 describes CONTINUATION frames and the END_HEADERS flag correctly. What it leaned on implementers to do — and what most of them didn’t do firmly enough — was bound the thing: cap how many CONTINUATION frames, or how many total header bytes, a peer may send before END_HEADERS must appear. The spec even warns that CONTINUATION frames can be abused. But a warning in prose is not a limit in code, and a dozen teams independently decided that a header block would, of course, eventually end. Attackers are exactly the people who decline to end it.
The fixes, once the coordinated disclosure landed, were all the same shape: count the frames or the bytes arriving before END_HEADERS, and if a peer blows past a sane ceiling, tear the connection down instead of politely buffering forever. Nobody removed CONTINUATION frames — legitimate large header sets still need them. They just stopped extending infinite trust to a peer that hasn’t finished its sentence.
Attacking below the unit of a request
Here’s what I keep coming back to, and it’s the same lesson Rapid Reset taught in a different key.
Almost everything we build to defend a web server is keyed on the request as the atom. Rate limits count requests per second. WAF rules inspect requests. Access logs record requests. Metrics dashboards chart them. The request is the unit of accounting for the entire stack. CONTINUATION Flood works because it attacks below that unit — it’s pre-request, a thing that consumes real CPU and memory while never graduating into the object your defenses know how to see. You cannot rate-limit, inspect, or log something your architecture doesn’t yet believe exists.
Rapid Reset played the same trick from the other side: it made requests so cheap to open-and-cancel that the concurrency limit meant to bound them never tripped, because a cancelled stream doesn’t count as concurrent. Both attacks found a moment where HTTP/2 defers when a thing “counts” — cancellation on one side, END_HEADERS on the other — and moved in to live in the deferral.
That’s the pattern worth carrying forward. Every time a protocol lets you postpone the instant at which some resource is committed or some request becomes real, you’ve created a window, and the window is where the accounting doesn’t apply yet. Efficiency and abusability keep turning out to be the same lever. HTTP/2 got faster by letting headers arrive in pieces and letting streams be freely cancelled — both genuinely good ideas — and each one handed an attacker a way to make a server do unbounded work on something that, by the stack’s own bookkeeping, hadn’t started. The next time you design a system that says “we’ll finish accounting for this later,” assume someone will make sure later never comes.