Cache Poisoning & Cache Deception
What it is
Two related but distinct bugs. Cache poisoning: an unkeyed input — a header or query param the cache doesn't include in its cache key — changes the response, and the cache then serves that poisoned response to every other visitor. Cache deception: tricking a cache into storing a private, personalized response under a URL that looks static, then fetching that same URL unauthenticated to read someone else's data.
Why it matters
The blast radius is the whole cache, not just your own request — one poisoned entry affects every visitor to that URL until it expires or gets purged. That's what turns a 'reflection' bug into something worth taking seriously.
Where to look
- Anything sitting behind a CDN or reverse-proxy cache
- Look at
,Vary
andCache-Control
headersAge - Endpoints that reflect
,X-Forwarded-Host
orX-Forwarded-Scheme
into the responseHost
How I test it
- Identify caching:
,Cache-Control
,Age
headers, or just suspiciously consistent response timing.X-Cache - Add an unkeyed header —
,X-Forwarded-Host
,X-Forwarded-Scheme
— with an attacker-controlled value and check if it reflects in the body, a redirect, or a script src.X-Original-URL - Confirm the poisoned response actually gets cached and served back to a second, unauthenticated request from a different session — without this step, all you have is reflection, not poisoning.
- For cache deception: append a fake static-looking suffix to a private authenticated URL (
) and check whether the personalized response gets cached and can then be fetched without authentication./account/settings/x.css
Hunting checklist
- Confirmed a caching layer exists
- Tried at least one unkeyed header for reflection
- Verified the poisoned response is served to a second, unauthenticated, different-session request
- Tried cache deception with a static-looking suffix on a private URL
- Captured the response before AND after poisoning to prove the change
Evidence to collect
- Request with the unkeyed header
- First (poisoned) response
- Second, unrelated request showing the same poisoned response served back
- Cache-related headers on both responses (Age, X-Cache)
- For deception: proof the private data was retrievable without authentication
Recommended fix
- Include every input that affects the response in the cache key, or don't cache responses that depend on it
- Strip/normalize forwarded headers at the edge before they reach the origin
- Never cache authenticated/personalized responses by default
- Match cache rules on exact paths, not loose suffix patterns
Qué es
Dos bugs relacionados pero distintos. Cache poisoning: un input no incluido en la clave de caché — una cabecera o parámetro que la caché no tiene en cuenta — cambia la respuesta, y la caché sirve después esa respuesta envenenada a cualquier otro visitante. Cache deception: engañar a una caché para que guarde una respuesta privada y personalizada bajo una URL que parece estática, y luego pedir esa misma URL sin autenticarse para leer los datos de otra persona.
Por qué importa
El radio de impacto es la caché entera, no solo tu propia petición — una entrada envenenada afecta a cada visitante de esa URL hasta que expira o se purga. Eso es lo que convierte un bug de 'reflejo' en algo que merece la pena tomarse en serio.
Dónde buscar
- Cualquier cosa detrás de una CDN o caché de proxy inverso
- Fíjate en las cabeceras
,Vary
yCache-ControlAge - Endpoints que reflejan
,X-Forwarded-Host
oX-Forwarded-Scheme
en la respuestaHost
Cómo lo pruebo
- Identifica si hay caché: cabeceras
,Cache-Control
,Age
, o simplemente un tiempo de respuesta sospechosamente constante.X-Cache - Añade una cabecera no incluida en la clave de caché —
,X-Forwarded-Host
,X-Forwarded-Scheme
— con un valor controlado por ti y comprueba si se refleja en el cuerpo, una redirección o un src de script.X-Original-URL - Confirma que la respuesta envenenada se guarda en caché y se sirve de vuelta a una segunda petición, sin autenticar, desde otra sesión — sin este paso, solo tienes un reflejo, no un envenenamiento.
- Para cache deception: añade un sufijo falso con pinta de estático a una URL privada y autenticada (
) y comprueba si la respuesta personalizada se cachea y luego se puede pedir sin autenticación./account/settings/x.css
Checklist de hunting
- Confirmada la existencia de una capa de caché
- Probada al menos una cabecera no incluida en la clave, buscando reflejo
- Verificado que la respuesta envenenada se sirve a una segunda petición, sin autenticar, desde otra sesión
- Probado cache deception con un sufijo con pinta de estático en una URL privada
- Capturada la respuesta antes Y después del envenenamiento para probar el cambio
Evidencias para el reporte
- Petición con la cabecera no incluida en la clave
- Primera respuesta (envenenada)
- Segunda petición, no relacionada, mostrando la misma respuesta envenenada servida de vuelta
- Cabeceras relacionadas con caché en ambas respuestas (Age, X-Cache)
- Para deception: prueba de que el dato privado se pudo obtener sin autenticación
Mitigación
- Incluir en la clave de caché cada input que afecte a la respuesta, o no cachear respuestas que dependan de él
- Limpiar/normalizar las cabeceras forwarded en el borde antes de que lleguen al origen
- No cachear por defecto respuestas autenticadas/personalizadas
- Ajustar las reglas de caché a rutas exactas, no a patrones de sufijo laxos