Navigations

Navigations

October 1, 2020

Detecting if a cross-site page triggered a navigation (or didn’t) can be useful to an attacker. For example, a website may trigger a navigation in a certain endpoint depending on the status of the user.

To detect if any kind of navigation occurred, an attacker can:

  • Use an iframe and count the number of times the onload event is triggered.
  • Check the value of history.length, which is accessible through any window reference. This provides the number of entries in the history of a victim that were either changed by history.pushState or by regular navigations. To get the value of history.length, an attacker changes the location of the window reference to the target website, then changes back to same-origin, and finally reads the value. Run demo

Download Trigger #

When an endpoint sets the Content-Disposition: attachment header, it instructs the browser to download the response as an attachment instead of navigating to it. Detecting if this behavior occurred might allow attackers to leak private information if the outcome depends on the state of the victim’s account.

Download Navigation (with iframes) #

Another way to test for the Content-Disposition: attachment header is to check if a navigation occurred. If a page load causes a download, it does not trigger a navigation and the window stays within the same origin. Run demo

In the snippet below , we’ve added a sandboxed iframe with downloads disabled to prevent a download modal from appearing.

// Set the destination URL to test for the download attempt
var url = 'https://example.org/';
// Create an outer iframe to measure onload event
var iframe = document.createElement('iframe');
// Don't actually download the file to be stealthy
iframe.sandbox = 'allow-scripts allow-same-origin allow-popups';
document.body.appendChild(iframe);
// Create an inner iframe to test for the download attempt
iframe.srcdoc = `<iframe src="${url}" ></iframe>`;
iframe.onload = () => {
      try {
          // If a navigation occurs, the iframe will be cross-origin,
          // so accessing "inner.origin" will throw an exception
          iframe.contentWindow.frames[0].origin;
          console.log('Download attempt detected');
      } catch(e) {
          console.log('No download attempt detected');
      }
}

info

When there is no navigation inside an iframe caused by a download attempt, the iframe does not trigger an onload event directly. For this reason, in the example above, an outer iframe was used instead, which listens for an onload event which triggers when subresources finish loading, including iframes.

important

This attack works regardless of any Framing Protections, because the X-Frame-Options and Content-Security-Policy headers are ignored if Content-Disposition: attachment is specified.

Download Navigation (without iframes) #

A variation of the technique presented in the previous section can also be effectively tested using window objects. In the snippet below, we’ve added a sandboxed iframe with disabled downloads to prevent a download modal from appearing.

// Set the destination URL
var url = 'https://example.org';

// Don't actually download the file to be stealthy
var iframe = document.createElement('iframe');
iframe.sandbox = 'allow-scripts allow-same-origin allow-popups';
document.body.appendChild(iframe);

// Get a window reference
var win = iframe.contentWindow.open(url);

// Wait for the window to load.
setTimeout(() => {
      try {
          // If a navigation occurs, the iframe will be cross-origin,
          // so accessing "win.origin" will throw an exception
          win.origin;
          parent.console.log('Download attempt detected');
      } catch(e) {
          parent.console.log('No download attempt detected');
      }
}, 2000);

Server-Side Redirects #

Max redirects #

When a page initiates a chain of 3XX redirects, browsers limit the maximum number of redirects to 20 1. This can be used to detect the exact number of redirects occurred for a cross-origin page by following the below approach 2:

  1. As a malicious website, initiate 19 redirects and make the final 20th redirect to the attacked page.
  2. If the browser threw a network error, at least one redirect occurred. Repeat the process with 18 redirects.
  3. If the browser didn’t threw a network error, the number of redirects is known as 20 - issued_redirects.

To detect an error one can use Error Events

If performed in a top window, this also works with SameSite lax cookies and other cross-site protections, such as Framing Isolation Policy or Resource Isolation Policy. Run demo

Inflation (Server-Side Errors) #

A server-side redirect can be detected from a cross-origin page if the destination URL increases in size and contains an attacker-controlled input (either in the form of a query string parameter or a path). The following technique relies on the fact that it is possible to induce an error in most web-servers by generating large request parameters/paths. Since the redirect increases the size of the URL, it can be detected by sending exactly one character less than the server’s maximum capacity. That way, if the size increases, the server will respond with an error that can be detected from a cross-origin page (e.g. via Error Events).

example

An example of this attack can be seen here.

Inflation (Client-Side Errors) #

Most browsers have a maximum permitted URL length, above which the navigation will be aborted. For example, Chrome limits URLs to a maximum length of 2MB 3. When this limit is exceeded, the browser may exhibit behavior that can be detected from a cross-origin page. The exact URL length limit and oracle behavior depends on the browser.

When requesting a URL with a fragment, the fragment is preserved on server redirects. For example, if //example.org redirects to //example.org/redirected, the browser will navigate to //example.org/redirected#fragment when //example.org#fragment is requested. This allows an attacker to artificially inflate the URL length by adding a large fragment to the URL so that it is exactly one character less than the maximum permitted length.

example

Chrome will navigate to an about:blank page if the URL length is exceeded. An attacker can detect whether a redirect occurred by checking if the page is still on the same origin.

Cross-Origin Redirects #

CSP Violations #

Content-Security-Policy (CSP) is an in-depth defense mechanism against XSS and data injection attacks. When a CSP is violated, a SecurityPolicyViolationEvent is thrown. An attacker can set up a CSP using the connect-src directive which triggers a Violation event every time a fetch follows an URL not set in the CSP directive. This allows an attacker to detect if a redirect to another origin occurred 4 5. Run demo

The example below triggers a SecurityPolicyViolationEvent if the website set in the fetch API (line 6) redirects to a website other than https://example.org:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
<!-- Set the Content-Security-Policy to only allow example.org -->
<meta http-equiv="Content-Security-Policy"
      content="connect-src https://example.org">
<script>
// Listen for a CSP violation event
document.addEventListener('securitypolicyviolation', () => {
  console.log("Detected a redirect to somewhere other than example.org");
});
// Try to fetch example.org. If it redirects to another cross-site website
// it will trigger a CSP violation event
fetch('https://example.org/might_redirect', {
  mode: 'no-cors',
  credentials: 'include'
});
</script>

When the redirect of interest is cross-site and conditioned on the presence of a cookie marked SameSite=Lax, the approach outlined above won’t work, because fetch doesn’t count as a top-level navigation. In a case like this, the attacker can use another CSP directive, form-action, and leverage the fact that submitting a HTML form using GET as its method does count as a top-level navigation.

The example below triggers a SecurityPolicyViolationEvent if the form’s action (line 3) redirects to a website other than https://example.org:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
<!-- Set the Content-Security-Policy to only allow example.org -->
<meta http-equiv="Content-Security-Policy"
      content="form-action https://example.org">
<form action="https://example.org/might_redirect"></form>
<script>
// Listen for a CSP violation event
document.addEventListener('securitypolicyviolation', () => {
  console.log("Detected a redirect to somewhere other than example.org");
});
// Try to get example.org via a form. If it redirects to another cross-site website
// it will trigger a CSP violation event
document.forms[0].submit();
</script>

Note that this approach is unviable in Firefox (contrary to Chromium-based browsers) because form-action doesn’t block redirects after a form submission in that browser.

Case Scenarios #

An online bank decides to redirect wealthy users to attractive stock opportunities by triggering a navigation to a reserved space on the website when these users consult their account balance. If this is only done for a specific group of users, it becomes possible for an attacker to leak the “client status” of the user.

Partitioned HTTP Cache Bypass #

If a site example.com includes a resource from *.example.com/resource then that resource will have the same caching key as if the resource was directly requested through top-level navigation. That is because the caching key is consisted of top-level eTLD+1 and frame eTLD+1. 6

Because a window can prevent a navigation to a different origin with window.stop() and the on-device cache is faster than the network, it can detect if a resource is cached by checking if the origin changed before the stop() could be run.

async function ifCached_window(url) {
  return new Promise(resolve => {
    checker.location = url;

    // Cache only
    setTimeout(() => {
      checker.stop();
    }, 20);

    // Get result
    setTimeout(() => {
      try {
        let origin = checker.origin;
        // Origin has not changed before timeout.
        resolve(false);
      } catch {
        // Origin has changed.
        resolve(true);
        checker.location = "about:blank";
      }
    }, 50);
  });
}

Create window (makes it possible to go back after a successful check)

let checker = window.open("about:blank");

Usage

await ifCached_window("https://example.org");

info

Partitioned HTTP Cache Bypass can be prevented using the header Vary: Sec-Fetch-Site as that splits the cache by its initiator, see Cache Protections. It works because the attack only applies for the resources from the same site, hence Sec-Fetch-Site header will be cross-site for the attacker compared to same-site or same-origin for the website.

Defense #

Attack AlternativeSameSite Cookies (Lax)COOPFraming ProtectionsIsolation Policies
history.length (iframes)✔️✔️FIP
history.length (windows)✔️NIP
onload event inside an iframe✔️✔️FIP
Download bar✔️\(^{1}\)NIP
Download Navigation (iframes)✔️ \(^{1}\)FIP
Download Navigation (windows) \(^{1}\)NIP
Inflation (Server-Side Errors)✔️RIP
Inflation (Client-Side Errors)NIP
CSP Violations \(^{2}\)RIP 🔗 NIP

🔗 – Defense mechanisms must be combined to be effective against different scenarios.


  1. Neither COOP nor Framing Protections helps with the mitigation of the redirect leaks because when the header Content-Disposition is present, other headers are being ignored.
  2. SameSite cookies in Lax mode could protect against iframing a website, but won’t help with the leaks through window references or involving server-side redirects, in contrast to Strict mode.

Real-World Examples #

A vulnerability reported to Twitter used this technique to leak the contents of private tweets using XS-Search. This attack was possible because the page would only trigger a navigation if there were results to the user query 7.

References #


  1. HTTP-redirect fetch, link ↩︎

  2. XS-Leaks in redirect flows, link ↩︎

  3. Chromium Docs - Guidelines for URL Display, link ↩︎

  4. Disclose domain of redirect destination taking advantage of CSP, link ↩︎

  5. Using Content-Security-Policy for Evil, link ↩︎

  6. github.com/xsleaks/wiki/pull/106 ↩︎

  7. Protected tweets exposure through the url, link ↩︎