
Proof-of-concept demonstrating CVE-2026-22732, a Spring Security flaw where setIntHeader("Content-Length") drops all security headers, with vulnerable and patched builds.
Spring Security silently drops HTTP response security headers. Demo / educational use only; run it against nothing but this local app.
| CVE | CVE-2026-22732 (CWE-425), published 2026-03-19 |
| CVSS 3.1 | 9.1 CRITICAL — AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N |
| Direct dependency | spring-boot-starter-web + spring-boot-starter-security 2.7.18 |
| Vulnerable component | spring-security-web / -config / -core 5.7.11 — transitive only, never named in pom.xml |
| Fixed component | spring-security-web 5.7.14-0.cgr.2, reached by one <version> change — see the transition |
| Verified on | Tomcat 9.0.118, JDK 17.0.18, macOS arm64 |
Affected ranges: 5.7.0–5.7.21, 5.8.0–5.8.23, 6.3.0–6.3.14, 6.4.0–6.4.14, 6.5.0–6.5.8, 7.0.0–7.0.3. Spring Boot 2.7.18 pins Spring Security 5.7.11, squarely inside the first range:
$ mvn dependency:tree -Dincludes=org.springframework.security
+- org.springframework.security:spring-security-config:jar:5.7.11:compile
| \- org.springframework.security:spring-security-core:jar:5.7.11:compile
\- org.springframework.security:spring-security-web:jar:5.7.11:compile
./run.sh # terminal 1 — builds and starts on :8080 (pins JDK 17)
./exploit.sh # terminal 2 — drives every endpoint, diffs the headers
run.sh pins JAVA_HOME because Spring Boot 2.7.x cannot run on the JDK 25 that mvn resolves by
default on this machine. Override with JAVA_HOME_17=/path/to/jdk17.
It also builds with -s settings-chainguard.xml by default, because the patched parent is not on
Maven Central. Set MAVEN_SETTINGS=/path/to/your/settings.xml to point elsewhere, or
MAVEN_SETTINGS= to build purely from Central — which works for stock 2.7.18 only.
exploit.sh reads the actual spring-security-web and spring-boot versions out of
target/*.jar, so its banner always reports what is really running rather than a hardcoded string.
SecurityConfig applies no header customisation at all — Spring Security's defaults are in
force, which is exactly what a security-conscious app relies on. Every endpoint returns the same
sensitive body:
{"account":"4111-1111-1111-1111","holder":"D. Havelock","balance":"82914.55"}
The only thing that varies is how the controller writes the response.
BASELINE standard Spring MVC return value
/safe/account OK all 6 headers delivered
CONTROL getOutputStream(), body > 8 KB buffer
/vuln/stream/account OK all 6 headers delivered
CONTROL explicit response.flushBuffer()
/vuln/flush/account OK all 6 headers delivered
EXPLOIT setIntHeader("Content-Length", n) <-- CVE-2026-22732
/vuln/content-length/account BYPASSED ALL 6 security headers dropped
BY DESIGN application sets its own Expires header (NOT this CVE)
/vuln/cache/account PARTIAL Cache-Control + Pragma dropped
Only /vuln/content-length/account changes state when the CVE is patched, so it is the only
endpoint exploit.sh derives its verdict from. The rest are controls.
setIntHeader("Content-Length", n) → total bypassThree lines of ordinary-looking controller code strip every header Spring Security promised:
response.setContentType("application/json");
response.setIntHeader("Content-Length", body.length);
response.getOutputStream().write(body);
$ curl -sD - -o /dev/null http://localhost:8080/vuln/content-length/account
HTTP/1.1 200
Content-Type: application/json
Content-Length: 77
Date: Tue, 01 Sep 2026 00:53:18 GMT
No X-Frame-Options, no X-Content-Type-Options, no Cache-Control, no Pragma, no Expires, no
X-XSS-Protection. Compare /safe/account, which carries all six. The response is framable by any
origin, MIME-sniffable, and cacheable — while serving a card number.
Corrected: an earlier version of this README called this "exploit 2" and claimed it was the
condition the advisory documents. It is not part of CVE-2026-22732 and no upgrade fixes it.
CacheControlHeadersWriter is byte-identical in 5.7.11, 5.7.14-0.cgr.2, 6.5.8 (last vulnerable) and
6.5.9 (first fixed) — verified by diffing the sources jars. Its Javadoc states the behaviour
outright: "Inserts headers to prevent caching if no cache control headers have been
specified."
It is still worth demonstrating, because the leak is real and the residual risk survives patching.
CacheControlHeadersWriter bails out if Cache-Control, Expires or Pragma is already
present, so setting any one of the three suppresses all of Spring Security's no-store
directives. One well-intentioned line does it:
response.setHeader("Expires", "Thu, 01 Jan 2099 00:00:00 GMT");
/safe/account NOT cacheable (Cache-Control: no-cache, no-store, max-age=0, must-revalidate)
/vuln/cache/account CACHEABLE (Cache-Control: absent / Expires: Thu, 01 Jan 2099 00:00:00 GMT)
/vuln/content-length/account CACHEABLE (Cache-Control: absent / Expires absent)
Expires counts as "present" in the table above, but with an attacker-friendly value the app chose —
Spring Security's Expires: 0 was replaced, not merely dropped. Cardholder data is now storable by
every browser and shared proxy on the path until 2099.
On the patched build /vuln/content-length/account flips to NOT cacheable, while
/vuln/cache/account stays exactly as above. Only application code or a reverse proxy fixes it —
which is the useful thing to say out loud in a demo: upgrading the library closes the CVE and leaves
this untouched.
Several widely-circulated write-ups — including a public reproduction repo — list
response.getOutputStream() and response.flushBuffer() as triggers, explaining that "the response
is committed before Spring Security can inject its headers". On Spring Security 5.7.11 that is
wrong. Both endpoints deliver all six headers.
/diag/committed shows why the explanation doesn't hold. After a 12 KB write the response really is
committed inside the controller, yet the headers still arrive:
>>> DIAG response.isCommitted() after 12048 byte write = true
| wrapper class = org.springframework.security.web.header.HeaderWriterFilter$HeaderWriterResponse
OnCommittedResponseWrapper overrides flushBuffer() and the output-stream writes, so it gets its
headers out ahead of those commits. Commit-ordering alone is not the bug; the declared-Content-Length
path is. The Spring advisory itself never endorses the commit-ordering story.
Keeping these two endpoints in makes the PoC falsifiable: it shows what does not reproduce as clearly as what does, and both stay green across the patch, which is what makes the one endpoint that does flip meaningful.
One line in pom.xml, nothing else. No source change, no property change, no Spring Boot major
bump:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.18</version> <!-- vulnerable -->
<version>2.7.18-0.cgr.3</version> <!-- patched -->
</parent>
That re-pins spring-security.version from 5.7.11 to 5.7.14-0.cgr.2 (and
spring-framework.version from 5.3.31 to 5.3.39-0.cgr.4) through the parent's
spring-boot-dependencies. Measured:
Diffing the spring-security-web sources jars, only one file matters. 5.7.11 →
5.7.14-0.cgr.2 adds setHeader / setIntHeader / addIntHeader overrides to
OnCommittedResponseWrapper, each routing Content-Length through setContentLength():
@Override
public void setIntHeader(String name, int value) {
checkContentLengthHeader(name, value); // <-- added
super.setIntHeader(name, value);
}
Before the fix only addHeader did this, so setIntHeader("Content-Length", n) left the wrapper's
tracked length at 0, onResponseCommitted() never fired, and HeaderWriterFilter never wrote its
headers before Tomcat committed the response.
The same hunk appears verbatim when diffing upstream 6.5.8 (last vulnerable) against 6.5.9
(first fixed), so this is the official fix backported, not a reimplementation. The Chainguard build
adds two null-guards upstream 6.5.9 does not have (value != null on the String overload, and
(csq != null) ? csq.length() : 4 in append).
Spring Security 5.7.x is end-of-life upstream; OSS fixes land only in 6.4.15 / 6.5.9 / 7.0.4+. If a rebuilt 5.7.x is not an option:
HeaderWriterFilter.shouldWriteHeadersEagerly = true via an
ObjectPostProcessor. Per the advisory this changes behaviour: application-written headers then
override only specific headers rather than suppressing Spring Security's. This one also fixes
/vuln/cache/account, which the version bump does not.None of these are wired into this project, so the vulnerable behaviour is the default and the
patched state is reachable by the single <version> change above.
Boot 2.7.18 pins Tomcat 9.0.83, which grype . flags with 34 CVEs (4 Critical). All of them are
fixed at 9.0.118 or below, and 9.0.118 is the newest 9.0.x release — so one property clears the set:
<properties>
<tomcat.version>9.0.118</tomcat.version>
</properties>
spring-boot-dependencies declares every tomcat-embed-* artifact through that single property, so
overriding it re-pins core, el and websocket together. Verified:
$ mvn dependency:tree | grep tomcat-embed
tomcat-embed-core:jar:9.0.118:compile
tomcat-embed-el:jar:9.0.118:compile
tomcat-embed-websocket:jar:9.0.118:compile
Constraint: stay on the 9.0.x line. Tomcat 10+ moved the Servlet API to jakarta.* while Spring
Framework 5.3 compiles against javax.servlet, so a 10.x/11.x bump fails at runtime with
NoClassDefFoundError on the servlet types.
Same mechanism applied to the rest of Boot 2.7.18's managed dependencies. No major-version bumps, and no Spring Boot upgrade:
These overrides interact with the patched parent, so know what they do before demoing. On
2.7.18-0.cgr.3 the parent already supplies tomcat.version 9.0.118, logback.version 1.2.13 and
snakeyaml.version 1.33 — those three rows become exact duplicates and can be deleted without
changing anything. The jackson-bom.version and log4j2.version rows still do real work: the
patched parent keeps Boot's stock 2.13.5 / 2.17.2, so the overrides win and those two dependencies
resolve to plain upstream builds rather than Chainguard-built ones. spring-framework.version is
commented out on purpose, which is what lets the parent's 5.3.39-0.cgr.4 through.
grype . progression| State | Findings | Breakdown |
|---|---|---|
| Stock Boot 2.7.18 | 99 | 7C / 39H / 38M / 15L |
| + Tomcat bump | 65 | 3C / 23H / 29M / 10L |
Fully cleared: Tomcat (34), Jackson (7), log4j (1). Overall 99 → 43, Highs 39 → 12.
Verified after every bump: the app boots on Tomcat/9.0.118, all six endpoints return 200, and the
CVE reproduces byte-for-byte. Spring Boot is still 2.7.18 and Spring Security still 5.7.11, so
CVE-2026-22732 is untouched — which is the point of this section, and also its honest limit:
patching everything around it does nothing for the application-framework CVE. Fixing that one needs
the parent bump, not a property.
The latest 1.x is 1.6.3 — same major, so nominally in scope. It does not work. Logback 1.3+ replaced
the SLF4J 1.7 StaticLoggerBinder with the SLF4J 2.x ServiceLoader provider, and Boot 2.7's
LogbackLoggingSystem calls StaticLoggerBinder directly. Measured with 1.5.38:
SLF4J: Failed to load class "org.slf4j.impl.StaticLoggerBinder"
Caused by: java.lang.NoClassDefFoundError: org/slf4j/impl/StaticLoggerBinder
at org.springframework.boot.logging.logback.LogbackLoggingSystem.getLoggerContext(...:304)
Escaping this needs SLF4J 2.x (a major bump) and a Boot 3.x logging system. 1.2.13 is the real ceiling, leaving 6 Logback findings (2 Medium, 4 Low) unfixable on this line.
Two of the three remaining Criticals are worth reading properly rather than by score:
HttpInvokerServiceExporter. This app does not use HTTP Invoker, so it is not reachable here.5.7.14-0.cgr.2 is past the fix version.The residue is structural: Spring Framework 5.3.x and Spring Security 5.7.x are both EOL. That, not Tomcat or Jackson, is the real argument for a Boot 3.x migration.
pom.xml parent + 2 starters, nothing else. Flip the <version> to switch state.
run.sh build + run on JDK 17, via settings-chainguard.xml
exploit.sh header diff, cache impact, clickjacking check, CVE-scoped verdict
settings-chainguard.xml Chainguard Libraries repo -- required for the patched parent
src/main/java/com/example/poc/
PocApplication.java @SpringBootApplication
SecurityConfig.java permitAll, zero header customisation
AccountController.java baseline, 2 exploits, 2 controls, 1 diagnostic
Auth is permitAll and CSRF is off so curl works unauthenticated — neither is part of this CVE.
| 2.7.18 | 2.7.18-0.cgr.3 |
|---|
/safe/account | OK 6/6 | OK 6/6 |
/vuln/stream/account | OK 6/6 | OK 6/6 |
/vuln/flush/account | OK 6/6 | OK 6/6 |
/vuln/content-length/account | BYPASSED 0/6 | OK 6/6 |
/vuln/cache/account | PARTIAL 4/6 | PARTIAL 4/6 (by design) |
exploit.sh verdict | VULNERABLE | PATCHED |
| Property | Boot 2.7.18 default | Pinned here | Ceiling reason |
|---|
tomcat.version | 9.0.83 | 9.0.118 | latest 9.0.x; 10+ is jakarta.* |
spring-framework.version | 5.3.31 | 5.3.39 | last OSS 5.3.x on Central |
jackson-bom.version | 2.13.5 | 2.22.2 | latest 2.x |
log4j2.version | 2.17.2 | 2.26.1 | latest 2.x |
snakeyaml.version | 1.30 | 1.33 | last 1.x; fix for the remaining CVE is 2.0 |
logback.version | 1.2.12 | 1.2.13 | last 1.2.x — see below |
spring-security.version | 5.7.11 | left alone | it is the subject of the demo |
| + all in-major bumps |
| 43 |
| 3C / 12H / 18M / 10L |
| Component | Why it can't be fixed in-major |
|---|
| spring-webmvc / -expression / -core / -context (25) | 5.3.39 is the last OSS 5.3.x; 14 of 15 webmvc findings have no fix at all, and the 5.3.42 grype cites is commercial-only |
| logback-core (6) | needs SLF4J 2.x, see above |
| spring-security-* (8) | EOL line; CVE-2026-22732 is deliberate |
| spring-boot / -autoconfigure (3) | no fix published for 2.7.x |
| snakeyaml (1) | CVE-2022-1471 is fixed only in 2.0 |