Skip to content

Web Cache Poisoning

Samuele Giampieri edited this page Jun 30, 2026 · 1 revision

Web Cache Poisoning

The Web Cache Poisoning (WCP) module is RedAmon's active scanner for web cache poisoning and web cache deception. Given the live URLs that earlier recon stages discovered, it finds URLs served through a cache and tests whether an attacker-controlled, unkeyed request component (a header, a query parameter, or a path trick) can be smuggled into a shared cache entry and then served to every other visitor.

It runs as GROUP 6 Phase A - in parallel with Nuclei, the GraphQL scanner, Subdomain Takeover, and VHost & SNI - because all of them consume the same inputs (BaseURL, Endpoint, Technology) and produce Vulnerability nodes, but have zero data dependency on each other. Active and disabled by default. Enable in the project settings under the Web Cache Poisoning tab.

Want the engineering-level reference? This page is the operator-facing guide. The exhaustive internals (every file in the recon/cache_scan package, the WCVS report schema, the FP-guard logic, the scoring tiers) live in readmes/README.WCP_RECON_MODULE.md in the project repo.

New to web cache attacks? Read top to bottom. The first half teaches the attack itself, how caches workhow poisoning workshow deception worksthe full technique catalogue. The second half shows how RedAmon detects it, the two engines and the scan pipeline step by step. If you just want to run it, jump to the UI parameter reference.


The core concept

A cache decides "have I seen this request before?" using a cache key built from some request components (the keyed ones - usually method + host + path + a few query params) while ignoring others (the unkeyed ones - most headers). If the backend uses an unkeyed component to shape the response, an attacker can poison the cached response for everyone:

  1. The attacker sends a request with a malicious value in an unkeyed header (e.g. X-Forwarded-Host: evil.example).
  2. The cache ignores that header for keying, so it treats the request as the normal page and stores the poisoned response.
  3. Every subsequent innocent visitor who requests the same page is served the attacker's poisoned copy from cache - no attacker interaction required.
Term Meaning
Cache key The request components a cache uses to decide "same request?"
Keyed / unkeyed Components included in / ignored by the cache key. Unkeyed + backend-trusted = the poisoning door
Cache buster A unique value forcing a fresh cache entry, used to isolate every test from the real page
Canary A benign, recognisable marker injected as the payload (non-resolving .invalid host)
Poisoning vs deception Poisoning pushes a malicious response to all victims; deception pulls a victim's private response into a shared cache
CPDoS Cache-poisoned denial of service (serving an error/oversized response to everyone). Off by default
Cache oracle A reliable signal (here, header-based) of whether a response was a cache hit or miss

How web caches work (the foundation)

To understand the attack you first have to understand the thing being attacked. A cache is a layer that sits between the visitor and the web server (the "origin") and keeps copies of responses so it can answer repeat requests itself, without bothering the origin. Caches exist for speed and scale: a CDN edge node in the visitor's own city can return a page in 10 ms instead of 300 ms, and it shields the origin from millions of identical requests.

There are two families of cache, and the distinction is the whole game:

  • Private caches store responses for one user (your browser cache, a per-user cache). Poisoning one only hurts that single user, so the impact is low.
  • Shared caches store one copy of a response and hand it to every user who asks for the same thing. CDNs (Cloudflare, Fastly, Akamai), reverse proxies (Varnish, nginx, Apache Traffic Server), and ISP proxies are all shared caches. This is what web cache poisoning targets: poison the single shared copy and you have poisoned it for everyone at once.

The cache key: how a cache decides "same request?"

When a request arrives, the shared cache computes a cache key by hashing together a chosen subset of the request, then looks that key up:

cache key  =  method + scheme + host + path + (a few specific query params)

If a stored response already exists under that key, the cache serves it (a HIT) and never contacts the origin. If not, it forwards to the origin (a MISS), stores the response it gets back, and serves it.

The detail that makes poisoning possible: the cache key only includes some parts of the request. Everything else is unkeyed, meaning the cache ignores it when matching. Most HTTP request headers (X-Forwarded-Host, User-Agent, often cookies) and frequently most query parameters are unkeyed by default.

  • Keyed = part of the key = the cache treats a change here as a different page.
  • Unkeyed = ignored by the key = two requests that differ only here collide on the same cached entry.

The Vary response header lets the origin tell the cache "also key on this request header" (for example Vary: Accept-Encoding). RedAmon's oracle captures Vary because it reveals which headers are already keyed, and therefore safe to ignore as attack vectors.

Where the vulnerability actually lives

A cache is perfectly safe as long as unkeyed inputs never change the response. The bug appears only when two things are true at the same time:

  1. The backend reads an unkeyed input and lets it shape the response (reflects it into the HTML, uses it to build a redirect Location, branches on it to pick a status code), and
  2. The cache stores that response and later serves it to other people under a key the attacker can also reach.

When those two overlap, an attacker tweaks the unkeyed input, the origin bakes the attacker's value into the response, the cache files it under the normal key, and every later visitor is served the poisoned copy. That overlap, "unkeyed but backend-trusted", is the single door this entire module hunts for.


How web cache poisoning works (step by step)

Poisoning pushes a malicious response into the shared cache so that it is served to all victims. The canonical attack chain:

sequenceDiagram
    participant A as Attacker
    participant Cache as Shared cache / CDN
    participant O as Origin server
    participant V as Victim (any visitor)
    A->>Cache: GET /page  with  X-Forwarded-Host: evil.com
    Note over Cache: header is UNKEYED, key = GET + host + /page
    Cache->>O: MISS, forward request (header passes through)
    O-->>Cache: 200 page with <script src="//evil.com/x.js">
    Note over Cache: stores poisoned body under the normal key
    V->>Cache: GET /page  (a totally normal request)
    Cache-->>V: HIT, poisoned page with the attacker's script
    Note over V: victim's browser loads //evil.com/x.js  →  mass stored XSS
Loading
  1. Find an unkeyed input the backend trusts. Here the app reflects X-Forwarded-Host into an absolute <script src> URL, which is common when a framework builds canonical or asset URLs from the "host" the proxy reports rather than from a fixed config value.
  2. Send one poisoning request. The attacker sets X-Forwarded-Host: evil.com. The cache does not key on that header, so as far as it is concerned this is just a normal request for /page.
  3. The origin bakes in the payload. The generated response now points the script tag at //evil.com/x.js.
  4. The cache stores it under the normal key. In a real attack there is no cache-buster, so it lands on the real /page entry.
  5. Every victim is poisoned. Anyone who requests /page is served the cached, attacker-controlled markup and runs the attacker's JavaScript. One request, unlimited victims, no victim interaction, lasting until the entry expires.

Why poisoning is so severe: it is mass (one cached entry serves everyone), zero-interaction (the victim simply browses normally), and persistent (it lasts the cache TTL and can be re-poisoned on expiry). Depending on which unkeyed input the backend trusts, the very same mechanism yields stored XSS, open redirect, JavaScript/asset hijack, HTTPS-to-HTTP downgrade, or a site-wide denial of service.


How web cache deception works (the mirror image)

Deception is the inverse trick. Instead of pushing a payload out to everyone, the attacker pulls a victim's private response into the shared cache, then reads it back from a URL the attacker controls.

The mechanism is path confusion: many caches will store anything whose path looks static (ends in .css, .js, .jpg) regardless of what the origin actually returned, while the origin ignores the extra path segment and serves the normal dynamic page.

sequenceDiagram
    participant A as Attacker
    participant V as Victim (logged in)
    participant Cache as Shared cache / CDN
    participant O as Origin server
    A->>V: lure: click https://site/account/profile/fake.css
    V->>Cache: GET /account/profile/fake.css  (carries the victim's session cookie)
    Note over Cache: path ends in .css  →  cache decides "static, cacheable"
    Cache->>O: MISS, forward
    Note over O: origin ignores /fake.css, serves /account/profile
    O-->>Cache: 200 victim's private profile (name, email, tokens)
    Note over Cache: stores the PRIVATE page under /account/profile/fake.css
    A->>Cache: GET /account/profile/fake.css  (no cookie)
    Cache-->>A: HIT, the victim's private profile
Loading
  1. The attacker lures a logged-in victim to https://site/account/profile/fake.css.
  2. The victim's browser sends the request with their session cookie attached.
  3. The cache sees the .css suffix and decides the response is a cacheable static file.
  4. The origin ignores the bogus /fake.css (or maps it to the same controller) and returns the victim's private account page.
  5. The cache stores that private page under the attacker-known URL.
  6. The attacker requests the exact same URL with no cookie at all and is handed the victim's private data straight from cache.

Poisoning vs deception in one line: poisoning pushes attacker content out to all victims; deception pulls a victim's private content in for the attacker. WCVS handles deception probing; the native confirmer is poisoning-shaped, so deception findings are surfaced but tend to score lower (see Known limitations).


Catalogue of techniques

The attack surface is far wider than "one weird header." Below is the full landscape this module reasons about, grouped by mechanism. The Engine column shows what covers each one: W = WCVS breadth sweep, N = RedAmon native hypotheses + confirmation.

1. Unkeyed header poisoning

The classic family: a request header the cache ignores but the backend trusts and reflects or acts on.

Technique Example header(s) Typical impact Engine
Host override X-Forwarded-Host, X-Host, X-Original-Host, X-Host-Override, Forwarded Reflected/stored XSS, open redirect, password-reset link poisoning W + N
Routing / server X-Forwarded-Server, X-Forwarded-Host-Override, X-HTTP-Host-Override Same as host override N
Scheme / protocol X-Forwarded-Proto, X-Forwarded-Scheme, X-Original-Scheme, X-Url-Scheme Forced HTTPS-to-HTTP downgrade, redirect loop, insecure asset load W + N
Port X-Forwarded-Port Redirect to the wrong port, broken absolute URLs N
URL / path override X-Original-URL, X-Rewrite-URL Cache-key vs routing confusion, serve the wrong page N
Client IP X-Forwarded-For, X-Real-IP, True-Client-IP, X-Client-IP Geo/ACL bypass cached for all, reflected-IP XSS N
Custom / vendor app-specific headers (X-Country, debug or CDN headers) Whatever the app trusts W

2. Unkeyed parameter and cookie poisoning

Technique How it works Impact Engine
Unkeyed query param A parameter that changes the response but is excluded from the key (utm_*, lang, tracking params) Reflected XSS / redirect cached for everyone W
Fat GET A request body sent on a GET; the cache keys on the URL only while the origin reads the body Smuggle a payload past the cache key W
Parameter cloaking Duplicate or oddly-delimited params (?x=1;x=2) parsed differently by cache vs origin Bypass the key, poison the entry W
Cookie poisoning An unkeyed cookie the backend reflects into the page Reflected content cached across users W

3. Cache-key manipulation

Technique How it works Impact Engine
Cache-key normalization The cache normalizes path/params (case-folding, %-decoding, trailing slash) differently than the origin Two different requests collide on one key W
Cache-key injection Inject a delimiter so the attacker's variant maps onto the victim's key Targeted poison of a specific entry W
Path normalization /page/..%2f, encoded slashes, dot-segments resolved differently by the two layers Serve the wrong cached resource W

4. CPDoS (cache-poisoned denial of service) - research profile only

Variant Mechanism Impact Engine
HHO (HTTP Header Oversize) Send a header larger than the origin accepts but the cache still forwards; the origin returns an error and the cache stores that error for everyone Site-wide outage W (gated)
HMC (HTTP Meta Character) A meta or control character the cache forwards but the origin rejects Cached error page W (gated)
HMO (HTTP Method Override) X-HTTP-Method-Override flips the method at the origin only Cached 4xx/5xx for all visitors W (gated)

CPDoS is off by default and only runs in the research profile with the Allow-CPDoS toggle, because by design it serves errors to real users.

5. Web cache deception

Variant The trick Impact Engine
Static-extension confusion /account/profile/x.css Cache stores the private page W
Path-parameter confusion /account/profile;x.css, /account/profile%2fx.css Same, but bypasses stricter caches W
Encoded delimiter / newline %0a, %23, %3f injected before the static suffix Defeat suffix checks W
Static-directory confusion /static/..%2faccount/profile Map a dynamic page under a cached directory W

6. Framework-specific (fingerprint-gated)

RedAmon adds these only when the recon technology fingerprint matches the stack, to keep request volume sane:

Stack Vector What it abuses Engine
Next.js x-invoke-status, __nextDataReq, x-now-route-matches, Rsc Forces an error render or data route that the cache then stores; CPDoS or data confusion N
Nuxt /_payload.json path confusion Cache a payload route in place of the page N
Remix / React Router _data param, Host/X-Forwarded-Host port confusion Serve a data response or a wrong-host build N

Why a dedicated module (and two engines)

Detection is a two-engine pipeline. Each engine alone is insufficient: the first is broad but noisy, the second is precise but narrow. Together they turn "looks vulnerable" into a trustworthy, scored finding.

flowchart LR
    T["Target URLs"] --> W["WCVS · breadth<br/>find suspects<br/>10+ technique classes"]
    W --> C["Native 5-phase · depth<br/>confirm suspects<br/>baseline → poison → clean"]
    C --> F["Scored findings<br/>≥ min confidence"]
    classDef a fill:#e0e0e0,stroke:#555,color:#000
    classDef b fill:#bdbdbd,stroke:#444,color:#000
    class T,W,C a
    class F b
Loading
Engine Role Strength Weakness
WCVS (Hackmanit Web Cache Vulnerability Scanner, Go) Breadth - find suspects 10+ technique classes, built-in crawler, deception coverage, mature High-noise; a "vulnerable" verdict needs re-proof
RedAmon native (Python, Phases 1b–5) Depth - confirm suspects Precise, fingerprint-aware, integrated scoring/safety/graph; reflected and non-reflective (differential) detection Single-shot poison (no concurrent race-winning)

WCVS casts the wide net (the "find suspects" half); the native confirmation engine re-validates every suspect with a safe baseline → poison → clean → persistence sequence (the "prove it" half). Only findings that pass the confidence threshold become Vulnerability nodes.


Pipeline position

GROUP 4  → HTTP probing (httpx)            → live BaseURLs
GROUP 5  → Resource enumeration            → parameterized Endpoints
GROUP 5b → JS Reconnaissance
GROUP 6 Phase A  → Nuclei  ||  GraphQL  ||  Subdomain Takeover  ||  VHost & SNI  ||  Web Cache Poisoning   ← parallel fan-out
GROUP 6 Phase B  → MITRE enrichment (consumes Nuclei CVEs)

WCP must run late because it depends on two products of earlier stages:

  1. Live URLs - from http_probe.by_url (GROUP 4 httpx) and resource_enum (GROUP 5 crawling). You cannot test cache poisoning before you know which URLs are live.
  2. Technology fingerprint - the framework hypothesis packs (Next.js, Nuxt, Remix) only fire when the recon fingerprint makes them plausible.

Phase A is fanned out via ThreadPoolExecutor, and each scanner uses an _isolated wrapper that deep-copies the shared combined_result so the threads never race on the same dict. WCP reuses the exact same target builder Nuclei uses (build_target_urls) - no duplicated discovery logic.


How a scan flows (the macro view)

flowchart TD
    S0["0 · Collect targets<br/>http_probe + resource_enum · RoE filtered · cap 200"]
    S1["1 · WCVS breadth<br/>docker run redamon-wcvs → candidates"]
    S1b["1b · Cache oracle<br/>X-Cache / Age / CF-Cache-Status / silent-cache"]
    S2["2 · Cache-buster<br/>isolated test slot"]
    S3["3 · Hypotheses<br/>WCVS + generic + framework packs"]
    S4["4 · Confirmation<br/>baseline → poison → clean → persisted?"]
    S5["5 · Scoring<br/>Confirmed / Strong / Tentative / Rejected"]
    S0 --> S1 --> S1b --> S2 --> S3 --> S4 --> S5
    S5 --> OUT["cache_scan output<br/>scan_metadata · by_target · findings · summary"]
    OUT --> NEO[("Neo4j<br/>Vulnerability source=cache_poisoning")]

    classDef mod fill:#f0f0f0,stroke:#666,color:#000
    classDef out fill:#bdbdbd,stroke:#444,color:#000
    classDef store fill:#9e9e9e,stroke:#333,color:#000
    class S0,S1,S1b,S2,S3,S4,S5 mod
    class OUT out
    class NEO store
Loading

Step 0 - Collect and gate targets

You cannot test caching on a URL you have not discovered, so the module starts by building its target list from what earlier recon already proved is live. Targets are unioned from three sources: (1) parameterized endpoint URLs from resource_enum (the crawlers, GROUP 5), because pages that take parameters are the ones most likely to reflect input; (2) live BaseURLs from http_probe (httpx, GROUP 4); and (3) a synthesised http(s)://{hostname} for any in-scope host that nothing else covered, so a host is never skipped just because it was not crawled.

The combined list is then deduplicated and gated twice before any packet leaves:

  • Rules of Engagement filtering drops every excluded host and any of its subdomains, so an out-of-scope target can never be probed even if it slipped into the graph.
  • A hard cap of 200 URLs keeps a huge attack surface from turning into a runaway active scan. WCVS sends hundreds of requests per URL, so 200 URLs is already tens of thousands of requests; the cap is a safety and time budget, not a discovery limit.

This is also exactly the target builder Nuclei uses (build_target_urls), so the two scanners agree on scope and there is no second, drifting copy of the discovery logic.

Phase 1 - WCVS breadth sweep ("find suspects")

WCVS (the Hackmanit Web Cache Vulnerability Scanner) is the wide net. Its job is coverage: for every target it runs its own mini-crawl and then throws its full library of technique classes (unkeyed headers, parameter tricks, path normalization, deception, and more) at each cacheable URL, looking for any sign that an unkeyed input changed a cached response. It is deliberately aggressive and high-recall, which is why its raw verdicts are treated as suspects, not findings, and handed to the native confirmer for re-proof.

Mechanically it runs Docker-in-Docker: the recon container shells out to the host Docker daemon to launch the redamon-wcvs image, exactly like Nuclei and Katana do. All targets go in as one batch (-u file:/targets/targets.txt), and WCVS writes its JSON report incrementally after each URL, so even if it times out partway through, RedAmon still parses every URL it finished. Each vulnerable check in that report becomes one candidate carrying the URL, the technique, and the specific vector to re-test. WCVS is launched with safety skips baked in (see Safety model) and with -stime to disable time-based cache detection, which infers caching from response-time deltas and is notoriously false-positive prone. If WCVS fails for any reason (timeout, no report, malformed JSON) the phase returns zero candidates and is non-fatal: the native hypotheses in Phase 3 still run, so the scan degrades gracefully instead of aborting.

Phase 1b - Cache oracle (is this URL even cached?)

This is the gate that makes the whole module both safe and meaningful. Web cache poisoning is, by definition, impossible on a URL that is not cached: if every request hits the origin fresh, there is no shared entry to poison. So before spending any poison requests, the oracle answers one question per URL, "is a shared cache actually storing this response?", and a no means the URL is dropped immediately. This both avoids wasting requests and prevents false positives where a one-off reflected value gets mistaken for a cached one.

The hard part is that caches do not announce themselves uniformly, so the oracle reads several independent signals. For each URL it issues up to three GETs (at least two, to catch a MISS that warms into a HIT, and caches that only store on a later request) and inspects the responses:

  • Status headers carry a hit/miss token: x-cache, cf-cache-status, x-cache-status, x-drupal-cache, x-varnish (numeric - two ids = a hit), x-served-by/x-cache-hits (Fastly), x-iinfo (Imperva), x-cache-lookup (Squid), and more. Tokens map as: hit → cached; miss/expired → cacheable; stale/updating → served stale from cache; dynamic/bypass/pass/no-cache → cache present but not caching this URL (Cloudflare dynamic correctly does not count as cacheable).
  • Presence headers (via, surrogate-control, cdn-cache-control, x-cdn) prove a cache layer exists.
  • A positive age header means the response was served from cache.
  • cache-control: public / a non-zero max-age marks the URL cache-eligible (unless private/no-store).
  • vary is captured (which request headers are keyed) and surfaced downstream.
  • Behavioural fallback (silent caches): when no cache header is present, the oracle probes twice across a short delay and flags a cache if the origin Date header is frozen and the body is identical (a cache replays the stored response with its original Date; a live origin advances it). This reads a server-generated header, so it is not the FP-prone response-time inference WCVS disables.

A URL that isn't cacheable is skipped, and the reason is recorded in by_target for visibility.

Phase 2 - Cache-buster placement (the safety core)

This phase is what separates a scanner from an attack. A real attacker poisons the live entry on purpose, harming everyone who visits. A scanner must prove the exact same vulnerability without ever touching the entry real users hit. The cache buster is how.

A cache buster is a unique, single-use value (by default a query parameter named rdmncb, e.g. ?rdmncb=cb9f1a2b) that the cache does key on. Because it is part of the key and never seen before, the cache treats the test request as a brand-new entry with its own private slot. The test sequence then poisons that throwaway slot and reads the poison back from that throwaway slot, proving the vulnerability, while the genuine /page entry (with no buster) is never written to and live visitors are never served anything the scanner produced.

find_cache_buster first confirms the chosen parameter actually participates in the cache key (adds it, requests twice, checks the responses diverge as expected). Crucially, every single vector mints a fresh buster value, so tests never collide with each other either. This is a hard safety control, not merely an optimisation, and it is why the scan can run against production caches in the safe-confirm profile.

Phase 3 - Hypotheses (native test vectors)

A "hypothesis" is a concrete guess of the form "header X is unkeyed and the backend trusts it." Each one becomes a vector the confirmer will test. This phase exists for two reasons: to re-test every WCVS suspect faithfully (mapping each WCVS candidate to the right vector type so a parameter or deception finding is not forced through the header path), and to add coverage WCVS may have missed, especially modern framework-specific tricks. A vector that WCVS already tested is not duplicated.

Each hypothesis carries three pieces of metadata that tell Phase 4 how to inject it and what to look for: vector_type (header | param | path), payload_kind (host | value | path | scheme | port | ip), and an impact_hint. The payload_kind is what decides whether the vector is detected by reflection or by behaviour change, which matters because not every poison echoes a visible marker (more on that in Phase 4).

There are two sources of vectors:

  1. Generic unkeyed headers (always tried unless WCVS already covered them), organised into families:

    • host spoofX-Forwarded-Host, X-Host, X-Forwarded-Server, X-Original-Host, X-Host-Override, Forwarded, …
    • scheme/protoX-Forwarded-Proto, X-Forwarded-Scheme, X-Original-Scheme, X-Url-Scheme
    • portX-Forwarded-Port
    • URL overrideX-Original-URL, X-Rewrite-URL
    • client-IPX-Forwarded-For, X-Real-IP, True-Client-IP, X-Client-IP

    Host/path/value headers carry the benign .invalid canary (reflective); scheme/port/IP headers carry a fixed benign value (https / 443 / 127.0.0.1) because they poison via behaviour change, not by echoing a marker - these are caught by the differential detector in Phase 4.

  2. Framework packs, gated on the recon technology fingerprint and the Framework-packs toggle:

    • Next.jsx-invoke-status (CPDoS via /_error render), __nextDataReq, x-now-route-matches, Rsc
    • Nuxt/_payload.json path confusion
    • Remix / React Router_data, Host/X-Forwarded-Host port confusion

Phase 4 - Behavioural confirmation (the heart of the engine)

For each vector, the confirmer runs a 4-request sequence all in one isolated cache-buster slot, using a benign canary:

sequenceDiagram
    participant S as Scanner
    participant C as Cache / CDN
    participant B as Backend
    Note over S,C: every request carries the same unique cache-buster (isolated slot)
    S->>C: 1 · baseline GET busted_url (clean)
    C->>B: miss → fetch
    B-->>C: clean response
    C-->>S: baseline (record baseline_hash)
    S->>C: 2 · poison GET busted_url + canary payload
    C->>B: unkeyed input ignored by cache key → fetch
    B-->>C: response reflecting canary
    C-->>S: poisoned (reflected_in_baseline?)
    Note over C: cache stores poisoned body under the busted_url key
    S->>C: 3 · clean GET busted_url (NO payload)
    C-->>S: HIT → poisoned body (persisted_on_clean? cache_hit_on_clean?)
    S->>C: 4 · repeat clean GET (stability)
    C-->>S: HIT → canary again (repeated_ok, stable)
Loading

The decisive moment is step 3: ask again as an innocent user, with no payload, and see the poison come back from cache. That is poisoning, proven.

Two detection modes run together:

  • Reflected - the benign canary marker is echoed in the body or a redirect Location. Unambiguous proof; the only mode allowed to reach the Confirmed tier.
  • Differential (non-reflective) - the poison changes the response behaviour (a Location redirect, a status code flip, or the body) with no echoed marker. Catches the class reflection-only confirmers are blind to: CPDoS status flips, redirect/scheme poisoning, cache-key confusion serving a different body. Capped at Strong (never Confirmed), since a behavioural diff is inherently more coincidence-prone than a reflected marker.

False-positive guard. Differential mode issues two clean baselines first and only trusts the response dimensions that were identical across both. A page whose body flaps every request (timestamps, CSRF tokens) has its body dimension marked untrusted, so it cannot raise a body-diff finding - status/location can still be judged if they were stable.

Phase 5 - Confidence scoring

Not every "yes" from Phase 4 is equally trustworthy, so instead of a raw true/false the engine assigns a confidence tier based on how much of the proof chain held. The single most important fact is persisted_on_clean: did the poison come back on the clean, payload-free follow-up request? If not, then whatever you saw was a one-request reflection, not a cached poison, and it is Rejected outright. If it did persist, the tier then rises with corroborating evidence, an observed cache HIT on the clean request, the poison repeating on a second clean request, and the response staying stable, each of which makes a coincidence less likely.

flowchart TD
    R["confirmation record"] --> P{"persisted_on_clean?"}
    P -- "no · reflected only" --> RJ["Rejected < 0.50"]
    P -- "yes" --> CH{"cache_hit AND repeated AND stable?"}
    CH -- "yes" --> CF["Confirmed 0.95–0.99"]
    CH -- "no" --> ST{"cache_hit or stable?"}
    ST -- "yes" --> SG["Strong 0.80–0.94"]
    ST -- "no" --> TT["Tentative 0.50–0.79"]

    classDef g fill:#e8e8e8,stroke:#555,color:#000
    classDef d fill:#bdbdbd,stroke:#444,color:#000
    classDef t fill:#9e9e9e,stroke:#333,color:#000
    class R g
    class P,CH,ST d
    class CF,SG t
    class TT,RJ g
Loading

Only findings with confidence ≥ Min confidence (default 0.8, which keeps Confirmed + Strong and discards Tentative + Rejected) are written to the graph. Raising the threshold trades recall for precision; lowering it surfaces weaker signals you would then triage by hand. Separately, the concrete impact (resolved in Phase 4, for example a persisted payload landing in a Location header makes it open_redirect) maps to a severity and CVSS score: stored_xss → critical / 9.3, open_redirect → high / 7.4, deception → high / 7.5, dos → high / 7.5, reflected → medium / 5.3. Confidence (how sure we are it is real) and severity (how bad it is if real) are deliberately kept as two independent axes.


Worked example (one Confirmed finding)

Target https://shop.example/home, Cloudflare-fronted, backend reflects X-Forwarded-Host into a <script src>. Buster ?rdmncb=cb9f1a2b, canary rdmn1a2b3c.redamon-poc.invalid.

sequenceDiagram
    participant S as Scanner
    participant C as Cloudflare
    participant B as Backend
    S->>C: oracle x2 GET /home
    C-->>S: cf-cache-status HIT · age 30  →  cacheable
    S->>C: baseline GET /home?rdmncb=cb9f1a2b
    C-->>S: clean page
    S->>C: poison GET …?rdmncb=cb9f1a2b<br/>X-Forwarded-Host: rdmn1a2b3c.redamon-poc.invalid
    C->>B: forwards (header unkeyed)
    B-->>C: page with //rdmn1a2b3c.redamon-poc.invalid/…
    C-->>S: reflected_in_baseline = true
    S->>C: clean GET …?rdmncb=cb9f1a2b (no header)
    C-->>S: HIT · canary STILL present  →  persisted + cache_hit
    S->>C: repeat clean GET
    C-->>S: HIT · canary again · stable
    Note over S: score 0.97 Confirmed · impact open_redirect · high/7.4
Loading

The canary never resolves, and the real …/home entry (no buster) was never touched. Result: a Vulnerability {source:"cache_poisoning", cache_header:"X-Forwarded-Host", confidence_tier:"Confirmed"} linked to the Endpoint and BaseURL, with a clickable poc_link and a curl_verify command.


Full UI parameter reference

Every control in the Web Cache Poisoning section of the project form, top to bottom. The naming convention is web_cache_poison_* (DB column) → webCachePoison* (Prisma/frontend) → WEB_CACHE_POISON_* (Python).

Master toggle & run button

Control Default What it does
Enable Web Cache Poisoning (header toggle) false Master switch. Active, opt-in. The whole section's fields only appear when this is on
Run partial recon (▶ button) - Appears only when enabled. Launches a one-off Partial Recon run of just this module against the current graph (plus any custom URLs you paste in the modal)

Scan profile

Control Default What it does
Scan profile (dropdown) safe-confirm Picks the safety envelope. safe-confirm (production recon - benign payloads, isolated cache buckets, no CPDoS) · extended (owned test targets) · research (lab only - the only profile that can enable CPDoS)

Techniques (toggles)

Toggle Default What it does
Framework hypothesis packs true Fire Next.js / Nuxt / Remix-specific vectors (x-invoke-status, __nextDataReq, /_payload.json, _data) only when the technology fingerprint matches. Off → generic header vectors only
Silent-cache detection (frozen-Date) true Catch caches that emit no X-Cache/Age headers (default Varnish/nginx, hardened CDNs) by fetching twice across a short delay and checking whether the origin Date is frozen. Without this, silent caches are skipped entirely
Non-reflective (differential) detection true Also confirm poisons that change the response behaviour - a persisted status code, Location redirect, or body - with no echoed marker. Guarded against false positives by requiring the affected dimension to be stable across two clean baselines. Catches CPDoS status flips and scheme/redirect poisoning. Adds one extra baseline request per vector
Web cache deception true Path-confusion tricks (/account/x.css) that fool the cache into storing a private page. Maps to WCVS -st deception,css skip removal
Allow CPDoS (cache-poisoned DoS) false Oversized-header / meta-char tests that can serve errors to all visitors. Only honored in the research profile - requires both this toggle and the research profile. Off by default
Cross-vantage revalidation false Re-confirm findings from a second network vantage. Requires extra egress infrastructure; off by default (infra-gated, hook only)

Performance & tuning (numeric / text fields)

Field Default Clamp What it does
Min confidence (0–1) 0.8 0–1 Only findings scoring at or above this become Vulnerability nodes. 0.8 keeps Confirmed + Strong and drops Tentative/Rejected
WCVS threads 10 1–50 Parallelism for the WCVS breadth sweep (-t). Higher = faster but louder for the target
Confirmation workers 6 1–16 How many URLs the native confirmation tests in parallel (bounds concurrent in-flight requests). Higher = faster; lower = stealthier; 1 = fully sequential. Vectors within a single URL are always confirmed sequentially
Max requests/sec per host 0 0–1000 Rate cap passed to WCVS (-rr). 0 = unlimited. Use a low value to stay under WAF thresholds
Custom cache header (optional) "" - Override the cache hit/miss header if the target uses a non-standard one (WCVS -ch). Leave blank to auto-detect
Silent-cache probe delay (seconds) 1.1 0.5–10 (only visible when Silent-cache detection is on) Wait between the two frozen-Date probes. Larger = more reliable on slow origins, but adds this much latency per silent URL

Settings present in the backend but not surfaced in this section

These are read from project settings / /defaults and tunable via presets or the API, but have no dedicated widget in the form:

Setting Default What it does
WEB_CACHE_POISON_DOCKER_IMAGE redamon-wcvs:latest The locally-built WCVS image to run
WEB_CACHE_POISON_TIMEOUT 1800 WCVS subprocess timeout, seconds. WCVS is thorough and slow (hundreds of requests/URL) - budget accordingly
WEB_CACHE_POISON_TIMEOUT_PER_REQ 10 Native confirmation per-request timeout, seconds
WEB_CACHE_POISON_CACHE_BUSTER_PARAM rdmncb The isolation cache-buster query-param name
WEB_CACHE_POISON_VERIFY_SSL true TLS verification during native confirmation

Safety model

Web cache poisoning is an active test; a careless probe can poison a production cache for real users. The controls below are enforced centrally:

  • Benign canaries only - markers are non-resolving (.invalid TLD), never live XSS or real attacker domains.
  • Isolated cache buckets - every test carries a unique cache-buster, so it lands in its own slot and never the real entry.
  • Three scan profiles - safe-confirm (production) · extended (owned test targets) · research (lab only).
  • CPDoS off by default - enabling it requires both the research profile and the Allow-CPDoS toggle.
  • WCVS safety skips - the runner always passes -st dos (suppress the oversized-header CPDoS probes) unless CPDoS is allowed, and -st deception,css unless deception is allowed; -stime always disables FP-prone time-based detection.
  • RoE-gated - out-of-scope hosts are filtered before any request fires.
  • Stealth mode disables the module entirely - active poisoning probes are loud and high-volume, incompatible with stealth/Tor. Stealth also force-disables CPDoS.
  • Disabled by default - opt-in master toggle.

Output structure

Results are stored under combined_result.cache_scan:

{
  "scan_metadata": {
    "scan_timestamp": "...", "duration_seconds": 12.3,
    "engine": "wcvs+native-confirm", "docker_image": "redamon-wcvs:latest",
    "scan_profile": "safe-confirm", "min_confidence": 0.8,
    "total_urls_scanned": 42, "cacheable_urls": 17, "wcvs_candidates": 5
  },
  "by_target": {
    "https://shop/home": {
      "oracle": { "cacheable": true, "indicator": "cf-cache-status", "signals": [...], "saw_hit": true },
      "findings": [ ... ]
    }
  },
  "findings": [ {
    "endpoint_url": "https://shop/home", "technique": "unkeyed_header",
    "vector_type": "header", "cache_header": "X-Forwarded-Host", "cache_param": "",
    "impact": "open_redirect", "confidence": 0.97, "confidence_tier": "Confirmed",
    "severity": "high", "cvss_score": 7.4, "cache_signals": ["x-cache: hit", "age: 12"],
    "cache_buster": "rdmncb=cb9f1a2b", "source_engine": "wcvs",
    "evidence": { "baseline_hash": "...", "poisoned_hash": "...",
                  "clean_validation_hash": "...", "poc_link": "...", "curl_verify": "..." }
  } ],
  "summary": { "total_findings": 1, "confirmed": 1, "strong": 0, "tentative": 0,
               "rejected": 0, "by_impact": {...}, "by_severity": {...},
               "urls_scanned": 42, "cacheable_urls": 17 }
}

This payload is consumed by the graph writer (graph_db/mixins/cache_mixin.py) and the report generator (webapp/src/lib/report/reportData.ts - adds a Web Cache Poisoning report section + risk score).


Graph schema

Consumes: BaseURL, Endpoint, Technology

Produces: Vulnerability (with source="cache_poisoning")

flowchart LR
    BU[("BaseURL")] -- "HAS_ENDPOINT" --> EP[("Endpoint")]
    EP -- "HAS_VULNERABILITY" --> V[("Vulnerability<br/>source=cache_poisoning")]
    BU -- "HAS_VULNERABILITY" --> V

    classDef n fill:#d9d9d9,stroke:#444,color:#000
    classDef v fill:#9e9e9e,stroke:#333,color:#000
    class BU,EP n
    class V v
Loading

Findings reuse the shared Vulnerability node (no new label) with source="cache_poisoning" and vulnerability_type="web_cache_poisoning". The mixin MERGEs the BaseURL/Endpoint on the fly, so a finding whose endpoint wasn't crawled still lands in a connected subgraph. The node id is deterministic and tenant-scoped, so re-scans dedupe via MERGE:

cache_{user_id}_{project_id}_{technique}_{baseurl}_{path}_{vector}   (sanitised)

Persisted properties: source, vulnerability_type, name, description, severity, cvss_score, confidence, confidence_tier, cache_header, cache_param, cache_vector_type, cache_impact, cache_technique, cache_buster, cache_signals[], source_engine (wcvs | hypothesis), cross_vantage, evidence (JSON), plus hoisted poc_link / curl_verify. A schema-contract guard logs a warning if the scanner ever emits a field the mixin doesn't map (a data-loss tripwire).

Example Cypher (what the AI agent can run):

MATCH (e:Endpoint)-[:HAS_VULNERABILITY]->(v:Vulnerability {source:'cache_poisoning'})
WHERE v.confidence_tier = 'Confirmed'
RETURN e.url, v.cache_header, v.cache_impact, v.confidence, v.poc_link

Partial recon

The same engine can be launched for a single phase from the workflow graph via the ▶ Run partial recon button or the workflow node's Play button.

flowchart TD
    M["Workflow graph · Play button"] --> MOD["PartialReconModal<br/>URL textarea + scope guard"]
    MOD --> API["POST /api/recon/projectId/partial"]
    API --> ORCH["recon-orchestrator<br/>spawns recon container"]
    ORCH --> PR["partial_recon.py → run_webcachepoison"]
    PR --> GB["build recon_data from graph<br/>BaseURLs + Endpoints · apex scope honored"]
    GB --> INJ["inject custom URLs"]
    INJ --> RUN["run_cache_scan"]
    RUN --> GW["update_graph_from_cache_scan + link user URLs"]

    classDef g fill:#e8e8e8,stroke:#555,color:#000
    class M,MOD,API,ORCH,PR,GB,INJ,RUN,GW g
Loading

Key points:

  • WEB_CACHE_POISON_ENABLED is force-set to true for the partial run (overrides the stored project toggle).
  • Targets come from the Neo4j graph (BaseURLs + Endpoints) plus any custom in-scope URLs from the modal.
  • The Include-Root-Domain scope toggle drops apex BaseURLs when root-domain scope is off.
  • settings_overrides from the modal bypass the stored project settings for that run.

See Recon Pipeline Workflow - Partial Recon for the modal layout.


Known limitations (be honest)

  • Single-shot poison. The native confirmer sends one poison request, then checks. On busy / short-TTL caches a poison may not reliably land before eviction - possible false negatives.
  • Differential blind spot. A non-reflective poison that only changes a response dimension the FP-guard marked untrusted (e.g. a body-diff on a page whose body legitimately flaps every request) is suppressed to avoid false positives - a deliberate recall-for-precision trade.
  • Deception is weak. True web-cache-deception needs an authenticated victim and a private-data-leak check; the poisoning-shaped loop tends to score it below threshold.
  • Framework packs go stale. x-invoke-status, __nextDataReq, etc. are version-specific maintenance surface.
  • No per-request rps throttle inside the native layer. Load is bounded by Confirmation workers (URLs in flight); only WCVS itself honors the rps setting.
  • WCVS is thorough and slow per URL (hundreds of requests/URL across techniques); budget the 1800 s default accordingly.

Related pages

Clone this wiki locally