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".
Why you should care
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.cssand serves the victim's/account/profilepage (with personal data). - The cache sees the
.csssuffix, thinks it is static, and STORES the response — with the victim's private data. - The attacker opens the same
.../profile.cssURL, 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".
How the attack works
The mechanism is a URL-suffix disagreement between the cache (deciding by extension) and the server (ignoring the extra part).
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 dataThe 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:
| Condition | Detail |
|---|---|
| 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 data | An 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.
Concrete example
An account page GET /account/profile behind a login, behind a CDN caching by extension.
# 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># 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.# 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# 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.# 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ớpWhat 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".
How to defend
Cache by the app's `Content-Type` and `Cache-Control`, NOT by the URL extension
mandatoryThe 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 setsprivate, 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. AContent-Type: text/htmlresponse 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-storeon 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.
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.The cache and the server must AGREE on "what this URL is"
mandatoryDeception 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.cssmust be a 404, not serve/account/profile. A router matching the path EXACTLY (not "starts with") means/profile.cssdoes not match the/profileroute. 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/xas/profilebut the cache treats them as static, there is room for deception. Both must parse URLs identically — this is why blocklisting the.csssuffix 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).
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.
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-CookieorCache-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.
Verifying the fix
1. The canary technique — the core check, two steps (like cache poisoning but reading, not writing):
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 02. Try many URL variants — the block 3 table: not only .css:
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"done3. Check the server REFUSES paths with an extra suffix (the server half in layer 1b):
# /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:
# 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.
### 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.Common mistakes
| The "fix" | Why it is wrong |
|---|---|
Set Cache-Control: private on the app and call it done | Cache-by-extension OVERRIDES it on many CDNs. The cache must be configured to honour Cache-Control |
Blocklist .css/.js suffixes on dynamic pages | There is still a path suffix, ;, a null byte, and other URL-parsing routes (the block 3 table). Allowlist static prefixes |
| Cache by the URL extension | This is the cause. Cache by the real Content-Type and the app's Cache-Control |
Serve /profile.css as /profile | Condition 1 of block 3. The router must match the path EXACTLY → /profile.css is a 404 |
| Fix only in the app | The 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 poisoning | Opposite 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.
Comments
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.
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…