Exploiting a bad implementation of OAuth2
In this post I'm going to share how I exploited a bad implementation of OAuth2 to take over user accounts with a single click.
OAuth2
Whenever you see a log in button that says "Log in with Google" or "Log in with Facebook", that's OAuth2 in action. A common OAuth2 flow looks like this:
User goes to
https://example.com
and clicks "Log in with Google".The user is redirected to Google's login/authorization page (
https://accounts.google.com/o/oauth2/auth?client_id=1234&scope=email&state=4321
).The user logs in and authorizes the application.
Google redirects the user back to the application (callback URL) with an authorization code and state parameter (
https://example.com/login/callback?code=5678&state=4321
).The application exchanges the authorization code for an access token (usually done server-side).
The application uses the access token to access the user's resources (profile, email, etc).
The state parameter
When the user is redirected to the OAuth2 provider (e.g., Google), the application can include a state parameter in the URL (as you can see in step 2 above), which is then returned by the OAuth2 provider in the callback URL (as you can see in step 4 above).
What's the point of returning the same value in the callback URL? you might ask. The state parameter is used to prevent CSRF attacks, in other words, it ensures that the user who initiated the OAuth2 flow is the same user who completed it, preventing an attacker from tricking a user into logging in with a different account (login CSRF).
In order to do that, the application must verify that the state parameter in the callback URL matches the one it sent. The state should be unique for each OAuth2 flow, hard to guess, and bound to the user that initiated the flow, as the OAuth2 RFC states.
The vulnerability
The site was generating a random state value for each OAuth2 flow, and correctly verifying it later in the callback URL. But there was a small problem, the state wasn't bound to the user that initiated the flow.
The OAuth2 flow looked like this:
User starts the OAuth2 flow.
The application generates a random value and stores it in the database.
The application redirects the user to the OAuth2 provider with the state parameter.
The OAuth2 provider redirects the user back to the application with the state parameter.
The application retrieves the state parameter from the URL and checks that the value exists in the database but doesn't check which user it belongs to.
This allows for an attacker to start an OAuth2 flow and then trick the victim into completing it, leading to a login CSRF attack.
From login CSRF to account takeover
The bad implementation of the state parameter allows for a login CSRF attack, but depending on the application, this type of attack might not be very impactful. But the application had the following behavior:
If the user isn't logged in and starts the OAuth process with a new provider, a new account is created and linked to that provider.
If the user is logged in and starts the OAuth process with a new provider, that provider is linked to the existing account. Allowing the user to log in with any of the linked providers.
If the user, logged in or not, starts the OAuth process with a provider already linked to another account, the application logs the user out, and logs him into the account linked to that provider.
If an attacker tricks the victim into completing the OAuth2 flow from another provider while logged in, the attacker can link the victim's account to its own.
Exploitation
Let's say the application allows users to log in with Google and Facebook, and the victim is logged in with Facebook. The attacker starts the OAuth2 flow with Google but doesn't complete it; instead, it intercepts the response. The attack would look like this:
The attacker goes to
https://example.com
and clicks "Log in with Google".The attacker is redirected to Google's login/authorization page with a state parameter generated by the application (e.g.,
https://accounts.google.com/o/oauth2/auth?client_id=1234&scope=email&state=c3VwZXItc2VjcmV0
).The attacker logs in and authorizes the application.
Google redirects the attacker back to the application with an authorization code. But the attacker intercepts the response and doesn't let the browser follow the redirect. The intercepted redirect would look like this:
https://example.com/login/callback?code=12345&state=c3VwZXItc2VjcmV0
.Since the redirect wasn't followed, the application didn't create a new account linked to Google, but it kept track of the generated state in the database, waiting to be validated.
While logged in with his Facebook account, the victim follows the attacker's link.
The application retrieves the state parameter from the URL and checks that the value exists in the database.
Then it uses the code from the URL to exchange it for an access token of the attacker's Google account.
Since the Google account doesn't exist in the application and the victim is already logged in, the application will link the victim's account to the attacker's Google account.
The attacker can now log in with his Google account and gain access to the victim's account.
Mitigation
To prevent this, the application should bind the state parameter to the user who initiated the OAuth2 flow. This can be done by storing the state parameter in the user's session instead of a separate database. This way, the intercepted state from one user wouldn't be valid for another user.
Timeline
12/02/2024: Reported the vulnerability to the company.
13/02/2024: The company acknowledged the report.
28/02/2024: The company asked for clarification on how the vulnerability could be exploited.
01/03/2024: The company confirmed the vulnerability and awarded a bounty of $1000.
13/03/2024: The vulnerability was fixed.
Comments
Comments powered by Disqus