HTTP/2 Rapid Reset: How One Feature Broke the DDoS Record

In late 2023 a botnet of 20,000 machines set a DDoS record against Google, Cloudflare, and AWS. The weapon wasn't bandwidth. It was an HTTP/2 feature working exactly as designed.

In late August 2023, three of the biggest networks on the planet got hit by the same attack, and every one of them broke a record. Google absorbed 398 million requests per second. Cloudflare took 201 million. Amazon, 155 million. To put that in scale: until that month, the largest such attack anyone had ever recorded was 46 million requests per second, and Google itself had set that one the year before. This didn’t nudge the record. It multiplied it by more than eight.

The part that should keep you up at night is the botnet. It had about 20,000 machines. Twenty thousand is nothing — a middling botnet, the kind that gets rented for pocket change. A flood at 398 million requests per second out of 20,000 nodes means each node was somehow producing tens of thousands of requests per second, sustained. That math doesn’t work with any normal HTTP client. It only works because of a specific thing HTTP/2 lets you do, and that thing is not a bug. It’s a feature. It works exactly the way the spec says it should.

That’s what makes Rapid Reset — CVE-2023-44487 — worth understanding. It isn’t a flaw someone left in the code. It’s the protocol’s own design, turned sideways.

Why HTTP/1.1 accidentally rate-limited attackers

To see what changed, you have to remember how boring HTTP/1.1 was about connections.

Under HTTP/1.1, a TCP connection handles one request at a time. You send a request, you wait for the response, and only then can you send the next one on that connection. If you want more requests in flight, you open more connections — and connections are expensive. Each one is a TCP handshake, a TLS handshake, kernel state on both ends. Browsers cap themselves at around six connections per host precisely because connections don’t come free.

So HTTP/1.1 had an accidental property nobody designed on purpose: it throttled clients. The cost of opening connections and the one-request-at-a-time discipline meant a single attacking machine could only push so hard before it ran out of sockets. The protocol’s clumsiness was, quietly, a defense.

HTTP/2 set out to fix exactly that clumsiness, and it succeeded completely.

Multiplexing, and the limit that was supposed to contain it

HTTP/2’s headline feature is multiplexing. One TCP connection carries many independent streams at once — each stream a request and its response, interleaved on the wire, no more one-at-a-time waiting. This is genuinely great. It’s why a page with 100 assets loads over a single connection instead of fighting over six.

But “many streams at once” needs a ceiling, or a client could open a million streams and exhaust the server. So the protocol has one: SETTINGS_MAX_CONCURRENT_STREAMS. The server announces, in effect, “you may have at most 100 streams open on this connection at a time.” Open your hundred, and you have to wait for some to finish before starting more. That setting is the governor. It’s the thing that’s supposed to bound how much work one connection can demand.

Here’s the hole. What counts as a stream you have “open”?

The cancel that costs nothing

HTTP/2 lets either side cancel a stream at any moment by sending a RST_STREAM frame. This is a good and necessary feature — you clicked a link, the page starts loading, you click a different link, the browser cancels the in-flight requests for the page you abandoned. Cancellation is normal. It happens constantly.

And the instant a stream is reset, it no longer counts against MAX_CONCURRENT_STREAMS. Of course it doesn’t — it’s over. The slot frees up immediately so a new request can take it.

Now put those two facts next to each other, which is all the attack is:

Open a stream — send the HEADERS frame, a full request. Immediately send RST_STREAM to cancel it. The slot is free again, so open another. Cancel it. Open another. Cancel it. Request, reset, request, reset, as fast as you can write frames onto the connection. You never hold more than a stream or two “open” at any instant, so you never bump the concurrency ceiling. The governor sees a well-behaved client sitting comfortably under its limit. Meanwhile you’ve fired thousands of requests down a single connection in the time it takes to send the frames — no round trips, no waiting, no new connections.

That’s the asymmetry, and it’s brutal. Sending HEADERS-then-RST_STREAM is almost free for the attacker: a couple of small frames. But the server, in the window before it processes the reset, has often already done the expensive part — allocated the stream, parsed the headers, routed the request, maybe dispatched it to a backend. The request gets cancelled after the work starts, not before. You pay for a stamp; the server pays for the whole letter, then throws it away, thousands of times a second, per connection.

That is how 20,000 machines produce 398 million requests per second. Each machine isn’t flooding bandwidth. It’s opening a handful of connections and playing request-reset ping-pong on each one, and the concurrency limit — the one mechanism that was supposed to bound this — never trips, because a cancelled stream isn’t a concurrent stream.

You can’t just patch the feature away

The uncomfortable thing about Rapid Reset is that there’s no single line to delete. RST_STREAM has to exist; clients genuinely need to cancel requests. Streams that get cancelled genuinely shouldn’t count toward a concurrency limit that’s about simultaneous work. Every piece of this behaves correctly in isolation. The attack lives in the seam between two correct behaviors.

So the mitigations are all about noticing the pattern rather than banning the feature. Servers and proxies learned to track the rate of resets on a connection and treat a client that opens-and-cancels far more than it completes as abusive — throttle it, or close the whole connection and make it pay the TCP/TLS cost again. Some limit the total number of streams a single connection may ever create over its lifetime, cancelled or not, so the request-reset loop eventually forces a reconnect. NGINX, for instance, has long had keepalive_requests capping requests per connection; tuning it down blunts exactly this. None of these outlaw cancellation. They just stop pretending that a connection cancelling ten thousand streams a second is a normal browser.

Here’s the part I keep coming back to. The whole history of HTTP is a march toward efficiency — fewer round trips, more concurrency, less waiting. HTTP/1.1’s inefficiency was a cage nobody meant to build, and it happened to hold attackers back. HTTP/2 melted the cage down for all the right reasons, and the same efficiency that makes a page snap to life is the efficiency an attacker uses to fire a stadium’s worth of requests through a single socket. We didn’t add a vulnerability. We removed a limitation, and the limitation had been doing unpaid security work the entire time.

That’s the pattern worth carrying to the next protocol. When you make something dramatically cheaper to do, you make it cheaper for everyone — including the person doing it ten thousand times a second with your infrastructure footing the bill. The efficient thing and the abusable thing keep turning out to be the same thing, and you usually only find out which is which after someone breaks the record.

Continue the conversation

← Back to Blog