
Proof-of-concept, демонстрирующий CVE-2026-22732 — уязвимость Spring Security, при которой setIntHeader("Content-Length") удаляет все заголовки безопасности, с уязвимой и исправленной сборками.
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 не может работать на 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: