SecLab

Web cache poisoning

A05CWE-524CWE-444
01

What it is

Cache poisoning is when an attacker makes a cache (a CDN, a reverse proxy) store a malicious response, which the cache then serves to every subsequent user. The root mechanism is that the cache key omits an input the response ACTUALLY depends on — so two requests "identical to the cache" produce two different responses, and the malicious one gets stored.

02

Why you should care

Relevance: CoreExpected: L2

What makes cache poisoning dangerous: it turns a reflected bug that only harms the attacker into one that harms EVERYONE. A reflected header (like X-Forwarded-Host in the host-header topic) affects only the attacker's own request; but if that response is cached, it serves every subsequent user — so a "self-only" XSS becomes a stored XSS at CDN scale.

Two concepts to grasp, and they are the whole topic:

  • The cache key is the set of inputs the cache uses to decide "are these two requests the same". The default is usually just method + path + a few headers. Any input OUTSIDE the cache key that the response depends on is a vulnerability: the attacker changes it, the response changes, but the cache treats the two requests as one.
  • Unkeyed input: a header like X-Forwarded-Host, X-Forwarded-Scheme, User-Agent, or a query parameter the cache ignores but the application uses. This is the attack surface.

And one point about location: the fix is almost always in the cache configuration, not the application code — like the request-smuggling topic. It is a bug of disagreement between the cache and the app about "what this request is".

03

How the attack works

The mechanism is the gap between the cache key (what the cache uses to compare requests) and what the response actually depends on.

Diagram source
sequenceDiagram    autonumber    actor A as Attacker    participant C as Cache (key = method + path)    participant S as Server    A->>C: GET / <br/>X-Forwarded-Host: evil.example    Note over C: The cache key OMITS X-Forwarded-Host → treats it as a normal GET /.    C->>S: forwards it    S-->>C: 200, HTML with <script src=//evil.example/x.js><br/>(the server used X-Forwarded-Host)    Note over C: The cache STORES the malicious response under key "GET /".    actor V as Victim    V->>C: GET / (normal)    C-->>V: the POISONED response from cache    Note over V: The victim gets the attacker's script. XSS at CDN scale.

The key point: the malicious response is STORED under a normal cache key, so a victim sending a completely normal request gets back the poisoned response. This is why it harms everyone, not only the attacker.

A table of forms, by cause:

FormUnkeyed inputResult
Reflected headerX-Forwarded-Host, X-Forwarded-SchemeXSS/redirect at CDN scale
Cache key normalisationcache and app normalise the path differentlyWrong content served
Cache key injectiona query parameter the cache drops but the app usesPoisoning keyed on the parameter
Fat GETthe cache key omits a GET bodyPoisoning via the body
DoS via cachea malformed header makes the server error, the error is cachedAn error page served to everyone

The last row is worth knowing: cache poisoning is not only XSS — poisoning an error page into the cache is a DoS. One request makes the server return 400, that 400 is cached, and everyone after gets 400 for a normal page.

Diagram description: A sequence diagram of cache poisoning. The attacker sends GET / with an X-Forwarded-Host header of evil.example. The cache has a cache key of only method and path so it ignores that header, treats it as a normal GET / and forwards it to the server. The server uses X-Forwarded-Host to build the HTML so it returns a page with a script tag pointing at evil.example, and the cache stores that malicious response under the key GET /. When a victim sends a normal GET /, the cache returns the poisoned response, so the victim gets the attacker's script — an XSS at CDN scale.

04

Concrete example

A homepage cached by a CDN, where the server uses X-Forwarded-Host to build the canonical URL.

HTTP
# The poisoning request. X-Forwarded-Host is NOT in the cache key, but the server uses it.GET / HTTP/1.1Host: app.exampleX-Forwarded-Host: evil.example
HTTP
HTTP/1.1 200 OKCache-Control: public, max-age=300 <script src="//evil.example/analytics.js"></script>   ← the server reflected X-Forwarded-Host

The cache stores this under the key GET / (it ignores X-Forwarded-Host). For the next 300 seconds, every user sending a normal GET / gets the page with the attacker's script.

Shell
# Detection: send an unkeyed header with a marker and see whether it gets cached.curl -s https://app.example/ -H 'X-Forwarded-Host: canary123.evil' | grep -q canary123 \  && echo "X-Forwarded-Host is reflected"# Then send a NORMAL request and see if the marker is still there → if so, it was cached.curl -s https://app.example/ | grep -q canary123 && echo "CACHE POISONED"
HTTP
# DoS variant: a malformed header makes the server return 400, and the 400 is cached.GET / HTTP/1.1X-Forwarded-Host: evil\r\n(a malformed header makes the server error)# The 400 is cached under the key GET / → everyone after gets 400 for the homepage.
# After the fix: X-Forwarded-Host is not reflected (use the configured domain, host-header topic),# or it is added to the cache key, and error pages are not cached.
YAMLA cache key of only method + path, and the cache stores error responses too.
# Cấu hình CDN/reverse proxy. Đây là nơi cache poisoning sống — không phải code app.cache:  # ❌ Cache key chỉ gồm method + path. Nếu app dùng X-Forwarded-Host để dựng response  #    (topic host-header), thì hai request khác X-Forwarded-Host cho hai response khác  #    nhau — nhưng cache coi chúng là MỘT, nên bản độc hại phục vụ cho mọi người.  key:    - method    - path    # thiếu mọi header mà app thực sự dùng để dựng response   # ❌ Cache cả 4xx/5xx. Một request với header dị dạng làm server trả 400, và 400 đó  #    bị cache dưới key GET / → mọi người sau nhận 400 cho trang chủ (CPDoS).  cacheable_status: [200, 301, 400, 404, 500]   # ❌ TTL dài. Một bản đã đầu độc sống 24 giờ.  default_ttl: 86400   # ❌ Ghi đè Cache-Control của app: cache trang kể cả khi app nói no-store.  ignore_origin_cache_control: true
05

What happened in the wild

James Kettle's research — "Practical Web Cache Poisoning" (2018) and "Web Cache Entanglement" (2020). Defined the class and showed it existing on many large sites via unkeyed headers like X-Forwarded-Host, X-Forwarded-Scheme. It is the source of the forms table in block 3, and of the canary-marker detection technique in block 4.

Real CDN incidents (many reports). Many bug bounty reports describe the same pattern: a reflected header plus a CDN caching by default, letting one request poison the homepage or a shared JS resource for every user. Memorable because the impact multiplies by the cache's scale: one request, millions of victims.

Cache-poisoning DoS (CPDoS, 2019 research). A family of attacks using malformed headers to make the server return an error and then make the cache store that error — turning the cache into a denial-of-service tool. It illustrates the last row of the block 3 table: cache poisoning is not only XSS, it is any bad response you can force into the cache.

06

How to defend

Layer 1

Do not reflect untrusted input into a cacheable response

mandatory

This is the core fix, and it intersects the host-header and xss topics: if the response reflects no attacker input, there is nothing to poison.

  • Do not use Host/X-Forwarded-Host/X-Forwarded-Scheme to build URLs in the response — use the configured domain (host-header layer 1). This is the most common source of cache poisoning.
  • Do not reflect headers/parameters into cacheable HTML. A <link rel=canonical> or <script src> built from input is XSS at CDN scale when cached.
  • If you must reflect it: encode for the context (the xss topic) AND ensure that input is in the cache key (layer 1b) — otherwise the malicious copy serves everyone.

The principle: a cacheable response is a shared response, so it must contain nothing that depends on a specific request. If the content depends on input, that response should not be cacheable (layer 1b).

YAML · Layer 1A cache key with every input the response depends on, 200s only, short TTL, honouring the app.
cache:  # Cache key gồm MỌI input mà app dùng để dựng response. Nguyên tắc: cache key phải  # KHỚP với thứ response phụ thuộc vào — nếu không, hai request "giống theo cache"  # cho hai response khác nhau, và đó là chỗ đầu độc.  #  # Tốt hơn nữa (lớp 1): app KHÔNG dùng các header này để dựng response, và lúc đó  # cache key chỉ cần method + path là an toàn. Cấu hình dưới là cho trường hợp buộc  # phải phụ thuộc một header (ví dụ đa ngôn ngữ theo Accept-Language).  key:    - method    - path    - normalized_query          # chuẩn hoá GIỐNG app (chống cache-key injection)    - header:Accept-Language    # response phụ thuộc nó → phải trong key   # Chỉ cache 200 (và 301/404 tĩnh có chủ ý). KHÔNG cache 4xx/5xx động — một 400 do  # header dị dạng bị cache là CPDoS: một request, DoS cho mọi người.  cacheable_status: [200, 301]   # TTL ngắn cho nội dung động: nếu một bản đầu độc lọt qua, nó không sống lâu.  default_ttl: 60   # TÔN TRỌNG Cache-Control của app. Nếu app đặt no-store cho một trang phụ thuộc  # phiên, cache phải nghe — ghi đè điều đó là một lỗ hổng cấu hình.  ignore_origin_cache_control: false   # Chuẩn hoá path GIỐNG app: nếu cache coi /PROFILE và /profile là hai key nhưng app  # coi là một, có chỗ cho cache-key injection.  normalize_path:    lowercase: true    strip_trailing_slash: true # Và ở app (lớp 1, giao với topic host-header): KHÔNG phản chiếu X-Forwarded-Host vào# response được cache. Dùng domain đã cấu hình cho canonical URL, base href, absolute URL.# Trang phụ thuộc người dùng: Cache-Control: private, no-store.
Layer 1b

The cache key must include EVERY input the response depends on

mandatory

Cache poisoning is a disagreement: the cache thinks two requests are the same, the app returns two different responses. The fix is making the cache key match what the response actually depends on.

  • Put in the cache key every header/parameter the app uses to build the response. If the response depends on X-Forwarded-Host, then X-Forwarded-Host must be in the cache key — or the app must not use it (layer 1).
  • Vary is the standard tool for this, but it must be configured at the CDN too: Vary: X-Forwarded-Host tells the cache the response differs by that header. Note: some CDNs ignore Vary for non-standard headers, so you must set the cache key explicitly at the CDN layer.
  • Normalise the path CONSISTENTLY between the cache and the app. If the cache treats /PROFILE and /profile as two keys but the app as one (or vice versa), there is room for cache-key injection. Configure both to normalise identically.

And limit what is cached: cache only genuinely shared static resources (JS, CSS, images). Session- or user-dependent pages set Cache-Control: private, no-store.

Layer 2

Do not cache error responses, and cap the TTL

This is the fix for the CPDoS variant in block 3: a cached error page is a DoS.

  • Do not cache 4xx/5xx (except some deliberately static 404s). One request making the server return 400 due to a malformed header, then that 400 serving everyone, is a DoS caused by one request. Configure the cache to store only 200s (and perhaps static 301/404s).
  • A short TTL for dynamic content, so a poisoned copy (if one slips through) does not live long. A page cached for 24 hours and poisoned is 24 hours of everyone getting the poison; cached for 60 seconds, the window is far smaller.
  • Honour the app's response headers before caching: if the app sets Cache-Control: no-store on a page, the CDN must respect it — a CDN configuration overriding that is a configuration vulnerability.

This is layer 2 because it reduces the damage rather than closing the cause; the cause is reflection (layer 1) and the cache key (layer 1b).

Layer 3

Detection: canary markers and cache-content monitoring

Cache poisoning is hard to detect because the victim gets a normal-looking response from the cache. Two controls:

  • A periodic synthetic canary: a job sends normal requests to the cached pages and checks the response contains no foreign domain, foreign script, or sign of tampering. If the cached copy is poisoned, the canary catches it — this is the block 4 technique, automated.
  • Log requests with anomalous unkeyed headers (X-Forwarded-Host differing from your domain, X-Forwarded-* headers from outside the proxy range). This is the signature of a cache-poisoning probe, the same detection as the host-header topic.
  • Alert when the error rate from the cache rises — it may be CPDoS: a cached error page serving everyone.

Layer 3 because it does not block, but for a vulnerability that silently serves a malicious response from the cache, monitoring the cache content is the only way to know it happened.

07

Verifying the fix

1. The canary technique — the core check, and it is two steps:

Shell
B=https://staging.example.comM="canary$RANDOM"# Step 1: send an unkeyed header with a marker, see whether it is reflected into the response.curl -s "$B/" -H "X-Forwarded-Host: $M.evil" | grep -q "$M" \  && echo "reflected — dangerous if cached" || { echo "not reflected, ok"; exit 0; }# Step 2: send a NORMAL request (no header) and see if the marker is still there.sleep 1curl -s "$B/" | grep -q "$M" && { echo "CACHE POISONED"; exit 1; }exit 0

2. Try many unkeyed headers — the block 3 table is a starting list:

Shell
for h in X-Forwarded-Host X-Forwarded-Scheme X-Host X-Forwarded-Server X-Original-URL; do  M="c$RANDOM"  curl -s "$B/" -H "$h: $M" > /dev/null  curl -s "$B/" | grep -q "$M" && echo "POISONED via $h"done

3. Grep for the app reflecting a header into the response — the same check as the host-header topic:

Shell
grep -rnE 'X-Forwarded-Host|X-Forwarded-Scheme|Request\.Host' --include='*.cs' src/ \  | grep -viE 'ForwardedHeaders|HostFiltering' \  && echo "WARNING: possibly reflecting a header into the response"

4. Check error responses are NOT cached (CPDoS):

Shell
# Force a 400 and check it has no public Cache-Control.curl -sI "$B/" -H 'X-Forwarded-Host: a\r\nb' | grep -iE 'cache-control|^HTTP'# 4xx/5xx must be no-store/private, not public.

5. Check the cache-key configuration at the CDN — this is where the real fix lives. Confirm every header the app uses to build the response is in the CDN cache key, or the app does not use them. This is a config check, not a code test.

HTTPThe two-step canary technique: reflect, then check whether the normal copy is poisoned.
### Web cache poisoning — kỹ thuật canary (chạy bằng httpyac/REST Client hoặc script)###### Nguyên tắc: cache poisoning khó phát hiện vì nạn nhân nhận response BÌNH THƯỜNG từ### cache. Canary là hai bước — bước 1 chứng minh header phản chiếu, bước 2 chứng minh### nó bị CACHE (một request bình thường vẫn thấy marker). ### Bước 1 · gửi header unkeyed với marker, xác nhận nó phản chiếu vào responseGET https://staging.example.com/ HTTP/1.1X-Forwarded-Host: canary-poison-marker.evil ### Kỳ vọng: response CHỨA "canary-poison-marker" (app phản chiếu header).### Nếu KHÔNG chứa → app không dùng header này, an toàn với dạng này. ### Bước 2 · gửi request BÌNH THƯỜNG (không header) — marker KHÔNG được cònGET https://staging.example.com/ HTTP/1.1 ### Kỳ vọng SAU KHI VÁ: response KHÔNG chứa "canary-poison-marker".### Nếu CÒN chứa → cache đã lưu bản độc hại từ bước 1, và nó đang phục vụ cho mọi### người dùng. Đây là cache poisoning đã xảy ra. ### Bước 3 · thử nhiều header unkeyed (bảng ở khối 3 là danh sách bắt đầu)GET https://staging.example.com/ HTTP/1.1X-Forwarded-Scheme: nothttps GET https://staging.example.com/ HTTP/1.1X-Original-URL: /admin ### Bước 4 · CPDoS — ép một lỗi và xác nhận nó KHÔNG bị cacheGET https://staging.example.com/ HTTP/1.1X-Forwarded-Host: %00malformed ### Kỳ vọng: nếu request này gây 400, response 400 phải có Cache-Control: no-store —### KHÔNG public. Một 400 bị cache dưới key "GET /" là DoS cho mọi người sau. ### Ghi chú: bản vá thật ở CẤU HÌNH CACHE (xem tab yaml), không ở đây. Các request này### là cách CHỨNG MINH cấu hình đúng, không phải cách sửa.
08

Common mistakes

The "fix"Why it is wrong
Escape the output but still reflect into a cached pageEscaping closes reflected XSS, but the cached copy still serves the attacker's input to everyone
Vary: X-Forwarded-Host without configuring the CDNMany CDNs ignore Vary for non-standard headers. You must set the cache key explicitly at the CDN
Treating cache poisoning as ordinary XSSIt multiplies by the cache's scale: one request, every user. And it has a DoS variant (CPDoS)
Caching 4xx/5xxA 400 from a malformed header, cached, is a DoS for everyone. Cache only 200s
A cache key of only method + pathEvery input OUTSIDE the key that the response depends on is surface. Put them in the key or do not use them
Using X-Forwarded-Host to build URLsThe same bug as the host-header topic, and the cache turns it into harm-everyone. Use the configured domain
The cache and app normalising the path differentlyCache-key injection. Configure both to normalise identically

The model mistake, and it is the central one: thinking the default cache key (method + path) is enough. Cache poisoning is a DISAGREEMENT between "what the cache thinks this request is" and "what the app builds the response from". Every input the app uses but the cache ignores is a vulnerability.

The location mistake: looking for the fix in application code. Like the request-smuggling topic, the fix almost always lives in the cache/CDN configuration — what the cache key includes, what is cached, the TTL. Code is only involved in whether it reflects input at all (layer 1).

09

References

Tier 1RFC 9111 — HTTP Caching · IETF · RFC · RFC 9111
Tier 1A05:2021 – Security Misconfiguration · OWASP · Top 10 · 2021
Tier 2Web cache poisoning · PortSwigger · Web Security Academy
Tier 2Practical Web Cache Poisoning · James Kettle, PortSwigger
Tier 3CPDoS: Cache Poisoned Denial of Service · Nguyen, Schneider et al.
Part of path
Secure Backend DeveloperView path

Comments

Join the discussion
Sign up to comment

Commenting needs an account with at least one completed lesson. That condition is what keeps this thread worth reading: every point belongs to someone who can be asked back, and reputation accrues over time.

Sign upSign in

You can still read every comment below without an account. Signing in brings you back to this exact spot, not to the top of the page.

Loading comments…