CVE-2026-24880 — Apache Tomcat chunk-extension request smuggling¶
Published 2026-06-06
Verified reproduction
This page documents a confirmed, end-to-end reproduction of CVE-2026-24880. The exploit was run against a controlled lab environment; do not direct it at systems you do not own.
| Field | Value |
|---|---|
| Project | Apache Tomcat |
| Affected component | ChunkedInputFilter / coyote module (java/org/apache/coyote/http11/filters/ChunkedInputFilter.java) |
| Severity | HIGH |
| CVSS | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N (score 7.5; NVD) |
| CWE | CWE-444 |
| Affected versions | 11.0.0-M1 – 11.0.18 · 10.1.0-M1 – 10.1.52 · 9.0.0.M1 – 9.0.115 · 8.5.0 – 8.5.100 · 7.0.0 – 7.0.109 |
| Fixed version | 11.0.20 · 10.1.53 · 9.0.116 (+ 8.5.x / 7.0.x backports) |
| Advisory | GHSA-563x-q5rq-57qp |
1. Vulnerability overview¶
Apache Tomcat is a widely deployed Java servlet container that handles HTTP/1.1 chunked transfer encoding through a component called ChunkedInputFilter. CVE-2026-24880 is a request-smuggling vulnerability: an attacker who can route traffic through a reverse proxy that passes raw CRLF sequences inside HTTP chunk extensions can embed a second, hidden HTTP request inside what Tomcat reads as harmless extension data. Tomcat processes that hidden request as a legitimate new request, crossing the authorization boundary the front-end proxy was supposed to enforce. This allows reaching endpoints, bypassing access controls, or poisoning the connection state for subsequent users on the same keep-alive channel.
The NVD rates this HIGH (CVSS 7.5). Apache's official rating is Low, reflecting that exploitation requires a permissive front-end proxy as a prerequisite. Once that precondition is met, the attack complexity is Low and no authentication is required.
Root cause¶
File: java/org/apache/coyote/http11/filters/ChunkedInputFilter.java
Functions: parseChunkHeader() and skipChunkHeader()
Before the fix, once the parser encountered a semicolon in a chunk header it set a single boolean flag (parsingExtension) and from that point accepted every following byte as extension data with no structural validation, only an optional size cap:
// BEFORE (vulnerable)
} else if (chr == Constants.SEMI_COLON && !parsingExtension) {
parsingExtension = true;
extensionSize.incrementAndGet();
} else if (!parsingExtension) {
// hex digit processing
} else {
// Extension 'parsing'
// Note that the chunk-extension is neither parsed nor
// validated. Currently it is simply ignored.
long extSize = extensionSize.incrementAndGet();
if (maxExtensionSize > -1 && extSize > maxExtensionSize) {
throwBadRequestException(sm.getString("chunkedInputFilter.maxExtension"));
}
}
The comment "neither parsed nor validated. Currently it is simply ignored" captures the bug precisely. Any byte, including \r\n, was absorbed as extension content. A permissive proxy could forward a chunk extension containing a raw \r\n followed by a complete second HTTP request; Tomcat's unvalidated extension reader would swallow those bytes, then re-parse the pipelined inner request as an independent second request when it reached Tomcat's request loop.
The fix (primary commit f07df938 for 10.x/11.x; backports 1b586d6a/6d478dbe for 9.x) replaces the boolean flag with an enum field extensionState and delegates all byte-level parsing to a new class ChunkExtension backed by an RFC-7230-compliant state machine:
// AFTER (fixed) — ChunkedInputFilter.java
- private volatile boolean parsingExtension = false;
+ private volatile State extensionState = null;
// On semicolon: enter validated extension parsing
} else if (chr == Constants.SEMI_COLON) {
extensionState = State.PRE_NAME;
long extSize = extensionSize.incrementAndGet();
...
}
// Per byte: delegate to ChunkExtension state machine
if (extensionState != null) {
extensionState = ChunkExtension.parse(chr, extensionState);
if (extensionState == State.CR) {
if (!parseCRLF()) { return false; }
eol = true;
extensionState = null;
} else { /* size check */ }
}
The new ChunkExtension class implements a streaming state machine (states: PRE_NAME, NAME, POST_NAME, EQUALS, VALUE, QUOTED_VALUE, POST_VALUE, CR) that throws IOException on any byte that does not conform to the RFC grammar. A raw \r is only valid as the start of the terminal \r\n sequence (state CR); a \n arriving in any other state throws an exception, which closes off CRLF injection through a chunk extension.
2. Vulnerable environment¶
The lab reproduces the bug with two Docker Compose service pairs on a private bridge network, downloadable as env.zip. All environment files are also browsable individually below.
Topology
| Service | Role | Image | Exposed to host |
|---|---|---|---|
frontend-vuln |
CRLF-permissive byte-pump proxy in front of the vulnerable back-end | Python stdlib | 127.0.0.1:8000 |
backend-vuln |
Vulnerable Tomcat 9.0.115 (last affected 9.x release) | tomcat:9.0.115-jdk17-temurin |
Internal only |
frontend-fixed |
Same proxy in front of the fixed back-end (negative control) | Python stdlib | 127.0.0.1:8001 |
backend-fixed |
Fixed Tomcat 9.0.116 (first patched 9.x release) | tomcat:9.0.116-jdk17-temurin |
Internal only |
The back-end containers are not reachable from the host, only through their respective front-end proxies, so the front-end → back-end authorization boundary is genuine. The front-end (env/config/frontend_proxy.py) parses only the outer request headers and then streams the body byte-for-byte to Tomcat without re-parsing or re-chunking it; raw CRLF inside a chunk extension reaches Tomcat unchanged. It enforces one access control: any outer request whose path begins with /internal is rejected with 403 Forbidden and never forwarded.
Environment files
- env/docker-compose.yml — compose definition for all four containers
- env/Dockerfile.backend — back-end image (deploys
LabServlet, the arrival-recording servlet) - env/Dockerfile.frontend — front-end proxy image
- env/config/frontend_proxy.py — the byte-pump proxy source
- env/config/backend-entrypoint.sh — generates a fresh UUID boot nonce and clears the arrival log on every container start
- env/app/src/LabServlet.java — the servlet that serves the nonce and records arrivals
- env/app/WEB-INF/web.xml — servlet deployment descriptor
Starting the environment
Verifying the environment is the vulnerable one
On start, each back-end container writes a fresh random UUID to /nonce/boot_nonce and truncates /nonce/arrivals.log. The nonce is also served through the proxy at GET /public/nonce. Run the smoke-test below to confirm four containers are up, the nonce is accessible, and /internal paths are gated:
# Check all four containers are running:
docker compose -f env/docker-compose.yml ps
# Read the boot nonce from the back-end directly (privileged channel):
docker exec cve-2026-24880-backend-vuln cat /nonce/boot_nonce
# Read the same nonce through the front-end proxy (should match):
curl -s http://127.0.0.1:8000/public/nonce
# Confirm the arrival log is empty at baseline:
docker exec cve-2026-24880-backend-vuln sh -c 'wc -c < /nonce/arrivals.log'
# Confirm the proxy blocks outer /internal requests with 403:
curl -s -o /dev/null -w '%{http_code}\n' "http://127.0.0.1:8000/internal/arrival?nonce=x"
Expected: four containers Up/healthy; the UUID from docker exec and from curl match; arrival log size 0; HTTP 403 for the outer /internal probe. The Tomcat version strings confirm which build is running:
backend-vuln:Server version: Apache Tomcat/9.0.115(last vulnerable 9.x)backend-fixed:Server version: Apache Tomcat/9.0.116(first fixed 9.x)
3. How to exploit¶
The exploit is a single Python script (exploit/smuggle.py), available as exploit.zip. It uses only the Python standard library and requires no third-party dependencies.
How the exploit works¶
The script performs two sequential raw socket operations on a single connection to the front-end proxy:
-
Read the fresh nonce: it connects to the front-end proxy and issues
GET /public/nonce HTTP/1.1, dechunks the response, and records the UUID. This is the only sanctioned way for the exploit to learn the nonce value at runtime; it is not baked in. -
Smuggle the inner request: it sends a single outer
POST /public/ingestrequest with a chunked body. The final (size 0) chunk carries an RFC-illegal chunk-extension (0;ext=/x). Immediately after the chunked body terminator the script writes the inner request on the same connection:
The byte-pump front-end forwards the outer request body verbatim. On the vulnerable Tomcat 9.0.115 back-end, ChunkedInputFilter.parseChunkHeader swallows the ext=/x bytes without any validation; the outer request completes; and Tomcat re-parses the pipelined bytes as a second independent request (GET /internal/arrival?nonce=<nonce>), which the back-end services and records. On the fixed Tomcat 9.0.116 back-end, the new ChunkExtension state machine rejects the / character as invalid in an extension name position and returns 400 Bad Request, so the inner request is never parsed.
Step-by-step instructions¶
Step 1. Ensure the environment is up (see §2).
Step 2. Run the exploit script against the vulnerable lane:
The script prints to stderr the nonce it read and the exact wire bytes it sent, and prints to stdout the raw bytes received from the back-end on the connection.
Step 3. Confirm the inner request was processed by reading the back-end's arrival record through the privileged out-of-band channel, independently of the exploit's own output:
docker exec cve-2026-24880-backend-vuln cat /nonce/boot_nonce
docker exec cve-2026-24880-backend-vuln cat /nonce/arrivals.log
What proves it worked¶
Success is confirmed by an independent observation of the back-end's internal state, not by reading the exploit script's own stdout.
The back-end's LabServlet appends ARRIVAL <nonce> to /nonce/arrivals.log whenever it services a request to GET /internal/arrival. That log is only writable by the back-end itself, not by the exploit. The verifier reads the log and the true boot nonce separately via docker exec, then checks that the nonce in the log exactly matches the current boot nonce.
Observed evidence (from the verified run):
# True boot nonce (privileged read):
docker exec cve-2026-24880-backend-vuln cat /nonce/boot_nonce
-> af77f0a0-5e91-480e-94df-3768cab17ee1
# Arrival log after exploit ran (privileged read):
docker exec cve-2026-24880-backend-vuln cat /nonce/arrivals.log
-> ARRIVAL af77f0a0-5e91-480e-94df-3768cab17ee1
The arrival log nonce af77f0a0-5e91-480e-94df-3768cab17ee1 matches the true boot nonce af77f0a0-5e91-480e-94df-3768cab17ee1 exactly. On the wire the script also received two 200 responses on the single connection: the first for the outer POST /public/ingest (outer-ok), the second for the smuggled GET /internal/arrival (recorded).
The front-end proxy's 403 gate ensures /internal/arrival cannot be reached by a direct outer request (confirmed at baseline: curl returns 403). Therefore the ARRIVAL entry in the log could only have been written if the inner request crossed the front-end → back-end boundary via the chunk-extension parser bug.
Negative control¶
Running the identical script against the fixed lane produces a 400 Bad Request from Tomcat 9.0.116 with a stack trace anchored at org.apache.coyote.http11.filters.ChunkedInputFilter.parseChunkHeader(ChunkedInputFilter.java:365), and the fixed back-end's arrival log stays empty:
# Fixed back-end arrival log after running against port 8001:
docker exec cve-2026-24880-backend-fixed cat /nonce/arrivals.log
-> (empty, size 0)
The patched ChunkExtension state machine rejects the illegal / byte in the extension field, the inner request is never parsed, and no arrival is recorded. This confirms the exploit depends on the chunk-extension parsing bug, not on any other HTTP pipelining path.
Environment teardown¶
When finished, stop and remove the containers and network:
4. Security advice¶
Remediation¶
Upgrade Apache Tomcat to a fixed release. The fix ships in:
- 11.0.20 or later (commits
fde1a823,2cb06c34) - 10.1.53 or later (commits
f07df938,1e71441a) - 9.0.116 or later (commits
1b586d6a,6d478dbe) - Backports also apply to 8.5.x and 7.0.x; consult the Apache security pages for those branches
Note: 11.0.19 was not released publicly with this fix; the first publicly available fixed 11.x release is 11.0.20.
Mitigations and workarounds¶
If an immediate upgrade is not possible, the following mitigations apply at the reverse proxy layer:
- Reject or normalize chunk extensions at the front-end proxy. Configure HAProxy, nginx, or any other proxy to strip chunk extensions or reject requests that carry them before forwarding to Tomcat. Most production proxy configurations do not forward raw CRLF in chunk extensions by default, which makes this the strongest available short-term mitigation.
- Disable HTTP/1.1 keep-alive connections between the proxy and Tomcat. This limits the value of a smuggled second request, though it does not fully eliminate the attack surface.
The root cause is that Tomcat's ChunkedInputFilter accepted any byte sequence in chunk extensions. The fix introduces a strict RFC-7230-compliant state machine that rejects non-conforming bytes outright. No workaround at the Tomcat level short of patching addresses the root cause.
References¶
- GHSA-563x-q5rq-57qp — authoritative affected/fixed version data, credits, CWE
- NVD CVE-2026-24880 — CVSS score, CWE, description
- Apache Tomcat 11 Security Page — Fixed in 11.0.20
- Apache Tomcat 10 Security Page — Fixed in 10.1.53
- Apache Tomcat 9 Security Page — Fixed in 9.0.116
- Patch commit f07df938 — primary fix for 10.x/11.x: adds
ChunkExtensionstate machine - Patch commit 1e71441a — follow-up: handles name-only extensions and non-blocking read edge cases
- Patch commit 1b586d6a — primary backport for 9.x
- Patch commit 6d478dbe — follow-up backport for 9.x
- Patch commit fde1a823 — 11.x primary patch
- Patch commit 2cb06c34 — 11.x follow-up
- OSS-Security announcement 2026-04-09 — public disclosure; credits Xclow3n as reporter
- HeroDevs vulnerability directory — attack mechanism description
- Rapid7 vulnerability database — additional context