Exploiting Cacheable Responses
TL;DR
The main browsers share their cache between Fetch requests and normal navigation. This means that any request that can be made with Fetch, which produces a cacheable response, can also be used to poison the browser navigation too. Given the right set of circumstances, this approach can unlock a raft of unexploitable vulnerabilities, and make them practical.
Background
The idea for this research came about after sitting through lots of penetration test washups, where the “Cacheable Response” finding was skipped over as irrelevant. However, you may have noticed that I find it really satisfying to chain a bunch of low impact issues into something more substantial, so I added browser caching to my never-ending research list.
The result was that I noticed that Fetch shares the same cache as the regular navigation, with the cache key being based upon the URI and originating state. However, because Fetch is able to add headers to the request that can change the response (but which are not included in the cache key), an attacker can pre-request resources to poison the private cache, then force the browser to navigate to the page, which activates the attack.
To make this work requires two things:
the target endpoint must pass a pre-flight check, as adding headers to a Fetch request requires CORS; and
the Fetch response (or if a redirect, then both the redirect and response) must be cacheable by the browser, either explicitly (cache-control, pragma or expires headers), or implicitly (301/308 status code, or last-modified header).
As an interesting observation, the response does not always have to be made available to Fetch for the browser cache to be updated. For example, if the pre-flight passes, but the actual response does not have an access-control-allow-origin header, then it will be blocked due to CORS. But even so, the cache will still be updated successfully.
In the Red Corner
From an attack point of view, the reason that you should be interested in poisoning the browser cache, is that you can use it to leverage vectors that would otherwise be unexploitable, and even better, to use them to pivot laterally between sites that share the same eTLD.
However, before we go any further, if you’re not already familiar with the way that caching and CORS work, then I’d recommend reading the Mozilla caching and CORS guides first, which are good primers.
You may also be thinking that this is exactly the kind of situation that browser state partitioning should prevent. And you’d be partially right. Originally it was introduced as a privacy measure, to stop information leaking between states. And in the same way that it broke shared CDNs, it should also have stopped a lot of the cache poisoning too. However, it only partially mitigates it. There are some browser implementation bugs that mean that different states are incorrectly treated as the same, but the biggest issue is that the null state is universally equal. So, if the attack can be delivered from the null origin, then it can be happily poisoned and exploited cross-site. Yay!
Pre-request with Fetch
The crux of the issue is that Fetch and the normal navigation share the same cache, but requests made with Fetch can include headers which change the response, but not the cache key. Ooops.
For the pre-request step to work, any useful Fetch request (which typically triggers an exploit using headers), will first need to pass the CORS preflight check. After this, the actual response will need to be cacheable. However, if the response itself does not pass CORS, then it will end up being cached anyway (after all it was successful at the HTTP layer). The detail varies per browser brand, so you’ll need to verify this manually.
And that’s it. All you need is to follow-up with a normal navigation to load the content from cache and activate it within the target DOM. Pow!
Putting it all Together
In practice (once you’ve found a target that meets all the requirements), the actual delivery ends up being really simple. For some browsers, you’ll need to wrap the below in an iframe, to force the state partition check to work.
window.nonce = Math.random( ).toString( 36 ).substr( 2, 5 )
fetch( 'http://a.gvj.io/200?n=' + window.nonce, {
method: 'GET',
mode: 'cors',
cache: 'reload',
redirect: 'follow',
credentials: 'include',
headers: {
'x-original-url': '/poisoned'
}
} )
.catch( ( ) => { } )
.then( ( ) => {
setTimeout( function( ) {
window.location.replace( 'http://a.gvj.io/200?n=' + window.nonce );
}, 200 );
} );
Browser Test Cases
Although it seems like there are a lot of browsers to choose from, there are basically three: the Chromium variants, Firefox, and Safari. Obviously, they all implemented state partitioning slightly differently, and so also respond differently to cache poisoning. On top of that, in the months since I first notified the vendors of this bug, the teams have also been merging patches that subtly change the caching behaviour.
So, if you want to know if a particular version of a browser is vulnerable, the test cases below should give you a definitive answer. These are divided by direct and iframe (forcing a null origin), then further subdivided by same-origin, same-site and cross-site states:
In the Blue Corner
As all the magic is happening in the browser, it might seem that there isn’t a lot you can do to prevent this kind of attack. However, there are a few things you can try.
The first is to ensure that dynamically generated content isn’t being cached when it shouldn’t be (remember that “cacheable response” finding in the report? It’s time to triage those). Then if a response really must be cached, you can explore the use of the vary header, which will force the cache key to include any headers of your choosing. Using this approach, you can effectively make Fetch and the normal navigation use separate cache keys.
Then from a detection perspective, for an attacker to find a vulnerable target typically requires scanning for broken CORS, which you can detect in your SIEM (look out for large volumes of failed OPTIONS requests).
Additional Information
https://fetch.spec.whatwg.org/#http-cache-partitions
https://developer.mozilla.org/en-US/docs/Web/Privacy/State_Partitioning
https://developer.mozilla.org/en-US/docs/Web/HTTP/Caching
https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2024-1554
“Thanks”
I just wanted to say a big “thanks” to the teams from Firefox, Google and Apple, who were efficient, professional and a complete pleasure to work with. Love you long-time!