
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.
O Spring Security descarta silenciosamente os cabeçalhos de segurança da resposta HTTP. Apenas para fins de demonstração / educacionais; execute-o apenas contra este aplicativo local.
| 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: