Aller au contenu

Cache poisoning & deception

Ce contenu n’est pas encore disponible dans votre langue.

Unkeyed input mining, web cache entanglement, error response caching, response splitting, and CPDoS each write attacker-controlled content or an error response back through the resource’s own URL, then send a follow-up request to confirm a shared cache is replaying it. Both requests in that pair go to a cache-buster-isolated URL/header — a fresh random token per attempt (--cache-buster-param, default cd_cachebuster, as a query param by default; --cache-buster-mode header/both for a header instead) — instead of the resource’s plain canonical URL, so confirming the vulnerability never poisons the cache entry a real visitor’s plain request would read from. See scan’s cache-buster flags.

Wordlist-driven mining across three input surfaces — headers, cookies, and query parameters — for candidates a fronting cache leaves out of its cache key: injects a marker via each candidate (isolated behind a fresh cache-buster token) and sends a follow-up plain request to the same isolated URL/header to check whether it receives the poisoned response back. This is the classic web cache poisoning precondition — Kettle’s “Practical Web Cache Poisoning” (2018) and the Param Miner discovery methodology.

Each surface mines its own bundled default wordlist, in this order:

  • Headers — X-Forwarded-Host, X-Forwarded-Scheme, X-Forwarded-Proto, X-Forwarded-Server, X-Host, X-Original-URL, X-Rewrite-URL
  • Cookies — debug, beta, preview, test, lang, language, locale, region, country, currency, theme, mobile
  • Query parameters — debug, test, preview, callback, jsonp, redirect, return_url, next, utm_source, utm_campaign, ref, lang, locale

scan’s --unkeyed-wordlist supplements every surface’s bundled list with app-specific candidate names of your own. --max-aggressive-requests is shared across all three surfaces (headers exhausted before cookies before query parameters), so mining deep into the bundled — let alone a custom — wordlist may need raising it well past its default of 10.

FindingSeverityCWE
Unkeyed header injection via <header>criticalCWE-444
Unkeyed cookie injection via <cookie>criticalCWE-444
Unkeyed query parameter injection via <param>criticalCWE-444

Web cache entanglement (fat GET & parameter cloaking)

Section titled “Web cache entanglement (fat GET & parameter cloaking)”

Two more ways an origin can read an input the cache key ignores, both from Kettle’s “Web Cache Entanglement: Novel Pathways to Poisoning” (Black Hat USA 2020). Both use the same marker-and-confirm method as unkeyed input mining, on a cache-buster-isolated URL.

  • Fat GET — a GET request carrying a body. Caches key on the URL and don’t expect a GET body to change the response, but some origins still let it override a query parameter. The marker is sent as a raw body, then as form-encoded and JSON bodies keyed by the resource’s own query parameter names (or q when it has none). Skipped when the scan method isn’t GET, since a body is ordinary there.
  • Parameter cloaking — ;<name>=<marker> appended to the query string. A cache that stops reading the query at the first ; keys the request like the plain URL, while an origin that treats ; as a parameter separator (Rails, some Java stacks) still reads the cloaked parameter. Candidate names are the resource’s own query parameters, then name, q, search, query, callback, redirect, url, lang, plus any --unkeyed-wordlist entries.

This check has its own --max-aggressive-requests budget (it isn’t shared with unkeyed input mining), so both techniques run within the default of 10: fat GET first, then cloaking.

FindingSeverityCWE
Cache poisoning via fat GET request bodycriticalCWE-436
Cache poisoning via parameter cloaking (<name>)criticalCWE-436

Appends a static-file extension or a nonexistent sub-path (/nonexistent.js, .css, /nonexistent.jpg, …) to the resource’s path and checks whether the (potentially authenticated) origin response is served — and looks cacheable — under that URL. This is cache deception via path confusion / static-extension probing.

The static extensions probed come from the CDN registry’s StaticExtensions field (cdn.AllStaticExtensions) — every extension any registered CDN caches by default, deduplicated. The check also tries a handful of origin-only path delimiters (;, %00, %0a, $) ahead of the plain suffixes: some caches decide cacheability from whatever static-looking suffix follows one of these characters, while the origin ignores the delimiter and keeps routing on the full path — a distinct mechanism from a bare static extension, documented by PortSwigger’s “Gotta cache ‘em all” research. For a systematic sweep of which delimiters a target’s origin and cache disagree on, see Delimiter & normalization mapping below.

FindingSeverityCWE
Cache deception via path confusionhighCWE-524

Maps the characters the origin treats as a path delimiter — truncating its route there — but a fronting cache keys on verbatim, and the reverse: characters the cache truncates its key at while the origin routes on the full path. That split is the foundation of both cache-key poisoning and cache deception: the cache stores the origin’s real (possibly authenticated) response under a distinct, attacker-chosen URL, or stores an attacker-provoked response under the real resource’s key. Based on Doyhenard’s “Gotta cache ‘em all” (Black Hat USA 2024).

Each candidate uses the baseline/suffix/insert probe:

  • R0 — the undelimited baseline response for the resource path.
  • R1 — the path plus a random, unrouteable suffix (no delimiter). If R1 already matches R0, the origin ignores trailing path junk wholesale, so no delimiter’s effect can be isolated and the sweep is skipped.
  • R2 — the path plus a candidate delimiter plus that suffix. If R2 matches R0, the origin truncated its route at the delimiter while a cache keying on the full request path would store that response under the distinct …<delimiter><suffix> URL — an origin-only delimiter. R1 and R2 each carry their own cache-buster, so they are answered by the origin’s routing of that exact path — otherwise a cache that truncates its key at the delimiter would replay the cached baseline and be mistaken for an origin-only delimiter.

For a candidate where R2 differs from R0 the check tests the reverse direction on a cache-buster-isolated entry: it requests <path>?<buster> + delimiter + suffix (the origin answers with something other than R0, typically a 404), then the plain <path>?<buster>. If that second, undelimited request is served the first one’s response, the cache keyed it under the undelimited key — it truncates at the delimiter — a cache-only delimiter. Any response the origin can be made to return for the delimited URL then replaces the real resource for every visitor (cache poisoning / denial of service). This costs two extra requests per candidate and runs after the other probes.

The candidates probed, each sent on the wire exactly as written (the percent-encoded forms reach the origin as those literal bytes, not a decoded octet): ;, ., $, %00, %0a, %23, %5c. A bare # from the research’s candidate list is omitted deliberately — a spec-compliant client can’t send an unencoded fragment delimiter in the path (it never leaves the client), so %23 covers the only transmittable form.

The check also probes path-normalization order with dot-segment traversal, encoded (..%2f) and plain (../): it inserts a nonexistent /<rand>/../ detour that collapses back to the resource only if the server normalizes the dot-segment. An origin that normalizes while the fronting cache keys on the verbatim, un-normalized detour path is the “normalize before vs. after cache rules” discrepancy — CloudFront/Azure/Imperva normalize before applying cache rules (not affected), while Cloudflare/Google Cloud/Fastly do not (the cache stores the origin’s response under the detour URL). The encoded form is the higher-risk one; a plain ../ is standard RFC 3986 behavior most caches share, so the origin result alone is reported for mapping only.

For each form the origin normalizes, the check then measures the cache side on a cache-buster-isolated entry. It primes <path>?<buster> and requires the repeat to be served from cache (a normalized HIT, or a non-zero Age) — otherwise the cache isn’t observably storing the resource and the order can’t be told apart from a miss. Then it requests the detour with a fresh random segment and the same buster:

  • served from cache straight away → the detour collapsed onto the primed canonical key: the cache normalizes before keying (info — it agrees with the origin);
  • a miss, then served from cache on a repeat → the cache stored the origin’s response under the verbatim detour URL: it normalizes after (or never) — the web-cache-deception condition (high for the encoded form, medium for the plain one);
  • neither → not determined, and nothing is recorded.

The measured order is compared with the PathNormalization the CDN registry documents for the detected CDN, and the finding notes whether they agree — a customer’s rule set or an extra cache tier can override the vendor default. If the order can’t be measured but the CDN is fingerprinted with documented behavior, an info finding states that unverified expectation. The probe costs up to six requests (two to prime, up to two per form).

Per-origin results (the origin-only and cache-only delimiters found, the origin’s normalization verdicts, and the measured cache-side order for each form) are recorded in the CDN quirk store (cdn.DiscoveredQuirks), accumulated across every resource probed under a target so a later resource — or an embedding tool — can reuse the mapping instead of re-probing.

The baseline R0 is fetched at the resource’s own URL, so the check runs after the rest of the §5 chain to avoid that request racing another check’s poison/confirm pair; every request that could write to a cache is cache-buster-isolated. Full coverage needs a request budget well above the default: up to 30 requests per resource (the origin-side sweep takes 10; the rest go to the cache-side probes in order — normalization order, then cache-only delimiters), so raise --max-aggressive-requests to cover them.

FindingSeverityCWE
Origin-only path delimiter <delimiter>high (cacheable) / mediumCWE-524
Origin normalizes encoded path traversalhighCWE-524
Origin normalizes plain path traversalinfoCWE-524
Cache-only path delimiter <delimiter>highCWE-524
Cache keys encoded ..%2f traversal verbatimhighCWE-524
Cache keys plain ../ traversal verbatimmediumCWE-524
Cache normalizes encoded/plain traversal before applying cache rulesinfoCWE-524
Cache path-normalization order not observableinfoCWE-524

Requests the resource through a path that starts under a static directory and climbs back out — /static/<rand>/../../account — and checks whether the origin still serves the resource and the response then gets cached. A cache that applies its “cache everything under /static/” rule to the raw path caches the origin’s dynamic response under a URL anyone can request. Three spellings are tried, because caches and origins disagree on when they decode:

VariantPath probedCatches
plain/static/x/../../accountcache matches its directory rule before resolving ../
encoded/static/x/..%2f..%2faccountcache matches the rule before decoding %2f
nested-encoded/static/x/..%252f..%252faccountcache decodes once, origin keeps decoding until the separators are real

Directories tried, in order: /static, /assets, /public, /images, /css, /js, /media. Each variant stops at its first hit. With the default --max-aggressive-requests of 10, /static and /assets are covered for all three variants; raise the cap to reach the rest.

Matching the canonical response only shows the origin normalizes the path, which most do. So the check also:

  • sends a control request with no traversal (/static/x/account) and gives up if the origin serves the resource for that too — it routes loosely, and a match can’t be attributed to normalization;
  • skips resources whose canonical URL is already a cache HIT, since caching one more URL for public content exposes nothing new;
  • repeats the matching request and looks for a cache HIT. A HIT on the traversal URL while the canonical URL isn’t cached is the deception working (high). With no readable cache-status header the finding is medium and says caching wasn’t observed.
FindingSeverityCWE
Cache deception via static-directory path traversalhigh (cache HIT observed) / mediumCWE-524
Cache deception via encoded static-directory path traversalhigh / mediumCWE-524
Cache deception via nested-encoded static-directory path traversalhigh / mediumCWE-524

The poisoning counterpart of the check above, also from “Gotta cache ‘em all”: a cache that normalizes ../ before computing its key while the origin routes the raw path. /static/x/../../account is then served by the origin as static content but stored under the key /account — one request overwrites any other path’s cache entry (“cache what/where”).

The probe never touches a real entry. It collides onto a random, never-requested path: request /static/<seg>/../../<rand>, and if the origin answered with content that differs from what a nonexistent path returns, request /<rand> plainly. Getting the static content back from a path nothing ever served proves the cache keyed the first request as /<rand>. A catch-all origin (every path answers alike), or one that normalizes the path itself, produces no finding.

Plain and %2f-encoded separators are tried against /static and /assets; the first confirmed collision ends the check. It needs a static route that returns 2xx for an arbitrary filename under the directory, so an origin whose static handler 404s on unknown files can’t be probed this way and is not reported.

FindingSeverityCWE
Cache-key path collision via path traversalcriticalCWE-436

Provokes a 4xx/5xx response (via an out-of-range Range request) and checks whether the error response itself carries cache directives that make it cacheable — a transient error served to other users for longer than intended.

FindingSeverityCWE
Error response is cacheablemediumCWE-524

Response splitting via cache-key manipulation

Section titled “Response splitting via cache-key manipulation”

Injects a CRLF-encoded header/path fragment into an unkeyed input and checks whether it’s reflected as a literal, separate response header — HTTP response splitting exploitable through the cache.

FindingSeverityCWE
Response splitting via unkeyed inputcriticalCWE-113

This check depends on the unkeyed-header-injection check completing first (rather than just on resource discovery) — both manipulate the same unkeyed inputs on the same live resource, and running them concurrently could let one check’s poisoning request land in the middle of the other’s poison/confirm pair and mask its finding.

Reproduces the three variants from the original 2019 CPDoS disclosure (see the CDN registry’s cpdos-2019 Quirk) against the live target, each as a poison-then-confirm pair: provoke an origin error, then check whether a follow-up plain request is served that same error back — meaning it was cached and is now being replayed to ordinary callers, not a one-off response to the probe itself.

  • HTTP Header Oversize (HHO) — an oversized header block a fronting cache accepts but the origin’s own, smaller limit rejects.
  • HTTP Method Override (HMO) — an X-HTTP-Method-Override: DELETE header the origin honors (re-interpreting the request) but the cache ignores, keying purely on the real GET line.
  • HTTP Meta Character (HMC) — a control character injected into an existing query parameter’s value, rejected by the origin’s input validation while the cache keys on the path alone. Needs a resource with at least one query parameter to mutate; a bare path has no surface for this variant and is silently skipped.

Stops after the first confirmed variant per resource: once one has poisoned a (typically single-slot) cache, the remaining variants would just observe that same residual poisoning rather than an independent finding.

FindingSeverityCWE
CPDoS via oversized headers (HHO)highCWE-444
CPDoS via method override (HMO)highCWE-444
CPDoS via HTTP meta-character (HMC)highCWE-444

Sends the resource 20 times, each with a different random value for one query parameter (its first existing one, or cd_key), and checks whether every value became its own cache entry — a MISS each time, and a HIT on repeating the first. If nothing ever caps the number of distinct entries, an attacker can fill the cache with junk and force an origin request per unique value, defeating the cache.

Treat this as an exposure assessment, not a defect: most caches key on the full query string by design, so it reports at low severity and the fix is edge-side (rate limiting, query-parameter allowlisting or normalization). Two constraints keep it from guessing:

  • It needs --max-aggressive-requests of at least 21 (20 distinct values plus one repeat) and a readable cache-status header. Below that there is no way to tell “no cap” from “a cap above the sample”, so the check sends nothing at the default of 10.
  • Any HIT among the 20 means a cap, a shared overflow bucket, or a parameter the cache doesn’t key on, and ends the check without a finding.

It leaves 20 junk entries in the target’s cache, which is why it sits behind --aggressive with the rest of this page.

FindingSeverityCWE
Unbounded cache-key cardinalitylowCWE-400