There are a few well known DOM APIs that leak cross origin information.
The Window DOM API documents how to traverse across cross origin windows (under other browsing contexts). One of these is the number of frames in the document (window.length).
let win /*Any Window reference, either iframes, opener, or open()*/; win.frames.length;
In some cases, different states have the same number of frames, preventing us from classifying them correctly.
In those cases, you can try continuously recording the frame count, as it can lead to a pattern you might be able to use, either for timing certain milestones or detecting anomalies in the frame count during the application loading time.
const tab = window.opener; // Any Window reference const pattern = []; tab.location = 'https://target'; const recorder = setInterval(() => pattern.push(tab.frames.length), 0); setTimeout(() => { clearInterval(recorder); console.log(pattern); }, 6 * 1000);
The History DOM API documents that the history object can know how many entries there are in the history of the user. This leak can be used to detect when a cross-origin page had some types of navigations (eg, those via history.pushState or just normal navigations).
Note that for detecting navigations on pages that can be iframed, it is possible to just count how many times the onload event was triggered (see Frame timing), in cases when the page can't be inside a frame, then this mechanism can be useful.
history.length; // leaks if there was a javascript/meta-refresh redirect
For most HTML elements that load subresources have error events that are triggered in the case of a response error (eg, error 500, 404, etc) as well as parsing errors.
One can abuse this, in two ways:
One way to "force" an error when fetching a subresource (unless cached), is by forcing the server to reject the request based on data that isn't part of the cache key. There are several ways to do this, for example:
Since the browser would only issue an HTTP request if there isn't something already in the cache, then one can notice that:
Cache probing is a well known attack, and some browsers have been looking into having separate cache storage for each origin, but no other solution is currently available.
For demonstration purposes, here is some example code using overlong HTTP referrer.
<iframe id=f></iframe> <script> (async ()=>{ let url = 'https://otherwebsite.com/logo.jpg'; // Evict this from the cache (force an error). history.replaceState(1,1,Array(16e3)); await fetch(url, {cache: 'reload', mode: 'no-cors'}); // Load the other page (you can also use <link rel=prerender>) // Note that index.html must have <img src=logo.jpg> history.replaceState(1,1,'/'); f.src = 'http://otherwebsite.com/index.html'; await new Promise(r=>{f.onload=r;}); // Check if the image was loaded. // For better accuracy, use a service worker with {cache: 'force-cache'} history.replaceState(1,1,Array(16e3)); let img = new Image(); img.src = url; try { await new Promise((r, e)=>{img.onerror=e;img.onload=r;}); alert('Resource was cached'); // Otherwise it would have errored out } catch(e) { alert('Resource was not cached'); // Otherwise it would have loaded } })(); </script>
Images, Videos, Audio and a few other resources allow for measuring their duration (in the case for video and audio) and size (for images).
For timing we have to consider two factors:
To defend against these attacks, browsers try to limit the amount of information leaked across windows/origins, and in some cases, also try to limit the accuracy of different mechanisms for measuring time.
The most common used mechanisms for measuring time are:
This type of measurement can be mitigated with same-site cookies in strict mode (for GET requests), or in lax mode (for POST requests). Using same-site cookies in lax mode is not safe, as it can be bypassed by timing navigation requests.
let before = performance.now() await fetch("//mail.com/search?q=foo") let request_time = performance.now() - before
In chrome the number of HTTP requests made by another window/document can be calculated using the network pool. To do this, the attacker needs two windows/documents.
Window A:
Window B:
These techniques are used for measuring the time it takes a navigation request to load.
This is useful for measuring the time it takes a GET request to load if protected by same-site cookies in lax mode. This can be mitigated with same-site cookies in strict mode.
This mechanism waits until all subresources finish loading. Note that in pages that set the X-Frame-Options
header, this mechanism can only be used for measuring the network request, because subresources are not measured. Note that the difference between onerror
and onload
is often also important, as well as the number of times each event is triggered, as that reveals how many navigations happened inside the iframe.
<iframe name=f id=g></iframe> <script> h = performance.now(); f.location = '//mail.com/search?q=foo'; g.onerror = g.onload = ()=>{ console.log('time was', performance.now()-h) }; </script>
This mechanism is only useful when a page uses X-Frame-Options
and one is interested on the subresources being loaded, or in the javascript code executing for other attacks (such as establishing the starting time for cross-document request timing or multi-threaded JavaScript).
To protect against this types of attacks one might be able to use Cross-Origin-Opener-Policy in the future.
let w=0, z=0, v=performance.now(); onmessage=()=>{ try{ if(w && w.document.cookie){ // still same origin } postMessage('','*'); }catch(e){ z=performance.now(); console.log('time to load was', v-z); } }; postMessage('','*'); w=open('//www.google.com/robots.txt');
Measuring JavaScript execution can be useful for understanding when certain events are triggered, and how long some operations take.
Examples:
In browsers other than Chrome, all JavaScript code (even cross-origin) runs in the same thread, which means that one can measure for how long code runs in another origin by measuring how long it takes for code to run next in the event pool.
In Chrome, every site runs in a different process, and every process has their own thread, which means that in order to measure the timing of JavaScript execution in another thread, we have to measure it in a different way. One way to do this is by:
Some times considered a vulnerability by browsers, and some times measured with timing. Regardless, it is some times possible to (incidentally) defend against this types of attacks by using CORB, and CORP. As their implementation also breaks some of the APIs.
Examples:
Current public mechanism to learn size of cross-site requests is with Flash.
By abusing the Cache API and the quota a single origin receives, it's possible to measure the size of a single response. To protect against this attack browsers add random noise to the quota calculation.
One can still perform the attack with the noise added, although it requires a lot more requests.
By abusing the Cache API, and the browser's cache, one can measure how long it takes for a simple request to be loaded from the different levels of caching. Assuming a longer response will take longer to load. By abusing techniques (such as "inflating" the response size), one can make the difference through timing more measurable.
If one can trigger and detect an XSS filter false positive, then one can figure out the presence of a specific element. This means that if it is possible to detect whether the filter triggered or not, then we can detect any difference in the elements blocked by XSS filters across two pages. It is easier to detect the XSS filter when it is enabled in blocking mode, as that blocks the loading of the page and all its subresources, making all browser side channels more obvious.
One way to detect the XSS filter (in blocking mode) has triggered can be done by counting the number of times a navigation happens when changing the location.hash
.
X-Frame-Options
), then one can count how many times the load event happened after a navigation to the same URL with a different location.hash
. If the XSS filter triggered, then the number will be 2, otherwise it will be 1.location.hash
changes don't trigger network requests, then by navigating the page to a URL with a different location.hash
, then navigating it to about:blank
, then triggering history.back()
, if that triggers a network requesthistory.length
. By changing the location of another window quickly, before the browser has a chance to make a navigation, but enough time to change the location.hash
, one can count how many entries exist in the history.length
(3 for when the filter did not trigger, and 2 when it did).Example code for history length attack.
let url = '//victim/?falsepositive=<script>xxxxx=1;'; let win = open(url); // Wait for the window to be cross-origin await new Promise(r=>setInterval(()=>{try{win.origin.slice()}catch(e){r(e)}},1)); // Change the location win.location = url + '#'; // Skip one microtask await Promise.resolve(1); // Change the location to same-origin win.location = 'about:blank'; // Wait for the window to be same-origin await new Promise(r=>setInterval(()=>r(win.document.defaultView),1)); // See how many entries exist in the history if (win.history.length == 3) { // XSS auditor did not trigger } else if (win.history.length == 2) { // XSS auditor triggered }
Some endpoints respond with a content disposition header set to "attachment", forcing the browser to download the response as a file. In some cases, the ability to detect whether or not a file was downloaded on a certain endpoint can leak information about the current user.
When a Chromium based browser downloads a file, a bottom bar is integrated into the browser window. By monitoring the window height we could detect whether or not the "downloads bar" opened.
// Any Window reference (can also be done using an iframe in some cases) const tab = window.opener; // The current window height const screenHeight = window.innerHeight; // The size of the chrome download bar on mac os x const downloadsBarSize = 49; tab.location = 'https://target'; setTimeout(() => { let margin = screenHeight - window.innerHeight; if (margin === downloadsBarSize) { return console.log('downloads bar detected'); } }, 5 * 1000);
Another way to test for the content-disposition: attachment header is to check if a navigation redirected the page. At least in Chrome, if a page load triggers a download, it will not trigger the navigation.
The leak will work roughly like this:
The Object DOM API documents that the object
element can be loaded depending on the Content-type
header.
The
typemustmatch
attribute is a boolean attribute whose presence indicates that the resource specified by thedata
attribute is only to be used if the value of the type attribute and theContent-Type
of the aforementioned resource match.
Currently, the Chromium-based browsers don't support the attribute typemustmatch
but the Firefox does.
This functionality can be used to determine whether the response has the Content-type: text/html
because if the embedded object was loaded successfully the number of frames will increase.
Worth to mention, typemustmatch
also ensures that the server responded with a 200 OK
header or the resource won't be loaded otherwise. Hence, it is possible to detect error pages as well.
Moreover, if the object wasn't loaded its height and width equal to 0 and is greater than it otherwise. That allows detecting any content-type of the response and distinguish between error pages.
let url = 'https://example.org' let mime = 'application/json' let x = document.createElement('iframe'); x.src = `data:text/html,<object id=obj type="${mime}" data="${url}" typemustmatch><script>onload = ()=>{console.log(obj.clientHeight)}%3c/script></object>`; document.body.appendChild(x);