## https://sploitus.com/exploit?id=EEC836DD-F307-57D4-9FD8-3DFE8C849ECE
# CVE-2026-22732 β Proof of Concept
Spring Security silently drops HTTP response security headers. Demo / educational use only; run it
against nothing but this local app.
| | |
|---|---|
| CVE | [CVE-2026-22732](https://spring.io/security/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 `` change β see [the transition](#the-vulnerable--patched-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 it
```bash
./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.
## The setup
`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:
```json
{"account":"4111-1111-1111-1111","holder":"D. Havelock","balance":"82914.55"}
```
The only thing that varies is how the controller writes the response.
## Measured results
```
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) >> 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.
## The vulnerable β patched transition
**One line in `pom.xml`, nothing else.** No source change, no property change, no Spring Boot major
bump:
```xml
org.springframework.boot
spring-boot-starter-parent
2.7.18
2.7.18-0.cgr.3
```
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:
| | 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` |
### The backport is the upstream fix
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()`:
```java
@Override
public void setIntHeader(String name, int value) {
checkContentLengthHeader(name, value); // ` change above.
## Patching the embedded Tomcat without upgrading Spring Boot
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:
```xml
9.0.118
```
`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.
## Patching everything else, still within each major line
Same mechanism applied to the rest of Boot 2.7.18's managed dependencies. No major-version bumps, and
no Spring Boot upgrade:
| 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 |
**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 |
| + all in-major bumps | **43** | **3C / 12H / 18M / 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.
### Why Logback stops at 1.2.13
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.
### The 43 that remain
| 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 |
Two of the three remaining Criticals are worth reading properly rather than by score:
- **CVE-2016-1000027** (spring-web, fix 6.0.0) β deserialization via
`HttpInvokerServiceExporter`. This app does not use HTTP Invoker, so it is not reachable here.
- **CVE-2024-38821** (spring-security-web, fix 5.7.13) β static-resource auth bypass in **WebFlux**.
This is a servlet app, so also not reachable. It *is* fixable in-major (5.7.13/5.7.14 are on
Central) and was left only to keep this section pinned at 5.7.11. The patched parent clears it as
a side effect, since `5.7.14-0.cgr.2` is past the fix version.
- **CVE-2026-22732** β intentional in the vulnerable state; cleared by the parent bump.
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.
## Layout
```
pom.xml parent + 2 starters, nothing else. Flip the 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.
## Sources
- [spring.io/security/cve-2026-22732](https://spring.io/security/cve-2026-22732/) β official advisory
- [GHSA-mf92-479x-3373](https://github.com/advisories/ghsa-mf92-479x-3373)
- [NVD CVE-2026-22732](https://nvd.nist.gov/vuln/detail/CVE-2026-22732)
- [Broadcom / Tanzu write-up](https://knowledge.broadcom.com/external/article/434435/cve202622732-spring-security-http-respon.html)
- [HeroDevs analysis](https://www.herodevs.com/blog-posts/cve-2026-22732-spring-security-silently-drops-http-security-headers)
- [semgrep/cve-2026-22732-demo](https://github.com/semgrep/cve-2026-22732-demo) β the reproduction whose stream/flush claims did not hold here
- [Red Hat Bugzilla #2449306](https://bugzilla.redhat.com/show_bug.cgi?id=2449306)