
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: