
Prova de conceito demonstrando a CVE-2026-22732, uma falha do Spring Security em que setIntHeader("Content-Length") remove todos os cabeçalhos de segurança, com builds vulneráveis e corrigidos.
| CVE | CVE-2026-22732 (CWE-425), publicado em 2026-03-19 |
| CVSS 3.1 | 9.1 CRÍTICO — AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N |
| Dependência direta | spring-boot-starter-web + spring-boot-starter-security 2.7.18 |
| Componente vulnerável | spring-security-web / -config / -core 5.7.11 — apenas transitivo, nunca nomeado no pom.xml |
| Componente corrigido | spring-security-web 5.7.14-0.cgr.2, alcançado por uma única alteração de <version> — veja a transição |
| Verificado em | Tomcat 9.0.118, JDK 17.0.18, macOS arm64 |
Intervalos afetados: 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. O Spring Boot 2.7.18 fixa o Spring Security 5.7.11, exatamente dentro do primeiro intervalo:
$ 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 — compila e inicia na :8080 (fixa o JDK 17)
./exploit.sh # terminal 2 — aciona todos os endpoints, compara os cabeçalhos
O run.sh fixa o JAVA_HOME porque o Spring Boot 2.7.x não consegue rodar no JDK 25 que o mvn
resolve por padrão nesta máquina. Substitua com JAVA_HOME_17=/path/to/jdk17.
Ele também compila com -s settings-chainguard.xml por padrão, porque o parent corrigido não está no
Maven Central. Defina MAVEN_SETTINGS=/path/to/your/settings.xml para apontar para outro local, ou
MAVEN_SETTINGS= para compilar puramente a partir do Central — o que funciona apenas para o 2.7.18
original.
O exploit.sh lê as versões reais de spring-security-web e spring-boot de dentro de
target/*.jar, então seu banner sempre reporta o que está realmente em execução em vez de uma string
fixa.
O SecurityConfig não aplica nenhuma customização de cabeçalho — os padrões do Spring Security
estão em vigor, que é exatamente com o que um aplicativo consciente de segurança conta. Todo endpoint
retorna o mesmo corpo sensível:
{"account":"4111-1111-1111-1111","holder":"D. Havelock","balance":"82914.55"}
A única coisa que varia é como o controller escreve a resposta.
BASELINE valor de retorno padrão do Spring MVC
/safe/account OK todos os 6 cabeçalhos entregues
CONTROL getOutputStream(), corpo > buffer de 8 KB
/vuln/stream/account OK todos os 6 cabeçalhos entregues
CONTROL response.flushBuffer() explícito
/vuln/flush/account OK todos os 6 cabeçalhos entregues
EXPLOIT setIntHeader("Content-Length", n) <-- CVE-2026-22732
/vuln/content-length/account BYPASSED TODOS os 6 cabeçalhos de segurança descartados
BY DESIGN aplicação define seu próprio cabeçalho Expires (NÃO é este CVE)
/vuln/cache/account PARTIAL Cache-Control + Pragma descartados
Apenas /vuln/content-length/account muda de estado quando o CVE é corrigido, então é o único
endpoint do qual o exploit.sh deriva seu veredito. Os demais são controles.
setIntHeader("Content-Length", n) → bypass totalTrês linhas de código de controller aparentemente comum removem todos os cabeçalhos que o Spring Security prometeu:
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
Sem X-Frame-Options, sem X-Content-Type-Options, sem Cache-Control, sem Pragma, sem Expires,
sem X-XSS-Protection. Compare com /safe/account, que carrega todos os seis. A resposta é
enquadrável por qualquer origem, sujeita a MIME-sniffing e cacheável — enquanto serve um número de
cartão.
Corrigido: uma versão anterior deste README chamava isso de "exploit 2" e afirmava ser a condição
documentada pelo advisory. Não faz parte do CVE-2026-22732 e nenhuma atualização o corrige.
O CacheControlHeadersWriter é byte a byte idêntico em 5.7.11, 5.7.14-0.cgr.2, 6.5.8 (último
vulnerável) e 6.5.9 (primeiro corrigido) — verificado comparando os jars de código-fonte. Seu Javadoc
declara o comportamento explicitamente: "Inserts headers to prevent caching if no cache control
headers have been specified."
Ainda vale a pena demonstrar, porque o vazamento é real e o risco residual sobrevive à correção.
O CacheControlHeadersWriter desiste se Cache-Control, Expires ou Pragma já estiver
presente, então definir qualquer um dos três suprime todas as diretivas no-store do Spring
Security. Uma linha bem-intencionada basta:
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 conta como "presente" na tabela acima, mas com um valor amigável ao atacante escolhido pela
aplicação — o Expires: 0 do Spring Security foi substituído, não apenas descartado. Dados de
portador de cartão agora são armazenáveis por todo navegador e proxy compartilhado no caminho até
2099.
Na build corrigida, /vuln/content-length/account muda para NOT cacheable, enquanto
/vuln/cache/account permanece exatamente como acima. Apenas código da aplicação ou um proxy reverso
o corrige — o que é a coisa útil a dizer em voz alta numa demonstração: atualizar a biblioteca fecha o
CVE e deixa isso intacto.
Vários write-ups amplamente divulgados — incluindo um repositório público de reprodução — listam
response.getOutputStream() e response.flushBuffer() como gatilhos, explicando que "a resposta é
commitada antes que o Spring Security possa injetar seus cabeçalhos". No Spring Security 5.7.11 isso
está errado. Ambos os endpoints entregam todos os seis cabeçalhos.
/diag/committed mostra por que a explicação não se sustenta. Após uma escrita de 12 KB, a resposta
realmente é commitada dentro do controller, mas os cabeçalhos ainda chegam:
>>> DIAG response.isCommitted() after 12048 byte write = true
| wrapper class = org.springframework.security.web.header.HeaderWriterFilter$HeaderWriterResponse
O OnCommittedResponseWrapper sobrescreve flushBuffer() e as escritas do output-stream, então ele
emite seus cabeçalhos antes desses commits. A ordenação de commit por si só não é o bug; o caminho do
Content-Length declarado é. O próprio advisory do Spring nunca endossa a história da ordenação de
commit.
Manter esses dois endpoints torna a PoC falseável: mostra o que não se reproduz tão claramente quanto o que se reproduz, e ambos permanecem verdes ao longo da correção, o que é o que torna o único endpoint que de fato muda significativo.
Uma linha no pom.xml, nada mais. Sem alteração de código-fonte, sem alteração de propriedade,
sem salto de versão major do 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>
Isso refixa spring-security.version de 5.7.11 para 5.7.14-0.cgr.2 (e
spring-framework.version de 5.3.31 para 5.3.39-0.cgr.4) através do
spring-boot-dependencies do parent. Medido:
| 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) |
veredito do exploit.sh | VULNERABLE | PATCHED |
Comparando os jars de código-fonte do spring-security-web, apenas um arquivo importa. 5.7.11 →
5.7.14-0.cgr.2 adiciona sobrescritas de setHeader / setIntHeader / addIntHeader ao
OnCommittedResponseWrapper, cada uma roteando Content-Length através de setContentLength():
@Override
public void setIntHeader(String name, int value) {
checkContentLengthHeader(name, value); // <-- added
super.setIntHeader(name, value);
}
Antes da correção, apenas addHeader fazia isso, então setIntHeader("Content-Length", n) deixava o
comprimento rastreado do wrapper em 0, onResponseCommitted() nunca disparava, e o
HeaderWriterFilter nunca escrevia seus cabeçalhos antes que o Tomcat commitasse a resposta.
O mesmo trecho aparece literalmente ao comparar o upstream 6.5.8 (último vulnerável) com 6.5.9
(primeiro corrigido), então esta é a correção oficial portada, não uma reimplementação. A build da
Chainguard adiciona duas verificações de nulo que o upstream 6.5.9 não tem (value != null na
sobrecarga de String, e (csq != null) ? csq.length() : 4 em append).
O Spring Security 5.7.x está em fim de vida no upstream; correções OSS chegam apenas em 6.4.15 / 6.5.9 / 7.0.4+. Se uma 5.7.x recompilada não for uma opção:
HeaderWriterFilter.shouldWriteHeadersEagerly = true via um
ObjectPostProcessor. Segundo o advisory, isso altera o comportamento: cabeçalhos escritos pela
aplicação então sobrescrevem apenas cabeçalhos específicos em vez de suprimir os do Spring
Security. Este também corrige /vuln/cache/account, o que o bump de versão não faz.Nenhuma delas está integrada a este projeto, então o comportamento vulnerável é o padrão e o estado
corrigido é alcançável pela única alteração de <version> acima.
O Boot 2.7.18 fixa o Tomcat 9.0.83, que o grype . sinaliza com 34 CVEs (4 Críticos). Todos eles
são corrigidos na 9.0.118 ou abaixo, e a 9.0.118 é a versão 9.0.x mais recente — então uma propriedade
limpa o conjunto:
<properties>
<tomcat.version>9.0.118</tomcat.version>
</properties>
O spring-boot-dependencies declara cada artefato tomcat-embed-* através dessa única propriedade,
então sobrescrevê-la refixa core, el e websocket juntos. Verificado:
$ 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
Restrição: permaneça na linha 9.0.x. O Tomcat 10+ moveu a Servlet API para jakarta.* enquanto o
Spring Framework 5.3 compila contra javax.servlet, então um bump para 10.x/11.x falha em tempo de
execução com NoClassDefFoundError nos tipos de servlet.
O mesmo mecanismo aplicado ao restante das dependências gerenciadas do Boot 2.7.18. Sem bumps de versão major, e sem atualização do Spring Boot:
| Propriedade | Padrão do Boot 2.7.18 | Fixado aqui | Motivo do teto |
|---|---|---|---|
tomcat.version | 9.0.83 | 9.0.118 | última 9.0.x; 10+ é jakarta.* |
spring-framework.version | 5.3.31 | 5.3.39 | última 5.3.x OSS no Central |
jackson-bom.version | 2.13.5 | 2.22.2 | última 2.x |
log4j2.version | 2.17.2 | 2.26.1 | última 2.x |
snakeyaml.version | 1.30 | 1.33 | última 1.x; a correção para o CVE restante é a 2.0 |
logback.version | 1.2.12 | 1.2.13 | última 1.2.x — veja abaixo |
spring-security.version | 5.7.11 | deixada em paz | é o objeto da demonstração |
Essas sobrescritas interagem com o parent corrigido, então saiba o que elas fazem antes de
demonstrar. No 2.7.18-0.cgr.3 o parent já fornece tomcat.version 9.0.118, logback.version
1.2.13 e snakeyaml.version 1.33 — essas três linhas se tornam duplicatas exatas e podem ser
removidas sem alterar nada. As linhas jackson-bom.version e log4j2.version ainda fazem trabalho
real: o parent corrigido mantém os padrões 2.13.5 / 2.17.2 do Boot, então as sobrescritas vencem e
essas duas dependências resolvem para builds upstream puros em vez de builds da Chainguard.
spring-framework.version está comentada de propósito, o que é o que deixa passar o 5.3.39-0.cgr.4
do parent.
grype .| Estado | Achados | Detalhamento |
|---|---|---|
| Boot 2.7.18 original | 99 | 7C / 39H / 38M / 15L |
| + bump do Tomcat | 65 | 3C / 23H / 29M / 10L |
| + todos os bumps in-major | 43 | 3C / 12H / 18M / 10L |
Totalmente limpos: Tomcat (34), Jackson (7), log4j (1). No geral 99 → 43, Altos 39 → 12.
Verificado após cada bump: o aplicativo inicia em Tomcat/9.0.118, todos os seis endpoints retornam
200, e o CVE se reproduz byte a byte. O Spring Boot ainda é 2.7.18 e o Spring Security ainda é 5.7.11,
então o CVE-2026-22732 permanece intocado — que é o ponto desta seção, e também seu limite
honesto: corrigir tudo ao redor não faz nada pelo CVE do framework de aplicação. Corrigir aquele
requer o bump do parent, não uma propriedade.
A 1.x mais recente é a 1.6.3 — mesmo major, então nominalmente no escopo. Não funciona. O Logback 1.3+
substituiu o StaticLoggerBinder do SLF4J 1.7 pelo provedor ServiceLoader do SLF4J 2.x, e o
LogbackLoggingSystem do Boot 2.7 chama StaticLoggerBinder diretamente. Medido com 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)
Escapar disso exige SLF4J 2.x (um bump major) e um sistema de logging do Boot 3.x. A 1.2.13 é o teto real, deixando 6 achados do Logback (2 Médios, 4 Baixos) não corrigíveis nesta linha.
| Componente | Por que não pode ser corrigido in-major |
|---|---|
| spring-webmvc / -expression / -core / -context (25) | 5.3.39 é a última 5.3.x OSS; 14 dos 15 achados do webmvc não têm correção alguma, e a 5.3.42 que o grype cita é apenas comercial |
| logback-core (6) | precisa do SLF4J 2.x, veja acima |
| spring-security-* (8) | linha em EOL; o CVE-2026-22732 é deliberado |
| spring-boot / -autoconfigure (3) | nenhuma correção publicada para 2.7.x |
| snakeyaml (1) | o CVE-2022-1471 é corrigido apenas na 2.0 |
Dois dos três Críticos restantes merecem ser lidos adequadamente em vez de por pontuação:
HttpInvokerServiceExporter. Este aplicativo não usa HTTP Invoker, então não é alcançável aqui.5.7.14-0.cgr.2 está além da
versão de correção.O resíduo é estrutural: o Spring Framework 5.3.x e o Spring Security 5.7.x estão ambos em EOL. Isso, não o Tomcat ou o Jackson, é o verdadeiro argumento para uma migração para o Boot 3.x.
pom.xml parent + 2 starters, nada mais. Alterne o <version> para trocar de estado.
run.sh compila + executa no JDK 17, via settings-chainguard.xml
exploit.sh diff de cabeçalhos, impacto de cache, verificação de clickjacking, veredito com escopo do CVE
settings-chainguard.xml repositório Chainguard Libraries -- necessário para o parent corrigido
src/main/java/com/example/poc/
PocApplication.java @SpringBootApplication
SecurityConfig.java permitAll, zero customização de cabeçalho
AccountController.java baseline, 2 exploits, 2 controles, 1 diagnóstico
A autenticação é permitAll e o CSRF está desativado para que o curl funcione sem autenticação —
nenhum dos dois faz parte deste CVE.