CVE-2024-24549 — Apache Tomcat HTTP/2 Deferred Header-Limit Validation DoS¶
Published 2026-06-04
Denial of Service — no authentication required
An unauthenticated remote attacker can send HTTP/2 requests with oversized or excessively numerous headers to exhaust server resources. The server must fully parse the entire header block before it rejects the stream, rather than stopping as soon as a limit is exceeded.
| Field | Value |
|---|---|
| Project | Apache Tomcat |
| Severity | HIGH |
| CVSS v3.1 | 7.5 HIGH (AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H) |
| CWE | CWE-20 (Improper Input Validation) |
| Affected versions | 8.5.0–8.5.98, 9.0.0-M1–9.0.85, 10.1.0-M1–10.1.18, 11.0.0-M1–11.0.0-M16 |
| Fixed versions | 8.5.99, 9.0.86, 10.1.19, 11.0.0-M17 |
| GHSA | GHSA-7w75-32cg-r6g2 |
1. Vulnerability Overview¶
Apache Tomcat is a widely-deployed Java servlet container that supports HTTP/2
alongside HTTP/1.1. When a client sends an HTTP/2 request, the server may receive
headers spread across multiple frames: a HEADERS frame (which may omit the
END_HEADERS flag) followed by one or more CONTINUATION frames. Tomcat limits the
total size and count of headers a request may carry through the maxHttpHeaderSize
and maxHeaderCount connector settings.
CVE-2024-24549 is a denial-of-service vulnerability rooted in the timing of when those limits are enforced. An attacker who can reach the HTTP/2 listener can send a header block split across a HEADERS frame and one or more CONTINUATION frames, where the configured limit is already exceeded by the HEADERS frame alone. Because Tomcat deferred its limit check to the very end of the header block (after all CONTINUATION frames were fully read and HPACK-decoded), the server was forced to buffer and process every byte of the over-limit block before it could issue a stream reset. Repeated across many concurrent streams or connections, this causes resource exhaustion and denial of service. No authentication is required; HTTP/2 must be enabled (it is on by default in Tomcat with NIO2/APR connectors).
Root cause¶
File: java/org/apache/coyote/http2/Http2Parser.java
Functions: readHeadersFrame(), readContinuationFrame(), onHeadersComplete()
In the vulnerable builds, validateHeaders() was called exactly once, inside
onHeadersComplete(), which runs only after the last frame of the header block (the
one carrying END_HEADERS) has been consumed. An inline comment in the original code
acknowledged this was intentional, citing the belief that the HTTP/2 specification
requires the receiver to read (swallow) every frame in a header block before it may
reject the request:
// BEFORE (in onHeadersComplete — called only after ALL header frames received)
private void onHeadersComplete(int streamId) throws Http2Exception {
// ...
// Delay validation (and triggering any exception) until this point
// since all the headers still have to be read if a StreamException is
// going to be thrown.
hpackDecoder.getHeaderEmitter().validateHeaders(); // <-- deferred to end
output.headersEnd(streamId, headersEndStream);
// ...
}
The fix moves validateHeaders() to the end of each individual frame: it is called
immediately after readHeadersFrame finishes swallowing any padding, and again after
each readContinuationFrame finishes reading its payload. If a limit is breached
partway through the header block, the stream is now reset at the frame boundary rather
than at the end of the whole block. The corrected comment also clarifies that the
HTTP/2 specification allows the receiver to send a RST_STREAM after any individual
frame in the block; the original belief that "must read all frames first" was
incorrect.
// AFTER — in readHeadersFrame(), after swallowPayload():
swallowPayload(streamId, FrameType.HEADERS.getId(), padLength, true);
// Validate the headers so far
hpackDecoder.getHeaderEmitter().validateHeaders(); // <-- early check added
if (Flags.isEndOfHeaders(flags)) {
onHeadersComplete(streamId);
} else { ...
// AFTER — in readContinuationFrame(), after readHeaderPayload():
readHeaderPayload(streamId, payloadSize);
// Validate the headers so far
hpackDecoder.getHeaderEmitter().validateHeaders(); // <-- early check added
if (endOfHeaders) {
headersCurrentStream = -1;
onHeadersComplete(streamId);
...
// AFTER — removed from onHeadersComplete():
- // Delay validation (and triggering any exception) until this point
- // since all the headers still have to be read if a StreamException is
- // going to be thrown.
- hpackDecoder.getHeaderEmitter().validateHeaders();
2. Vulnerable Environment¶
The reproduction environment runs a single container using the official, unmodified
tomcat:10.1.18-jdk17-temurin-jammy Docker image. Tomcat 10.1.18 falls within the
affected range (10.1.0-M1 through 10.1.18) and ships the vulnerable Http2Parser
code. No source changes are made to the image; only two configuration files are
bind-mounted to set up the diagnostic conditions needed for verification.
Download the full environment: env.zip
Individual files:
Stack layout¶
| Component | Details |
|---|---|
| Container | cve-2024-24549-tomcat (image tomcat:10.1.18-jdk17-temurin-jammy) |
| Protocol | HTTP/1.1 + HTTP/2 cleartext (h2c) on the same port |
| Exposed port | 127.0.0.1:8080 → container 8080 |
| Transport | No TLS, no ALPN; h2c via HTTP/2 prior-knowledge preface |
| Auth | None required |
env/config/server.xml configures a single NIO connector on port 8080 with
maxHttpHeaderSize="8192" (8 KB). An Http2Protocol upgrade is attached to the
same port with maxHeaderCount="20". The HTTP/2 parser derives its effective header
size limit from the enclosing connector, so both limits are modest and unambiguous:
any header block larger than 8 KB or carrying more than 20 headers is over-limit.
There is no upstream proxy or other component that pre-validates or normalizes
headers.
env/config/logging.properties raises the org.apache.coyote.http2 logger to
FINE (DEBUG level). This causes Http2UpgradeHandler to emit, to both the
container's stdout and the Catalina log file, the full StreamException including
its stack trace when a stream is rejected. Both outputs are server-controlled and
independent of any exploit process, and form the diagnostic channel used to confirm
the vulnerable behavior.
Starting the environment¶
Wait for the health check to pass (up to 60 seconds, polling every 5 s), then confirm the version and that h2c is live:
docker inspect -f '{{.State.Health.Status}}' cve-2024-24549-tomcat
# expect: healthy
curl -s --http2-prior-knowledge -o /dev/null \
-w 'http_version=%{http_version} code=%{response_code}\n' \
http://127.0.0.1:8080/
# expect: http_version=2 code=404
The code=404 response is expected; no web application is deployed. What matters
is that http_version=2 confirms h2c negotiation succeeded, and the HTTP/2 parser
(including the vulnerable code path) is active.
To confirm the diagnostic channel is emitting HTTP/2-level log lines:
3. How to Exploit¶
The exploit sends a single HTTP/2 stream whose header block is deliberately split
across two frames: a HEADERS frame (sent without END_HEADERS) that already exceeds
the 8192-byte limit, followed by a CONTINUATION frame (with END_HEADERS) sent
after a short pause. On the vulnerable build, the server reads and HPACK-decodes
both frames before resetting the stream. On a patched build, the stream would be
reset immediately after the HEADERS frame, and the CONTINUATION frame would never be
consumed.
The proof-of-concept script exploit/h2_continuation_dos.py hand-crafts HTTP/2 frames using only the Python standard library; no external packages are required. Download the full exploit bundle: exploit.zip
Step 1 — Establish a clean baseline¶
Before running the exploit, record that no stream errors have occurred yet:
Step 2 — Run the exploit¶
The three arguments are: target host, port, and the pause in seconds between the HEADERS and CONTINUATION frames (0.5 s makes the frame boundary unambiguous in the server's log timestamps). The script prints the frames it sends and receives to stderr for diagnostic reference, but that output is not used to judge success; the verdict comes from the server log.
The script's diagnostic output will resemble:
[*] connected to 127.0.0.1:8080
[*] sent preface + SETTINGS + SETTINGS ACK
[*] sent HEADERS (no END_HEADERS) stream=1 junk_header_value_bytes=12000 payload_len=12231
[*] sent CONTINUATION (END_HEADERS) AFTER the over-limit HEADERS frame
[recv] frame=SETTINGS flags=0x00 stream=0 len=18
[recv] frame=SETTINGS flags=0x01 stream=0 len=0
[recv] frame=RST_STREAM flags=0x00 stream=1 len=4 error_code=11
[*] done
Step 3 — Read the server-side evidence¶
The decisive evidence is on the server's own diagnostic channel (the Docker log), not in the exploit's stdout. Read it immediately after the exploit finishes:
docker logs cve-2024-24549-tomcat 2>&1 | \
grep -E 'Frame type \[HEADERS\]|Frame type \[CONTINUATION\]|swallowPayload|Closed due to error|StreamException|readHeaderPayload.*payload of size' | tail -8
What proves it worked¶
The server log records a precise sequence of events on stream 1. The key observable is not merely that the stream was eventually rejected, but which frame the reset follows; that is the discriminator between vulnerable and patched behavior.
The log produced by this run shows the following ordered sequence on Connection [6]:
| Time | Server log event | Meaning |
|---|---|---|
04:34:43.474 |
Http2Parser.validateFrame ... Frame type [HEADERS], Flags [0], Payload size [12231] |
Over-limit HEADERS frame received (12231 > 8192 limit), no END_HEADERS |
04:34:43.475 |
readHeaderPayload ... Processing headers payload of size [12,231] |
Full HEADERS block read and HPACK-decoded |
04:34:43.475–.477 |
Stream.emitHeader ... x-junk-00 .. x-junk-07 (all junk headers) |
Every header in the over-limit block decoded |
04:34:43.478 |
Http2Parser.swallowPayload ... Swallowed [0] bytes then upgradeDispatch Exit ... SocketState [ASYNC_IO] |
HEADERS frame finished — handler returned to wait for more frames, with no reset issued |
04:34:44.123 |
validateFrame ... Frame type [CONTINUATION], Flags [4], Payload size [217] + readHeaderPayload ... size [217] |
Server consumed the CONTINUATION frame sent 0.645 s later — after the limit was already exceeded |
04:34:44.124 |
Http2UpgradeHandler.upgradeDispatch ... Stream [1] Closed due to error |
Stream reset fires only here |
04:34:44.125 |
sendStreamReset ... Error [ENHANCE_YOUR_CALM], Message [... Total header size too big] |
RST_STREAM emitted at/after END_HEADERS |
The StreamException stack trace in the log shows the exception object was
constructed during the HEADERS frame (inside readHeaderPayload at
Http2Parser.java:543, reached via readHeadersFrame at line 282), yet the actual
stream reset (Closed due to error) only fires during the later CONTINUATION
dispatch, more than half a second afterward. Java records a throwable's stack at
construction time, so this construction-at-HEADERS / reset-after-CONTINUATION split
is direct evidence of the deferred validation path: the exception was stored and
re-thrown inside onHeadersComplete() rather than being raised immediately.
org.apache.coyote.http2.StreamException: Connection [6], Stream [1], Total header size too big
at org.apache.coyote.http2.Http2Parser.readHeaderPayload(Http2Parser.java:543)
at org.apache.coyote.http2.Http2Parser.readHeadersFrame(Http2Parser.java:282)
...
A patched build (10.1.19 or later) would have called validateHeaders() at the end
of readHeadersFrame, issued the reset at 04:34:43.478, and never logged a
Frame type [CONTINUATION] line at all. The same exploit against a patched server
produces no CONTINUATION processing; that is what attributes the observed behavior
specifically to this vulnerability.
Environment teardown¶
When finished, stop and remove the container and its network:
4. Security Advice¶
Remediation¶
Upgrade Apache Tomcat to a version that includes the fix:
| Branch | First safe version |
|---|---|
| 8.5.x | 8.5.99 |
| 9.0.x | 9.0.86 |
| 10.1.x | 10.1.19 |
| 11.0.x | 11.0.0-M17 |
The fix is mechanical: validateHeaders() is moved from onHeadersComplete() into
readHeadersFrame() and readContinuationFrame(), so it runs at the end of each
individual frame rather than at the end of the entire header block. No configuration
change enables the fix; it is entirely in the patched binaries.
Mitigations and workarounds¶
If an immediate upgrade is not possible, consider the following:
- Disable HTTP/2. Remove the
<UpgradeProtocol className="org.apache.coyote.http2.Http2Protocol" .../>element fromserver.xml. The vulnerability is specific to the HTTP/2 parser; HTTP/1.1 is not affected. This eliminates the attack surface entirely at the cost of HTTP/2 features. - Lower header limits. Reducing
maxHttpHeaderSizeandmaxHeaderCountdoes not prevent the attack but limits the amount of data the server must process per stream before rejecting it. This reduces per-stream cost but does not remove the fundamental deferred-validation logic. - Rate-limit or front with a reverse proxy. Placing a reverse proxy or WAF that terminates HTTP/2 connections in front of Tomcat can reduce exposure, depending on its own HTTP/2 handling.
None of these workarounds eliminate the underlying vulnerability; upgrading is the correct fix.
References¶
- NVD CVE-2024-24549: CWE, CVSS, description
- GHSA-7w75-32cg-r6g2: affected/fixed versions per Maven artifact
- Apache mailing list announcement: official disclosure
- Patch commit 0cac540 (Tomcat 9.x / 9.0.86)
- Patch commit 810f49d (Tomcat 10.x / 10.1.19)
- Patch commit 8e03be9 (Tomcat 11.x / 11.0.0-M17)
- Patch commit d07c821 (Tomcat 11.x additional fix)
- Apache Tomcat 8 security page
- Apache Tomcat 9 security page
- Apache Tomcat 10 security page
- Apache Tomcat 11 security page
- OSS-Security announcement
- Snyk blog: HTTP/2 CONTINUATION DoS: broader class analysis
- NetApp advisory NTAP-20240402-0002: downstream vendor impact