SecLab

Web cache deception

A01CWE-525CWE-668
01

What it is

Cache deception is the reverse of cache poisoning: instead of poisoning a public page, the attacker tricks the cache into storing the victim's PRIVATE page (an account page, personal data) and then reads it. The mechanism is the cache and the server disagreeing about "is this URL a static resource or a dynamic one".

02

Why you should care

Relevance: CoreExpected: L2

The point to grasp: cache deception exploits a disagreement about the URL suffix. A cache usually decides "cache or not" by extension — .css, .js, .jpg are static, cacheable. But many servers ignore the extra part of the URL and still serve the dynamic page. Combine the two:

  • The attacker tricks the victim into opening https://app.example/account/profile.css.
  • The server ignores /profile.css and serves the victim's /account/profile page (with personal data).
  • The cache sees the .css suffix, thinks it is static, and STORES the response — with the victim's private data.
  • The attacker opens the same .../profile.css URL, the cache serves the stored copy → reads the victim's data.

The difference from cache poisoning: poisoning writes a malicious copy into the public cache; deception reads a private copy out of the cache. Both share the root cause — the cache and server disagree — but in opposite directions.

And like the other cache/smuggling topics: the fix is in the cache configuration, not the code. It is a bug of the cache deciding "what to cache" by one rule (the extension) different from the rule the server uses to decide "what to serve".

03

How the attack works

The mechanism is a URL-suffix disagreement between the cache (deciding by extension) and the server (ignoring the extra part).

Diagram source
sequenceDiagram    autonumber    actor A as Attacker    actor V as Victim (signed in)    participant C as Cache (caches by .css)    participant S as Server (ignores /profile.css)    A->>V: Trick into opening /account/profile.css    V->>C: GET /account/profile.css<br/>Cookie: session=<the victim's>    C->>S: forwards it (not in cache yet)    S-->>C: 200, the VICTIM's profile page<br/>(the server ignores .css)    Note over C: The cache sees the .css suffix → thinks STATIC → STORES the response<br/>(with the victim's private data).    A->>C: GET /account/profile.css (NO cookie)    C-->>A: the stored copy — the VICTIM's data

The key point: the cache stores a login-gated page because it thinks it is static. The attacker needs no victim session to read it — the cache serves the stored copy to anyone asking the same URL, cookie or not.

A conditions table — both must be true to exploit:

ConditionDetail
The server ignores the extra part of the URL/account/profile.css serves as /account/profile
The cache decides by extension.css/.js/.jpg → cache, ignoring the page's Cache-Control
The page has private dataAn account page, personal data — something worth stealing

The variants live in "the server ignores the extra part": not only .css. /account/profile/xyz (a path suffix), /account/profile;.css (a path parameter), /account/profile%00.css (a null byte), /account/profile.css?x=1 — each is a different disagreement between how the server parses the URL and how the cache decides. This is why an extension blocklist is not enough (like the injection topics).

Diagram description: A sequence diagram of cache deception. The attacker tricks a signed-in victim into opening the link /account/profile.css. The victim browser sends that request with the victim session cookie to the cache; the cache does not have it so it forwards to the server. The server ignores the .css suffix and serves the victim profile page with personal data. The cache sees the .css suffix so thinks it is a static resource and stores that response, with the victim private data. The attacker then opens the same profile.css URL without any cookie, and the cache returns the stored copy — the victim data.

04

Concrete example

An account page GET /account/profile behind a login, behind a CDN caching by extension.

HTTP
# The attacker tricks the victim into opening this URL (via email, a chat link). The victim is signed in.GET /account/profile.css HTTP/1.1Host: app.exampleCookie: session=<the victim's session>
HTTP
# The server IGNORES ".css" and serves the victim's dynamic profile page:HTTP/1.1 200 OKContent-Type: text/htmlCache-Control: private, no-store       ← the page SAYS do not cache <h1>Alice Nguyen</h1><p>Email: alice@acme.com · Card: **** 4242</p>
# But the CACHE decides by the .css EXTENSION and IGNORES the page's Cache-Control: private.# It stores the response — with Alice's data — under the key /account/profile.css.
HTTP
# The attacker opens the same URL, with NO Alice cookie:GET /account/profile.css HTTP/1.1Host: app.example(no cookie) HTTP/1.1 200 OK  (from cache)<h1>Alice Nguyen</h1><p>Email: alice@acme.com · Card: **** 4242</p>   ← Alice's data
Shell
# Detection: open a private page with a fake static suffix, then open it again with NO cookie.curl -s https://app.example/account/profile.css -b "session=$MY_SESSION" > /dev/nullcurl -s https://app.example/account/profile.css | grep -q "alice@acme.com" \  && echo "CACHE DECEPTION: private data readable with no cookie"
# After the fix: the cache honours Cache-Control: private, AND the server refuses /profile.css# (does not ignore the extra part), AND the cache stores by real Content-Type, not by extension.
YAMLCache by extension, overriding the app's Cache-Control — two lines, and the whole vulnerability.
# Cấu hình CDN. Cache deception sống Ở ĐÂY, không trong code app.cache:  rules:    # ❌ Cache theo PHẦN MỞ RỘNG URL. Một trang tài khoản mở với đuôi .css được cache    #    NHƯ MỘT tài nguyên tĩnh — kèm dữ liệu riêng của người dùng trong response.    #    Đây là điều kiện 2 của khối 3.    - match: "\\.(css|js|jpg|png|woff2|svg)$"      action: cache      ttl: 86400   # ❌ BỎ QUA Cache-Control của origin. App đặt "private, no-store" cho trang tài  #    khoản, nhưng CDN ghi đè nó và cache theo đuôi ở trên. Nên một app ĐÚNG vẫn bị  #    deception vì cấu hình cache sai.  respect_origin_cache_control: false # Và ở server: router khớp "bắt đầu bằng", nên /account/profile.css khớp route# /account/profile và server phục vụ trang động — điều kiện 1 của khối 3.# routes:#   - path_prefix: /account/profile   # ❌ prefix, nên .css đi kèm cũng khớp
05

What happened in the wild

Omer Gil — "Web Cache Deception Attack" (Black Hat 2017). The talk that defined the class and showed it working on large services (including a well-known PayPal demo at the time): open an account-page URL with a .css suffix, and the cache stores the page with personal data. It is the source of the conditions table in block 3.

Cache-by-extension-by-default CDN incidents (many reports). Many CDNs default to caching anything with a static extension, and that default overrides the app's Cache-Control: private. So a correct app (setting no-store on account pages) is still deceived if the CDN is configured cache-by-extension. It illustrates block 2's point: the fix is in the cache configuration, not the app code.

Extended research on URL variants (Kettle/Gil, 2020). Showed .css is only one way; a path suffix, a path parameter (;), a null byte, and other URL-parsing differences between the cache and the server all open deception. This is why block 6 says the fix must be "the cache and the server AGREE on what this URL is", not "block the .css suffix".

06

How to defend

Layer 1

Cache by the app's `Content-Type` and `Cache-Control`, NOT by the URL extension

mandatory

The root cause is the cache deciding "what to cache" by extension, overriding what the app says. The fix is making the cache RESPECT what the app says.

  • The cache honours the response's Cache-Control. If the app sets private, no-store, the cache must NOT store it — regardless of the URL suffix. This is the most important line, and it is a CDN configuration: turn off "cache by extension" and turn on "honour origin Cache-Control".
  • Cache only by the real Content-Type. A Content-Type: text/html response must NOT be cached as static even if the URL ends in .css. The cache goes by what the server RETURNS, not what the URL SUGGESTS.
  • The app sets Cache-Control: no-store on every session-/user-dependent page — this is the half on the app side, and it must be there even if you trust the cache to honour it. Defence in depth.

The important point: this is a cache configuration decision, and it must be explicit. Many CDNs default to cache-by-extension because it is fast; you have to turn it off explicitly for paths with dynamic content.

YAML · Layer 1Allowlist static prefixes, honour Cache-Control, and the server matches the path exactly.
cache:  # Mặc-định-ĐÓNG: chỉ cache các PREFIX tĩnh đã biết, mọi thứ khác không cache. Đây là  # biện pháp mạnh nhất (lớp 2) — một trang động mới thêm KHÔNG bị cache do sơ ý, giống  # nguyên tắc [Authorize] toàn cục ở topic access-control.  default: no-cache  rules:    - match_prefix: ["/_next/static/", "/assets/"]   # tài nguyên tĩnh có hash tên file      action: cache      ttl: 31536000                                   # tĩnh bất biến → cache lâu là an toàn   # TÔN TRỌNG Cache-Control của origin. Nếu app nói private/no-store, cache KHÔNG lưu —  # bất kể đuôi URL. Đây là dòng đóng deception ở phía cache.  respect_origin_cache_control: true   # Cache theo CONTENT-TYPE thật, không theo đuôi URL. Một response text/html KHÔNG  # được cache như tĩnh dù URL kết thúc .css — cache dựa vào những gì server TRẢ VỀ.  cache_by: content_type  cacheable_content_types: ["text/css", "application/javascript", "image/*", "font/*"] server_routing: |  # Nửa phía server (lớp 1b): router khớp path CHÍNH XÁC, không "bắt đầu bằng".  # /account/profile.css KHÔNG khớp route /account/profile → trả 404, không phục vụ  # trang động. Cache và server ĐỒNG Ý về "URL này là gì".  app.MapGet("/account/profile", ...);   // khớp đúng "/account/profile"  // /account/profile.css, /account/profile/x, /account/profile;.css → 404 app_headers: |  # Và app đặt no-store cho MỌI trang phụ thuộc phiên — nửa phía app. Phòng thủ  # chiều sâu: kể cả khi tin cache tôn trọng nó, app vẫn nói ra.  [ResponseCache(NoStore = true)]   // trên controller của trang tài khoản  // → Cache-Control: no-store, và cache (đã cấu hình respect) sẽ nghe.
Layer 1b

The cache and the server must AGREE on "what this URL is"

mandatory

Deception is a disagreement, so the second fix removes the possibility of disagreement — like the request-smuggling topic.

  • The server does not ignore the extra part of the URL. /account/profile.css must be a 404, not serve /account/profile. A router matching the path EXACTLY (not "starts with") means /profile.css does not match the /profile route. This is the half on the server side.
  • Normalise URLs consistently between the cache and the server. If the server treats /profile;.css, /profile%00.css, /profile/x as /profile but the cache treats them as static, there is room for deception. Both must parse URLs identically — this is why blocklisting the .css suffix is not enough (the variants table in block 3).

The general principle: the cache and the server are two different URL parsers, and every place they disagree is a vulnerability — the same lesson as request-smuggling (the cache/backend disagree on the request boundary) and web-cache-poisoning (they disagree on the cache key).

Layer 2

Allowlist the cacheable paths, do not blocklist

The strongest configuration control is to invert the default: the cache caches nothing unless explicitly listed.

  • Cache only known static paths/prefixes (/static/, /assets/, /_next/static/), and everything else is uncached by default. This is closed-by-default, like the global [Authorize] principle in the access-control topic: a newly added dynamic page is NOT cached by accident.
  • The reverse — blocklisting dynamic paths — is open-by-default, and a new account page forgotten from the blocklist gets cached. The same reason blocklists lose in the injection topics.

With modern architectures (Next.js, hashed static asset filenames), an allowlist is very natural: static resources live under a known prefix, so "cache only that prefix" is both safe and simple. SecLab does exactly this — only _next/static and /assets are cached.

Layer 3

Detection: private data in a cached response

Deception is hard to detect because the victim sees nothing unusual — the attacker reads the data in a separate request. Two controls:

  • A synthetic canary: a job signs in with a test account, opens private pages with fake suffixes (.css, .js, a path suffix), then reopens them with NO cookie and checks whether the response contains the test account's data. If it does → the cache is storing private pages (the block 4 technique, automated).
  • Log requests to dynamic paths with a static suffix (/account/*.css, /api/*.js) — legitimate clients do not produce them, so they are the signature of a deception probe.
  • Check whether a cached response carries a Set-Cookie or Cache-Control: private — a personalised response reaching the cache is a red flag; a CDN-layer scanner can catch it.

Layer 3 because the layer-1 configuration already blocks; monitoring tells you if a misconfiguration slips through.

07

Verifying the fix

1. The canary technique — the core check, two steps (like cache poisoning but reading, not writing):

Shell
B=https://staging.example.com# Step 1: sign in with a test account, open a private page with a fake static suffix.curl -s "$B/account/profile.css" -b "session=$TEST_SESSION" | grep -q "$TEST_EMAIL" \  && echo "the server ignores .css and serves the private page"# Step 2: open the SAME URL with no cookie — the test data must NOT appear.sleep 1curl -s "$B/account/profile.css" | grep -q "$TEST_EMAIL" \  && { echo "CACHE DECEPTION: private data readable without signing in"; exit 1; }exit 0

2. Try many URL variants — the block 3 table: not only .css:

Shell
for u in /account/profile.css /account/profile.js /account/profile/x \         "/account/profile;.css" "/account/profile%00.css"; do  curl -s "$B$u" -b "session=$TEST_SESSION" > /dev/null  curl -s "$B$u" | grep -q "$TEST_EMAIL" && echo "DECEPTION via $u"done

3. Check the server REFUSES paths with an extra suffix (the server half in layer 1b):

Shell
# /account/profile.css must be a 404, not serve /account/profile.curl -s -o /dev/null -w '%{http_code}\n' "$B/account/profile.css" -b "session=$TEST_SESSION"# Expect 404. If 200 → the server ignores the extra part, condition 1 of block 3 holds.

4. Check the cache honours Cache-Control: private — this is a CDN config check:

Shell
# The private page response must carry Cache-Control private/no-store...curl -sI "$B/account/profile" -b "session=$TEST_SESSION" | grep -i cache-control# ...AND the CDN must honour it (no cache-by-extension). Check the CDN config directly.

5. Check the cache allowlist (layer 2) — confirm the CDN config caches only known static prefixes (/_next/static, /assets), everything else uncached by default. This is a config check, not a code test.

HTTPThe two-step canary: open the private page with a fake suffix using a cookie, then re-read with none.
### Web cache deception — kỹ thuật canary (httpyac/REST Client hoặc script)###### Ngược với cache poisoning: ở đây ta kiểm cache có LƯU một trang RIÊNG TƯ và phục### vụ nó cho người KHÔNG có cookie hay không. Hai bước, và bước 2 mới chứng minh lỗ hổng. ### Bước 1 · đăng nhập (tài khoản TEST) và mở trang riêng với đuôi tĩnh giảGET https://staging.example.com/account/profile.css HTTP/1.1Cookie: session={{TEST_SESSION}} ### Kỳ vọng SAU KHI VÁ: 404 (server không phục vụ /profile.css như /profile).### Nếu 200 và chứa email của tài khoản test → server bỏ qua .css (điều kiện 1, khối 3). ### Bước 2 · mở CÙNG URL, KHÔNG cookie — đây là request của "kẻ tấn công"GET https://staging.example.com/account/profile.css HTTP/1.1 ### Kỳ vọng SAU KHI VÁ: KHÔNG chứa email của tài khoản test (cache không lưu trang riêng).### Nếu CHỨA → cache đã lưu trang riêng của tài khoản test và phục vụ nó cho một request### không đăng nhập. Đây là cache deception đã xảy ra — dữ liệu riêng đọc được không cần cookie. ### Bước 3 · thử các biến thể URL (bảng khối 3: không chỉ .css)GET https://staging.example.com/account/profile/x HTTP/1.1Cookie: session={{TEST_SESSION}} GET https://staging.example.com/account/profile;.css HTTP/1.1Cookie: session={{TEST_SESSION}} ### Bước 4 · kiểm response trang riêng có Cache-Control đúngGET https://staging.example.com/account/profile HTTP/1.1Cookie: session={{TEST_SESSION}} ### Kỳ vọng: header Cache-Control chứa "no-store" hoặc "private". Nhưng lưu ý: header### đúng ở app KHÔNG đủ nếu CDN cache-by-extension ghi đè nó — bản vá thật ở cấu hình### cache (xem tab yaml), và các request này là cách CHỨNG MINH nó đúng.
08

Common mistakes

The "fix"Why it is wrong
Set Cache-Control: private on the app and call it doneCache-by-extension OVERRIDES it on many CDNs. The cache must be configured to honour Cache-Control
Blocklist .css/.js suffixes on dynamic pagesThere is still a path suffix, ;, a null byte, and other URL-parsing routes (the block 3 table). Allowlist static prefixes
Cache by the URL extensionThis is the cause. Cache by the real Content-Type and the app's Cache-Control
Serve /profile.css as /profileCondition 1 of block 3. The router must match the path EXACTLY → /profile.css is a 404
Fix only in the appThe main fix is in the CACHE CONFIGURATION. The app setting no-store is the app half, the cache honouring it is the cache half
Treating it as cache poisoningOpposite direction: poisoning WRITES a malicious copy into the public cache; deception READS a private copy out of the cache

The model mistake, and it is the central one: thinking Cache-Control: private on the app is enough. Many CDNs cache by extension and override that header — so the fix must be in the cache configuration: honour Cache-Control, cache by Content-Type, and allowlist static prefixes.

The location mistake: looking for the fix in the code. Like request-smuggling and web-cache-poisoning, this is a bug of DISAGREEMENT between two infrastructure components (the cache and the server) about "what this URL is", so the fix lives where that disagreement is resolved — the cache configuration and server routing, not a handler.

09

References

Tier 1A01:2021 – Broken Access Control · OWASP · Top 10 · 2021
Tier 2Web cache deception · PortSwigger · Web Security Academy
Tier 2HTTP Caching · MDN · HTTP guide · 2025
Tier 3Web Cache Deception Attack · Omer Gil (Black Hat USA 2017)
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…