Skip to content

Web cache deception: how path confusion tricks a CDN into caching secrets

· 21 min read
Copyright: MIT
The words CACHE DECEPTION as a large monospace wordmark with one orange .css suffix and an arrow into a cache box

You log into your bank. The page that loads shows your balance, the last four digits of a card, a transaction list. It is built fresh for you, on each request, and the server marks it with headers that say do not cache this, ever. Now suppose someone sends you a link to the same page with five characters bolted onto the end, something like /account/home/x.css. You click it. The page that loads is still yours: balance, card, transactions, all of it. But the CDN sitting in front of the bank just looked at that .css on the end, decided it was a stylesheet, and filed a copy of your personalized page in its shared cache. A minute later the attacker requests the exact same URL and the CDN hands them the stored copy. Your balance. Your card. Your transactions.

That is web cache deception. No injection, no stolen password, no malware. The whole attack lives in a disagreement: the origin server and the cache in front of it read the same URL and reach different conclusions about what it is. The server sees a dynamic page with a weird suffix and serves your account. The cache sees a static asset and stores the result for anyone who asks. This post follows that disagreement from the day it was disclosed in 2017 to where it stands now, after two academic measurement studies and a 2024 round of research that turned a single trick into a whole family of them.

A short map of what follows. First the original disclosure and the exact mechanism Omer Gil demonstrated against PayPal. Then why a CDN caches by file extension at all, which is the design decision the attack abuses. Then the formal account of path confusion from the USENIX studies, the delimiter and normalization variants the 2024 research added, and how deception differs from its sibling, cache poisoning. Finally the defenses, in order of how much they actually help. The cache-key mechanics that underpin all of this are covered separately in the anatomy of a CDN cache key; here the subject is the parser gap, not the key.

The 2017 disclosure

In February 2017 a security researcher named Omer Gil published a write-up of an attack he called the web cache deception attack, with a working demonstration against PayPal. The shape of it was almost insultingly simple. PayPal’s account pages lived at paths like /myaccount/home. Gil requested them with a fake static filename appended:

https://www.paypal.com/myaccount/home/attack.css
https://www.paypal.com/myaccount/settings/notifications/attack.css

PayPal’s server did not have a file called attack.css under those paths. What it had was a handler for /myaccount/home that, faced with the extra path segment, ignored the segment and served the account page anyway. The response carried the usual no-cache headers, because this was private content built per user. But the cache in front of PayPal was not reading those headers. It was reading the file extension. It saw .css, concluded the response was a stylesheet, and cached it. Gil reported that the cached copy persisted for roughly five hours, and that repeated access could extend that window. Inside the cached page: account balance, the last digits of a credit card, and depending on the page, addresses, email, and transaction history.

Two conditions had to both hold for this to work, and they did. The cache had to be configured to store responses based on extension while disregarding the origin’s caching headers. And the origin had to treat the appended path as something to ignore rather than something to reject, returning the parent page instead of a 404. Neither condition is exotic. The first is a common performance default, because forcing the cache to honor every header on every static asset throws away hit rate. The second is how a great many web frameworks route by default, treating trailing path segments as parameters rather than as a demand for a file that does not exist.

PayPal fixed the issue and it was disclosed publicly. The interesting part is not that one payment provider had a misconfiguration. It is that the misconfiguration was the natural, recommended-sounding state of two independently reasonable systems, placed in front of each other.

Why a CDN caches by extension in the first place

To see why this keeps happening you have to look at how a CDN decides whether to store a response, because the extension heuristic at the center of the attack is not a bug. It is a deliberate optimization, and the vendor docs are open about it.

Take Cloudflare’s account of its own cache, published in April 2017 a couple of months after Gil’s disclosure. The cache runs the decision in two phases. An eligibility phase looks at the request and asks whether this is the kind of thing worth caching at all. A disqualification phase looks at the response and asks whether anything about it says do not store. The eligibility phase is where the file extension comes in. Cloudflare keeps a list of extensions it treats as always cacheable, and the published list is concrete: class css jar js jpg jpeg gif ico png bmp pict csv doc docx xls xlsx ps pdf pls ppt pptx tif tiff ttf otf webp woff woff2 svg svgz eot eps ejs swf torrent midi mid. A response to a URL ending in one of those is a candidate for caching on extension alone.

The reasoning is sound on its own terms. Static assets are the bulk of most sites by request count and they rarely change. Stylesheets, fonts, images, scripts. If the cache had to round-trip to the origin and parse caching headers for every one of them, the edge would be doing a lot of work to confirm what the extension already told it. So the extension becomes a fast path. The cost of that fast path is the assumption baked into it: that a URL ending in .css actually returns a stylesheet. That assumption is true for honestly named files and false for /myaccount/home/attack.css, and the cache has no independent way to tell the two apart unless it stops to check.

One URL, two readings Victim clicks /account/x.css CDN cache sees .css → store Origin serves /account returns private page + Cache-Control: no-store cache ignores headers, stores by extension Attacker requests /account/x.css cache HIT serves victim's page *The origin marks the page no-store, but the cache never consults that header on the static fast path. It stores the victim's private response keyed to a URL the attacker also controls, and the second request is a hit.*

That two-phase design also explains the standard mitigation that vendors reach for. If the cache, in its disqualification phase, actually checked whether the returned Content-Type matched the extension in the URL, the deception would fall apart. A .css URL that comes back as text/html is not a stylesheet, whatever the path says. That check is exactly what Cloudflare later shipped as Cache Deception Armor, and we will get to its limits.

Path confusion, defined

Gil’s write-up named the attack. The academic account that came two years later named the underlying class and made it precise. In the 2020 USENIX Security paper “Cached and Confused: Web Cache Deception in the Wild,” the authors define web cache deception as an instance of a broader bug class they call path confusion: a condition where a web cache and an origin server disagree on what URL a request is actually for. The cache thinks the request targets one resource; the origin thinks it targets another. When the resource the cache picks is cacheable and the resource the origin picks is private, you have deception.

The 2017 example is the simplest member of the class. The cache reads /account/home/x.css as a request for x.css and stores it; the origin reads it as a request for /account/home and serves the account. But once you state the bug as a parser disagreement rather than as one specific trick, the question becomes how many ways two parsers can disagree about a URL. That is the question the follow-up work answered, and the answer is: a lot of ways.

The taxonomy that came out of this work sorts the variants by which part of the URL grammar the two systems read differently. The first axis is the path-mapping discrepancy, which is the classic case. A framework using REST-style routing maps /user/123/profile/wcd.css to the handler for /user/123/profile and ignores the trailing segment, while a cache using traditional file-path mapping reads it as a file wcd.css in a directory /profile. Same string, two readings.

The second axis is the delimiter discrepancy. Different servers treat different characters as the thing that ends the meaningful part of a path. Java’s Spring framework treats a semicolon as the start of matrix parameters, so /profile;foo.css routes to /profile and the ;foo.css is metadata. A cache that does not know about matrix parameters sees a path that plainly ends in .css and caches it. Ruby on Rails uses the dot as a format delimiter, so /profile.css can route to the /profile action with a requested format; if Rails does not recognize the format it falls back to HTML and returns the profile, which the cache stores as a stylesheet. The delimiter that ends the path for the origin is invisible to the cache, and the suffix after it is exactly the static-looking bait.

Same string, divergent parse cache reads origin reads /profile/wcd.css wcd.css (static) /profile path mapping /profile;x.css ...;x.css (static) /profile delimiter: Spring matrix param /profile%23x.css literal %23 → .css decode → # → /profile delimiter decoding /assets/..%2fprofile /assets/.. (cacheable dir) resolve → /profile normalization *Four ways the same URL splits. In each row the cache treats the tail as a static resource and stores it; the origin reads a delimiter or resolves a dot-segment and returns the dynamic page. The accent marks what the cache latches onto.*

The encoding and normalization variants

The third axis is where it gets subtle, and it is the part the 2024 research pushed hardest. Reserved characters in a URL can be percent-encoded, and cache and origin do not agree on when to decode them or whether to act on the decoded result. Encode a delimiter and you can make one side see it and the other miss it.

Consider /profile%23wcd.css. The %23 is an encoded #. An origin that decodes the path before routing turns this into /profile#wcd.css, treats the # as a fragment delimiter, and serves /profile. A cache that keys and matches on the still-encoded path sees a literal string that ends in .css and stores it. The fragment that truncates the path for the origin is just inert characters to the cache. The same logic runs with %3f for ?: a cache can match its caching rules against the encoded form, decide .css makes it cacheable, then decode %3f to ? before forwarding to the origin, which now sees /myaccount?wcd.css, reads the ? as the start of a query string, and serves /myaccount. One side acts on the encoded bytes, the other on the decoded ones, and the gap between those two moments is the vulnerability.

The fourth axis is normalization of dot-segments, the ../ sequences that mean “go up a directory.” RFC path resolution says a client and server should collapse these before use, but caches and origins do it at different times and to different degrees, especially when the dots are themselves encoded as %2e and the slashes as %2f. The 2024 research mapped this across providers and found a clean split. CloudFront, Azure, and Imperva normalize the path before evaluating their cache rules. Cloudflare, Google Cloud, and Fastly evaluate rules against the unnormalized path. That difference is directly exploitable. Against a cache that does not normalize first, a request like /assets/..%2fprofile matches the cacheable /assets directory rule, because the cache sees the literal /assets/... prefix; the origin decodes and resolves the dot-segment up to /profile and serves the dynamic page. Reverse the layering, with a cache that normalizes and an origin that uses a delimiter to stop early, and you flip the construction: /profile;%2f%2e%2e%2fstatic normalizes to /static for the cache while the semicolon truncates it to /profile at a Spring origin.

A blunt observation from that work is worth keeping in mind, because it explains why this is so hard to stamp out. The researchers noted that Apache and Nginx resolve URLs differently enough that, in their words, it is impossible to put the same cache proxy in front of both without creating some path-confusion discrepancy. The parsers were written by different people, at different times, to satisfy different specs, and there is no central authority that forces a cache and an origin to read a URL the same way. The disagreement is structural. The 2024 study also catalogued the static-directory and static-file rules that give the attack its targets: directories like /static, /assets, /wp-content, /media, /templates, /public, and /shared are commonly cached wholesale, and filenames like /robots.txt, /favicon.ico, and /index.html trigger caching even with no extension at all. Every one of those is a place to aim a path-confusion payload.

Walking one attack end to end

It helps to step through a single exploitation slowly, because the timing is what makes deception different from an ordinary information leak. Nothing here is a working exploit; it is the mechanism, stated plainly.

The attacker first needs a URL that the origin maps to a private page but the cache reads as a static asset. They find one by probing without authentication, which is what made the 2022 detection method scale: send a request to a known dynamic endpoint with a static-looking suffix and a delimiter, and watch the response headers. If the origin returns the dynamic page and the response comes back with a cache header like X-Cache: HIT or a CDN age header on the second request, the two parsers disagree and the URL is a candidate. No login required to confirm the discrepancy, only to confirm what private data the page would carry for a logged-in victim.

Then the attacker needs a victim to make the request while authenticated. That is the social step: a link in an email, a message, a page the victim already trusts. When the victim, logged in, follows /account/settings/x.css, their browser attaches their session cookie, the origin builds their personalized page, and the cache stores it under the static-looking key. The victim sees nothing wrong; the page rendered correctly. The cookie never left the victim’s browser in a form the attacker can read, and it does not need to. The rendered page already contains the secrets, baked into the HTML.

Finally the attacker requests the same URL themselves, unauthenticated. Their request carries no session cookie, but it does not matter, because the cache answers from storage before the request ever reaches the origin. The stored bytes are the victim’s page. The race is the only constraint: the attacker must request the URL while the cached copy is still warm. Gil’s original PayPal window was about five hours and could be extended by re-requesting, which keeps the entry from aging out. That is comfortably long enough to act on a link a victim clicked a minute ago.

How big the problem actually is

The two USENIX measurement studies are what move this from “a clever trick against PayPal” to “a property of the deployed web.” The 2020 paper ran the first large-scale scan. The 2022 follow-up, “Web Cache Deception Escalates!,” ran the largest to date: a methodology that needs no authentication to find the bug, pointed at the Alexa Top 10,000. It flagged 1,188 vulnerable websites. That is not a count of obscure sites. These were top-ranked domains, and the figure covers only the variants the automated method could detect without logging in.

The escalation in the title is the more important contribution. The 2017 attack leaked whatever happened to be on the cached page: balances, addresses, a partial card number. The 2022 work showed the same primitive reaching secret tokens. A web page that embeds a CSRF token, a session identifier, or an API key in its body becomes, once cached, a public dispenser of that secret. Steal a CSRF token from a cached page and you can forge state-changing requests as the victim. Steal a session identifier and you have their session outright. The authors went further and showed a WCD vector repurposed to poison a cache entry with a cross-site scripting payload, turning a confidentiality bug into stored XSS that runs in other users’ browsers. In one case a customer-support management service with a single WCD flaw exposed 456 of its downstream clients, because the vulnerable component sat in a shared platform. The reach of one misconfiguration is bounded by how many tenants share the parser.

This is the line between deception and its sibling, cache poisoning, and it is worth drawing clearly because the two get conflated. In deception the attacker tricks the cache into storing a victim’s private response and then reads it back. The data flows from victim to attacker. In poisoning the attacker tricks the cache into storing an attacker-controlled malicious response under a key that legitimate users will hit, and the bad data flows from attacker to victims. Both exploit the same root cause, a cache and an origin disagreeing about a request, but they run it in opposite directions. The cache-key mechanics behind poisoning, and the unkeyed-header inputs that make it work, are the subject of the CDN cache key post; the XSS escalation above is the point where a deception primitive crosses over into poisoning territory.

A short history of the bug 2017 Gil discloses, PayPal demo 2017 Cloudflare ships Deception Armor 2020 USENIX: path confusion class 2022 "Escalates": 1,188 sites, tokens 2024 Delimiter + normalization variants *The bug class held steady for seven years while the catalogue of ways to trigger it grew. The 2024 work moved the target from one trick to the parser seam between any cache and any origin.*

Defenses, in order of how much they help

The strongest defense is also the simplest to state and the most annoying to get right everywhere: mark every dynamic response so no shared cache will ever store it, and configure the cache to obey that mark. Concretely, dynamic responses should carry Cache-Control: no-store and, where appropriate, private. no-store tells any cache not to keep a copy at all. private tells shared caches the response is for a single user and only a browser-local cache may hold it. Gil’s original 2017 recommendation was a version of this: configure the cache to store files only when the origin’s caching headers actually permit it. The catch is that this only works if the cache honors the headers, and the entire attack exists because the extension fast path skips them. So the header alone is necessary but not sufficient. You also have to make sure the cache is configured to never override Cache-Control with an extension rule or an edge TTL, which on most CDNs is a separate setting that defaults the wrong way for this purpose.

The content-type check is the second line, and it is what the major vendors automated. Cloudflare’s Cache Deception Armor verifies that the file extension in the URL matches the Content-Type the origin returned before it will cache on the static fast path. A .css URL that comes back as text/html is not a stylesheet and is not cached. This catches the canonical attack cleanly, because a deception payload almost always returns an HTML page under a static-looking extension. It is not airtight. Armor allows some legitimate mismatches by design, treating application/octet-stream as a wildcard and permitting format conversions like a .jpg served as image/webp, and those allowances are exactly the seams researchers probe. There is a public HackerOne report of bypassing Cache Deception Armor using an .avif extension, where the content-type pairing slipped through the allowed set. And the protection, by Cloudflare’s own documentation, depends on Origin Cache Control: a Cache-Control header from the origin or an Edge Cache TTL rule can override it. So the armor reduces the attack surface substantially but does not remove the need to get the headers right underneath it.

The deepest fix, and the one the 2024 research argues for, is to eliminate the parser disagreement itself. If the cache and the origin resolve every URL identically, including delimiters, encodings, and dot-segments, there is no gap to exploit and the rest of the defenses become belt-and-suspenders. In practice that means: disable cache-key normalization where the origin does not match it, stop the proxy from forwarding the suffix that follows a cache delimiter, and, where two components fundamentally cannot be made to agree, change one of them. The researchers were willing to say the quiet part out loud, that if your CDN and your HTTP server parse paths irreconcilably, the real fix may be to replace one of them. That is a heavy recommendation and most teams will not take it. But it is honest about the nature of the bug. You cannot patch a disagreement between two correct-by-their-own-spec parsers with a header. You can only narrow the cases where they disagree, or remove one of the parsers from the conversation.

Two design choices help on the application side and reduce the blast radius when something slips through. Keep secrets out of cacheable response bodies. A CSRF token or session identifier embedded in HTML becomes a catastrophe the moment that HTML is cached; the same token delivered only in an HttpOnly cookie does not, because a cached page does not carry the victim’s cookies to the attacker. The cookie attribute model that makes this hold, HttpOnly, Secure, SameSite, and the __Host- prefix, is worth understanding in its own right and is covered in cookie security attributes. And configure the origin to reject nonexistent paths with a 404 rather than silently serving the parent, which removes the path-mapping variant at the source. Gil named that one in 2017 and it is still good advice: if /account/home/x.css returns a 404 instead of the account page, there is nothing private for the cache to store.

What the bug tells you about layered systems

Web cache deception has the unusual property of having survived almost unchanged for nearly a decade while everything around it improved. The CDNs added content-type checks. The frameworks documented their delimiters. The measurement studies named the class and quantified it. And the bug is still findable in the field, because none of those improvements touched the thing that actually causes it: two programs, each correct, reading the same string and reaching different conclusions, with a trust boundary running right between them.

That is the lesson worth carrying out of this. The cache is not buggy and the origin is not buggy. Each does exactly what its own specification says. The vulnerability lives in the assumption that they agree, and that assumption is never written down anywhere, never tested, never owned by either team. It is the gap between two systems, and gaps between systems are where security work is hardest, because no single component is wrong and no single patch is sufficient. The five characters at the end of a URL are not an exploit. They are a question put to two parsers at once, in the hope that they answer it differently. For seven years, often enough, they have.


Sources & further reading

Further reading