
Proof-of-concept, демонстрирующий CVE-2026-22732 — уязвимость Spring Security, при которой setIntHeader("Content-Length") удаляет все заголовки безопасности, с уязвимой и исправленной сборками.
| 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 не может работать на JDK 25, который
mvn выбирает по умолчанию на этой машине. Переопределите через 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
Только /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, который несёт все шесть. Ответ может быть встроен во
фрейм с любого origin, подвержен MIME-sniffing и кэшируем — при этом отдавая номер карты.
Исправлено: более ранняя версия этого README называла это «эксплойтом 2» и утверждала, что это
условие, документированное в advisory. Это не часть CVE-2026-22732, и никакое обновление это не
исправляет. CacheControlHeadersWriter побайтово идентичен в 5.7.11, 5.7.14-0.cgr.2, 6.5.8 (последняя
уязвимая) и 6.5.9 (первая исправленная) — проверено сравнением sources jar. Его Javadoc прямо
указывает на это поведение: «Inserts headers to prevent caching if no cache control headers have been
specified.»
Это всё равно стоит демонстрировать, потому что утечка реальна и остаточный риск сохраняется после
патча. CacheControlHeadersWriter прекращает работу, если Cache-Control, Expires или Pragma
уже присутствует, поэтому установка любого одного из трёх подавляет все директивы no-store от
Spring Security. Достаточно одной благонамеренной строки:
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 считается «присутствующим» в таблице выше, но с дружественным атакующему значением,
выбранным приложением — Expires: 0 от Spring Security был заменён, а не просто отброшен. Данные
держателей карт теперь могут храниться каждым браузером и общим прокси на пути до 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() и записи в output-stream, поэтому он
выпускает свои заголовки раньше этих фиксаций. Один лишь порядок фиксации не является багом; путь с
объявленным Content-Length — является. Сам Spring advisory никогда не поддерживает историю о порядке
фиксации.
Сохранение этих двух эндпоинтов делает 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>
Это перефиксирует 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-boot-dependencies
родителя. Измерено:
| 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 |
При сравнении sources jar spring-security-web важен только один файл. 5.7.11 →
5.7.14-0.cgr.2 добавляет переопределения setHeader / setIntHeader / addIntHeader в
OnCommittedResponseWrapper, каждое из которых направляет 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 фиксировал ответ.
Тот же фрагмент появляется дословно при сравнении upstream 6.5.8 (последняя уязвимая) с 6.5.9
(первая исправленная), так что это официальное исправление, перенесённое бэкпортом, а не
перереализация. Сборка Chainguard добавляет две проверки на null, которых нет в upstream 6.5.9
(value != null в перегрузке для String, и (csq != null) ? csq.length() : 4 в append).
Spring Security 5.7.x — end-of-life в upstream; исправления OSS выходят только в 6.4.15 / 6.5.9 / 7.0.4+. Если пересобранный 5.7.x не вариант:
HeaderWriterFilter.shouldWriteHeadersEagerly = true через
ObjectPostProcessor. Согласно advisory это меняет поведение: записанные приложением заголовки
тогда переопределяют только конкретные заголовки, а не подавляют заголовки Spring Security. Это
также исправляет /vuln/cache/account, чего не делает обновление версии.Ничто из этого не подключено к этому проекту, поэтому уязвимое поведение является поведением по
умолчанию, а исправленное состояние достижимо единственным изменением <version> выше.
Boot 2.7.18 фиксирует Tomcat 9.0.83, который grype . помечает 34 CVE (4 Critical). Все они
исправлены в 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 падает во
время выполнения с NoClassDefFoundError на типах servlet.
Тот же механизм применён к остальным управляемым зависимостям Boot 2.7.18. Никаких мажорных обновлений и никакого обновления Spring Boot:
| Свойство | По умолчанию 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 | последняя OSS 5.3.x на Central |
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 | оставлено как есть | это предмет демо |
Эти переопределения взаимодействуют с исправленным родителем, поэтому знайте, что они делают, прежде
чем демонстрировать. На 2.7.18-0.cgr.3 родитель уже предоставляет tomcat.version 9.0.118,
logback.version 1.2.13 и snakeyaml.version 1.33 — эти три строки становятся точными дубликатами и
могут быть удалены без каких-либо изменений. Строки jackson-bom.version и log4j2.version всё ещё
делают реальную работу: исправленный родитель сохраняет стандартные 2.13.5 / 2.17.2 от Boot, поэтому
переопределения побеждают, и эти две зависимости разрешаются в обычные upstream-сборки, а не в собранные
Chainguard. spring-framework.version закомментирован намеренно, что и позволяет пройти
5.3.39-0.cgr.4 от родителя.
grype .| Состояние | Находки | Разбивка |
|---|---|---|
| Стандартный Boot 2.7.18 | 99 | 7C / 39H / 38M / 15L |
| + обновление Tomcat | 65 | 3C / 23H / 29M / 10L |
| + все обновления в пределах мажорной линии | 43 | 3C / 12H / 18M / 10L |
Полностью очищено: Tomcat (34), Jackson (7), log4j (1). В целом 99 → 43, Highs 39 → 12.
Проверено после каждого обновления: приложение загружается на Tomcat/9.0.118, все шесть эндпоинтов
возвращают 200, и CVE воспроизводится побайтово. Spring Boot всё ещё 2.7.18, а Spring Security всё ещё
5.7.11, поэтому CVE-2026-22732 не затронута — в чём и смысл этого раздела, а также его честный
предел: патчинг всего вокруг не делает ничего для CVE в application-framework. Для исправления этой
нужно обновление родителя, а не свойство.
Последняя 1.x — 1.6.3 — та же мажорная версия, так что номинально в области охвата. Она не работает.
Logback 1.3+ заменил SLF4J 1.7 StaticLoggerBinder на провайдер SLF4J 2.x ServiceLoader, а
LogbackLoggingSystem из Boot 2.7 вызывает 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) неисправимыми на этой линии.
| Компонент | Почему нельзя исправить в пределах мажорной версии |
|---|---|
| spring-webmvc / -expression / -core / -context (25) | 5.3.39 — последняя OSS 5.3.x; у 14 из 15 находок webmvc вообще нет исправления, а 5.3.42, которую указывает grype, только коммерческая |
| logback-core (6) | нужен SLF4J 2.x, см. выше |
| spring-security-* (8) | линия EOL; CVE-2026-22732 намеренна |
| spring-boot / -autoconfigure (3) | для 2.7.x исправление не опубликовано |
| snakeyaml (1) | CVE-2022-1471 исправлена только в 2.0 |
Две из трёх оставшихся Critical стоит прочитать внимательно, а не по баллам:
HttpInvokerServiceExporter. Это приложение не использует HTTP Invoker, поэтому здесь она
недостижима.5.7.14-0.cgr.2 прошёл версию исправления.Остаток структурный: Spring Framework 5.3.x и Spring Security 5.7.x оба EOL. Это, а не Tomcat или Jackson, — настоящий аргумент за миграцию на Boot 3.x.
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.