
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、1 箇所の <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 # terminal 1 — builds and starts on :8080 (pins JDK 17)
./exploit.sh # terminal 2 — drives every endpoint, diffs the headers
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 は実際の spring-security-web と spring-boot のバージョンを target/*.jar から読み取るため、そのバナーは常にハードコードされた文字列ではなく、実際に動作しているものを報告する。
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
CVE が修正されたときに状態が変わるのは /vuln/content-length/account だけであり、exploit.sh が判定を導き出すのはこのエンドポイントのみである。残りは対照群である。
setIntHeader("Content-Length", n) → 完全バイパス一見普通のコントローラーコード 3 行が、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 なし。6 つすべてを含む /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 を diff して検証済み。その Javadoc はこの挙動を明言している: 「キャッシュ制御ヘッダーが指定されていない場合に、キャッシュを防ぐヘッダーを挿入する。」
それでもこれを実証する価値はある。リークは現実であり、残存リスクはパッチ適用後も生き残るからである。CacheControlHeadersWriter は Cache-Control、Expires または Pragma がすでに存在する場合に処理を中止するため、3 つのうちどれか 1 つを設定するだけで Spring Security の no-store ディレクティブすべてが抑制される。善意の 1 行でそれが起きる:
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 ではそれは誤りである。 両エンドポイントとも 6 つのヘッダーすべてを配信する。
/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 のアドバイザリ自体、コミット順序の話を一切支持していない。
これら 2 つのエンドポイントを残すことで PoC は反証可能になる: 何が再現しないかを、何が再現するかと同じくらい明確に示し、両方ともパッチを通じてグリーンのままであることが、反転する唯一のエンドポイントを意味あるものにしている。
pom.xml の 1 行、それだけ。 ソース変更なし、プロパティ変更なし、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>
これにより親の 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 に)再固定される。測定結果:
| 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 |
spring-security-web のソース jar を diff すると、重要なファイルは 1 つだけである。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(最初の修正版)を diff しても同じハンクがそのまま現れるため、これは再実装ではなく公式修正のバックポートである。Chainguard ビルドはアップストリーム 6.5.9 にはない 2 つの null ガードを追加している(String オーバーロードの value != null、および append の (csq != null) ? csq.length() : 4)。
Spring Security 5.7.x はアップストリームで EOL である; OSS の修正は 6.4.15 / 6.5.9 / 7.0.4+ にのみ入る。再ビルドされた 5.7.x が選択肢にならない場合:
ObjectPostProcessor 経由で HeaderWriterFilter.shouldWriteHeadersEagerly = true を設定する。アドバイザリによればこれは挙動を変える: アプリケーションが書いたヘッダーは、Spring Security のヘッダーを抑制するのではなく、特定のヘッダーのみを上書きするようになる。これはまた /vuln/cache/account も修正するが、バージョンアップではそれは修正されない。