Spring Security 会静默丢弃 HTTP 响应安全头。仅供演示 / 教育用途;请仅针对此本地应用运行。
| CVE | CVE-2026-22732 (CWE-425),发布于 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 |
| 直接依赖 | spring-boot-starter-web + spring-boot-starter-security 2.7.18 |
| 受影响组件 | spring-security-web / -config / -core 5.7.11 — 仅为传递依赖,从未在 pom.xml 中显式声明 |
| 修复组件 | spring-security-web 5.7.14-0.cgr.2,只需修改一处 <version> 即可达到 — 参见过渡过程 |
| 验证环境 | Tomcat 9.0.118、JDK 17.0.18、macOS arm64 |
受影响范围: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 固定使用 Spring Security 5.7.11,正好落在第一个范围内:
$ 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 # 终端 1 — 构建并在 :8080 上启动(固定 JDK 17)
./exploit.sh # 终端 2 — 驱动每个端点,对比响应头差异
run.sh 固定了 JAVA_HOME,因为 Spring Boot 2.7.x 无法在此机器上 mvn 默认解析出的 JDK 25 上运行。可通过 JAVA_HOME_17=/path/to/jdk17 覆盖。
它默认还会使用 -s settings-chainguard.xml 进行构建,因为修复后的父 POM 不在 Maven Central 上。设置 MAVEN_SETTINGS=/path/to/your/settings.xml 可指向其他位置,或设置 MAVEN_SETTINGS= 完全从 Central 构建 — 这仅对原版 2.7.18 有效。
exploit.sh 会从 target/*.jar 中读取实际的 spring-security-web 和 spring-boot 版本,因此其横幅始终报告真正运行的版本,而非硬编码字符串。
SecurityConfig 完全没有进行任何响应头定制 — Spring Security 的默认设置生效,这正是注重安全的应用所依赖的。每个端点都返回相同的敏感响应体:
{"account":"4111-1111-1111-1111","holder":"D. Havelock","balance":"82914.55"}
唯一不同的是控制器写入响应的方式。
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
只有 /vuln/content-length/account 在 CVE 被修复后状态发生变化,因此它是 exploit.sh 得出判定结论的唯一端点。其余均为对照组。
setIntHeader("Content-Length", n) → 完全绕过三行看似普通的控制器代码就剥离了 Spring Security 承诺的每一个响应头:
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
没有 X-Frame-Options,没有 X-Content-Type-Options,没有 Cache-Control,没有 Pragma,没有 Expires,没有 X-XSS-Protection。对比 /safe/account,它携带了全部六个响应头。该响应可被任意源嵌入框架、可被 MIME 嗅探、可被缓存 — 而它提供的却是一个卡号。
更正: 本 README 的早期版本将此称为“exploit 2”,并声称这是公告所记录的条件。它不属于 CVE-2026-22732,且没有任何升级能修复它。CacheControlHeadersWriter 在 5.7.11、5.7.14-0.cgr.2、6.5.8(最后一个受影响版本)和 6.5.9(第一个修复版本)中逐字节相同 — 已通过对比源码 jar 验证。其 Javadoc 明确说明了该行为:“在未指定任何缓存控制头的情况下,插入响应头以防止缓存。”
它仍然值得演示,因为泄漏是真实存在的,且残余风险在打补丁后依然存在。CacheControlHeadersWriter 在 Cache-Control、Expires 或 Pragma 已存在时会直接退出,因此设置三者中任意一个都会抑制 Spring Security 的所有 no-store 指令。一行善意的代码就能做到:
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 算作“存在”,但应用选择了一个对攻击者友好的值 — Spring Security 的 Expires: 0 被替换了,而不仅仅是被丢弃。持卡人数据现在可被路径上的每个浏览器和共享代理存储,直到 2099 年。
在修复后的构建中,/vuln/content-length/account 翻转为 NOT cacheable,而 /vuln/cache/account 保持完全如上。只有应用代码或反向代理能修复它 — 这正是演示中值得大声说出的有用信息:升级库关闭了 CVE,却对此毫无影响。
若干广泛流传的分析文章 — 包括一个公开的复现仓库 — 将 response.getOutputStream() 和 response.flushBuffer() 列为触发条件,解释说“响应在 Spring Security 能注入其响应头之前就已提交”。在 Spring Security 5.7.11 上这是错误的。 两个端点都交付了全部六个响应头。
/diag/committed 展示了为什么该解释不成立。在写入 12 KB 后,响应确实已在控制器内部提交,但响应头仍然到达:
>>> DIAG response.isCommitted() after 12048 byte write = true
| wrapper class = org.springframework.security.web.header.HeaderWriterFilter$HeaderWriterResponse
OnCommittedResponseWrapper 重写了 flushBuffer() 和输出流写入,因此它能在这些提交之前将响应头发送出去。仅凭提交顺序本身并不是缺陷;声明 Content-Length 的路径才是。Spring 的公告本身从未认可提交顺序这一说法。
保留这两个端点使 PoC 可被证伪:它展示了什么不会复现,与什么会复现同样清晰,且两者在补丁前后都保持绿色,这正是使那个确实翻转的端点具有意义的原因。
pom.xml 中改一行,仅此而已。 无需修改源码、无需修改属性、无需升级 Spring Boot 大版本:
<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>
这会通过父 POM 的 spring-boot-dependencies 将 spring-security.version 从 5.7.11 重新固定为 5.7.14-0.cgr.2(并将 spring-framework.version 从 5.3.31 重新固定为 5.3.39-0.cgr.4)。实测结果:
对比 spring-security-web 源码 jar,只有一个文件重要。5.7.11 → 5.7.14-0.cgr.2 为 OnCommittedResponseWrapper 添加了 setHeader / setIntHeader / addIntHeader 重写,每个都将 Content-Length 路由到 setContentLength():
@Override
public void setIntHeader(String name, int value) {
checkContentLengthHeader(name, value); // <-- added
super.setIntHeader(name, value);
}
修复前只有 addHeader 这样做,因此 setIntHeader("Content-Length", n) 使包装器跟踪的长度保持为 0,onResponseCommitted() 从未触发,HeaderWriterFilter 也从未在 Tomcat 提交响应之前写入其响应头。
对比上游 6.5.8(最后一个受影响版本)与 6.5.9(第一个修复版本)时,同一代码块逐字出现,因此这是官方修复的向后移植,而非重新实现。Chainguard 构建添加了两个上游 6.5.9 没有的空值保护(String 重载上的 value != null,以及 append 中的 (csq != null) ? csq.length() : 4)。
Spring Security 5.7.x 在上游已停止维护;OSS 修复仅出现在 6.4.15 / 6.5.9 / 7.0.4+ 中。如果无法选择重新构建的 5.7.x:
ObjectPostProcessor 设置 HeaderWriterFilter.shouldWriteHeadersEagerly = true。根据公告,这会改变行为:应用写入的响应头随后仅覆盖特定响应头,而不会抑制 Spring Security 的响应头。这一方案还能修复 /vuln/cache/account,而版本升级则不能。这些都没有接入本项目,因此受影响行为是默认状态,而修复状态可通过上述单处 <version> 修改达到。
Boot 2.7.18 固定使用 Tomcat 9.0.83,grype . 标记其存在 34 个 CVE(4 个严重)。所有这些都在 9.0.118 或更低版本中修复,而 9.0.118 是最新的 9.0.x 版本 — 因此一个属性即可清除全部:
<properties>
<tomcat.version>9.0.118</tomcat.version>
</properties>
spring-boot-dependencies 通过这一个属性声明所有 tomcat-embed-* 构件,因此覆盖它会同时重新固定 core、el 和 websocket。已验证:
$ 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
约束: 保持在 9.0.x 线上。Tomcat 10+ 将 Servlet API 迁移到 jakarta.*,而 Spring Framework 5.3 是针对 javax.servlet 编译的,因此升级到 10.x/11.x 会在运行时因 servlet 类型上的 NoClassDefFoundError 而失败。
同样的机制应用于 Boot 2.7.18 管理的其余依赖。没有大版本升级,也没有 Spring Boot 升级:
这些覆盖会与修复后的父 POM 交互,因此演示前请了解它们的作用。 在 2.7.18-0.cgr.3 上,父 POM 已提供 tomcat.version 9.0.118、logback.version 1.2.13 和 snakeyaml.version 1.33 — 这三行成为完全重复项,可删除而不改变任何内容。jackson-bom.version 和 log4j2.version 两行仍然有实际作用:修复后的父 POM 保留 Boot 的原始 2.13.5 / 2.17.2,因此覆盖生效,这两个依赖解析为普通上游构建而非 Chainguard 构建。spring-framework.version 被有意注释掉,这正是让父 POM 的 5.3.39-0.cgr.4 得以通过的原因。
grype . 进展| 状态 | 发现数 | 明细 |
|---|---|---|
| 原版 Boot 2.7.18 | 99 | 7C / 39H / 38M / 15L |
| + Tomcat 升级 | 65 | 3C / 23H / 29M / 10L |
| + 所有大版本内升级 |
完全清除:Tomcat (34)、Jackson (7)、log4j (1)。总体 99 → 43,High 39 → 12。
每次升级后均已验证:应用在 Tomcat/9.0.118 上启动,所有六个端点返回 200,且 CVE 逐字节复现。Spring Boot 仍为 2.7.18,Spring Security 仍为 5.7.11,因此 CVE-2026-22732 未受影响 — 这正是本节的重点,也是其诚实的局限:修补周围的一切对应用框架 CVE 毫无作用。修复那一个需要升级父 POM,而非修改属性。
最新的 1.x 是 1.6.3 — 同一大版本,因此名义上在范围内。但它无法工作。Logback 1.3+ 用 SLF4J 2.x 的 ServiceLoader 提供程序替换了 SLF4J 1.7 的 StaticLoggerBinder,而 Boot 2.7 的 LogbackLoggingSystem 直接调用 StaticLoggerBinder。使用 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)
摆脱这一点需要 SLF4J 2.x(大版本升级)以及 Boot 3.x 的日志系统。1.2.13 是真正的上限,在此线上留下 6 个 Logback 发现(2 个 Medium、4 个 Low)无法修复。
| 组件 | 为何无法在大版本内修复 |
|---|
剩余三个严重漏洞中有两个值得认真阅读,而非仅看评分:
HttpInvokerServiceExporter 的反序列化。此应用不使用 HTTP Invoker,因此在此处不可达。5.7.14-0.cgr.2 已超过修复版本。残余问题是结构性的:Spring Framework 5.3.x 和 Spring Security 5.7.x 都已停止维护。这才是迁移到 Boot 3.x 的真正理由,而非 Tomcat 或 Jackson。
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
认证为 permitAll 且 CSRF 已关闭,以便 curl 无需认证即可工作 — 两者都不属于此 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 (设计如此) |
exploit.sh 判定 | VULNERABLE | PATCHED |
| 属性 | Boot 2.7.18 默认值 | 此处固定为 | 上限原因 |
|---|
tomcat.version | 9.0.83 | 9.0.118 | 最新的 9.0.x;10+ 是 jakarta.* |
spring-framework.version | 5.3.31 | 5.3.39 | Central 上最后一个 OSS 5.3.x |
jackson-bom.version | 2.13.5 | 2.22.2 | 最新的 2.x |
log4j2.version | 2.17.2 | 2.26.1 | 最新的 2.x |
snakeyaml.version | 1.30 | 1.33 | 最后一个 1.x;剩余 CVE 的修复版本是 2.0 |
logback.version | 1.2.12 | 1.2.13 | 最后一个 1.2.x — 见下文 |
spring-security.version | 5.7.11 | 保持不变 | 它是本演示的主题 |
| 3C / 12H / 18M / 10L |
| spring-webmvc / -expression / -core / -context (25) | 5.3.39 是最后一个 OSS 5.3.x;15 个 webmvc 发现中有 14 个根本没有修复,且 grype 引用的 5.3.42 仅限商业版 |
| logback-core (6) | 需要 SLF4J 2.x,见上文 |
| spring-security-* (8) | 已停止维护的线;CVE-2026-22732 是刻意保留的 |
| spring-boot / -autoconfigure (3) | 2.7.x 没有发布修复 |
| snakeyaml (1) | CVE-2022-1471 仅在 2.0 中修复 |