Skip to content

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

docker compose -f env/docker-compose.yml up -d

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:

docker logs cve-2024-24549-tomcat 2>&1 | grep -c org.apache.coyote.http2
# expect: > 0

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:

docker logs cve-2024-24549-tomcat 2>&1 | grep -cE 'StreamException|Closed due to error'
# expect: 0

Step 2 — Run the exploit

python3 exploit/h2_continuation_dos.py 127.0.0.1 8080 0.5

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:

docker compose -f env/docker-compose.yml down -v

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 from server.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 maxHttpHeaderSize and maxHeaderCount does 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