0x55aa
← Back to Blog

Web Cache Poisoning: When Your CDN Becomes the Hacker's Megaphone 📢

6 min read

Web Cache Poisoning: When Your CDN Becomes the Hacker's Megaphone 📢

What if I told you there's an attack that lets a hacker modify your website for every visitor — and your server never processes a single malicious request?

No SQL injection. No stolen credentials. Just a hacker whispering something weird into your CDN's ear, and watching it shout the lie at thousands of users.

That's web cache poisoning. And it's sneakier than it sounds.

What Even Is a Cache? 🧠

Quick mental model before we dive in.

When you visit shop.example.com/products, your CDN (CloudFront, Cloudflare, Fastly — pick your poison) doesn't always call your origin server. If someone else visited that URL 30 seconds ago, the CDN cached the response and serves it instantly.

Faster page loads. Lower server costs. Marketing loves it. DevOps loves it.

Hackers? They really love it.

The Attack in Plain English 🎭

Here's the thing about caches: they don't just key on the URL.

In my experience building production systems with CloudFront, I've seen many headers flying around that the cache just... ignores when storing responses. Headers like X-Forwarded-Host, X-Original-URL, X-Rewrite-URL, or random debug headers the developers added during testing and never removed.

If your application reads those ignored headers and reflects them in the response body, you've got a problem.

The attack flow:

  1. Hacker sends a request with a weird, unkeyed header
  2. Your app uses that header to build a response (say, an inline script URL or redirect)
  3. CDN caches the poisoned response
  4. Every subsequent user gets the malicious version — no evil request needed from them

One evil request. Thousands of victims. Maximum efficiency. 😈

A Real-World Example 🔍

Here's the kind of code that gets you in trouble:

// BAD: Trusting unkeyed headers to build URLs
app.get('/api/config', (req, res) => {
  const host = req.headers['x-forwarded-host'] || req.headers['host'];

  res.json({
    apiBase: `https://${host}/api`,
    cdnUrl: `https://${host}/assets`,
    // This gets cached and served to everyone
  });
});

An attacker sends:

GET /api/config HTTP/1.1
Host: shop.example.com
X-Forwarded-Host: evil.attacker.com

Your server returns {"apiBase": "https://evil.attacker.com/api"}. CDN caches it. Now every user's frontend is pointed at the attacker's server. 🎯

The safe version:

// GOOD: Use environment config, not request headers
app.get('/api/config', (req, res) => {
  res.json({
    apiBase: process.env.API_BASE_URL,
    cdnUrl: process.env.CDN_URL,
    // Hard-coded from config, not user-controlled
  });
});

How I First Encountered This 🕵️

As someone passionate about security, I first came across cache poisoning while doing recon on a bug bounty program a few years back. The target had a JavaScript file served through CloudFront that included a dynamically generated analytics endpoint based on the request's X-Forwarded-Host header.

The header wasn't in the CDN's cache key configuration.

I submitted a poisoned request. Hit the URL from a fresh IP. Got my poisoned response back, cached and ready to serve. Reported it, they patched it in 48 hours, and I had a new story for security community meetups. 😄

In security communities, we often discuss how cache poisoning sits at the intersection of "technically brilliant" and "terrifyingly simple to execute." You don't need 0-days. You need curiosity and an HTTP client.

The Headers to Watch 👀

These are the usual suspects — headers that apps sometimes use but caches typically ignore:

Header Why It's Dangerous
X-Forwarded-Host Apps may use it to construct URLs
X-Original-URL Routing overrides that change responses
X-Rewrite-URL Same story
X-Custom-IP-Authorization Sometimes bypasses auth logic
Pragma: akamai-x-check-cacheable Debug headers left in production 🤦

Pro Tip: Run your app through a proxy (Burp Suite works great) and look for headers that change the response body without changing the cache key. Those are your treasure — or your liability.

Cache Poisoning vs. Cache Deception 🤔

People mix these up constantly. Let me save you the embarrassment:

  • Cache Poisoning: Attacker poisons the cache. Victims get the bad response.
  • Cache Deception: Attacker tricks the cache into storing a victim's private response. The attacker then retrieves it.

Same word, very different attack. Cache deception is often done by appending static file extensions to dynamic URLs: /account/profile.css. The cache stores it as a static file. Attacker fetches it. Your account data is now theirs.

Both are bad. Both are surprisingly common.

Defending Your Application 🛡️

1. Audit what headers you actually use in responses

// Laravel: Never trust these for generating URLs
// Bad
$host = request()->header('X-Forwarded-Host');

// Good - use configured URLs
$host = config('app.url');

2. Configure your CDN cache keys properly

In CloudFront, explicitly define what goes into your cache key. If you don't use X-Forwarded-Host in your origin responses, don't let it vary the cache.

CloudFront Cache Policy:
- Cache based on: Host header only
- Explicitly exclude: X-Forwarded-Host, X-Original-URL

3. Add Vary headers wisely

Vary: Accept-Language, Accept-Encoding
# NOT: Vary: X-Forwarded-Host (this helps attackers key the cache)

4. Separate your cacheable and dynamic content

Anything that reads request headers and reflects them? Don't cache it. Mark it:

Cache-Control: no-store, private

5. Use framework URL generation — not raw request data

// Node.js + Express: Use configured base URL
const baseUrl = process.env.APP_URL; // not req.headers.host

Real Talk: Is Your App Vulnerable? 💬

Search your codebase for these patterns:

# Find places that trust forwarded headers
grep -r "x-forwarded-host" src/ --include="*.js" -i
grep -r "X_FORWARDED_HOST" src/ --include="*.php" -i
grep -r "getClientIp\|originalUrl\|rewriteUrl" src/ -i

If those show up in code that generates URLs, script src attributes, redirect targets, or API responses — you need to review them carefully.

In my experience building production systems on AWS, the biggest issue I've seen is developers trusting the Host header in multi-tenant applications without validating it against an allowlist. CloudFront can send some surprising values depending on your distribution config.

The Fix Isn't Just Code 🔧

Cache poisoning is a design problem as much as a code problem.

  • Document which headers your app reads
  • Build an allowlist, not a blocklist
  • Test your CDN config as part of your security reviews
  • Periodically flush and verify cache contents in staging

Security communities have done incredible work mapping this attack surface. Tools like Param Miner (Burp extension) automate the discovery of unkeyed parameters. Run it against your staging environment. You might be surprised what you find.

TL;DR ⚡

  • Cache poisoning = one malicious request that harms all cached response consumers
  • Root cause: apps reflect unkeyed headers in cacheable responses
  • Never use raw X-Forwarded-Host or similar headers to build URLs or content
  • Configure CDN cache keys explicitly — exclude headers you don't use
  • Mark dynamic, header-dependent responses as no-store
  • Audit with Param Miner before attackers audit for you

Your CDN is one of your best performance tools. Don't let it become a megaphone for someone else's payload.


Got questions about cache security or AWS CDN configurations? Find me on LinkedIn — I'm always up for a good war story from the field. More security deep-dives on GitHub. 🔐