The Redirect It Only Half Checked: Stealing an OAuth Code
The challenge
Loomhaus signs you in with OAuth. When it builds the sign-in link it checks that the redirect_uri you asked for belongs to the app, and its check is that the value starts with the registered address https://app.loomhaus.example. The authorization server will send the one-time code to whatever redirect_uri survives that check. A victim has already clicked a link you prepared; the attacker's logging endpoint recorded what arrived. Craft the redirect_uri that passes the check but delivers the code to the attacker, then read the code it captured.
What you'll learn
- Explain why hostnames are read right to left and a prefix match is not an origin check
- Build a redirect_uri that passes startsWith yet points at an attacker host
- Describe how a leaked authorization code leads to account takeover
- State why exact-match allowlists are the only safe redirect_uri validation
- Explain how PKCE makes a stolen code non-redeemable
Skills tested
Prerequisites
- The OAuth authorization-code step at a high level
- How a URL is structured
How it works
In the authorization-code flow the server sends a one-time code to the redirect_uri the client asked for, so validating that URI is the whole security of the step. If an attacker can get the code delivered to a host they control, they can finish the sign-in as the victim.
A check of does it start with our address looks reasonable and is not. https://app.loomhaus.example is the literal prefix of https://app.loomhaus.example.evil-capture.example, where the real app name has become the leftmost labels of a domain the attacker registered. Hostnames are compared right to left; a left-anchored string test ignores that entirely.
The only sound validation is an exact match against pre-registered, fully qualified redirect URIs. startsWith, endsWith and contains each fail to a differently shaped host.
Common mistakes
- Using a bare attacker host.
https://evil-capture.example/cbdoes not start with the registered address, so the check rejects it. - Dropping the victim cookie. The authorize endpoint only issues a code for a signed-in session; the request already carries one.
- Answering with the redirect URL. The prize is the
codevalue in the attacker log, not the location. - Assuming the code is harmless. Without PKCE binding, whoever holds the code can exchange it.
How to defend against it
Validate the redirect target exactly, and bind the code to the client.
- Match
redirect_uriagainst an allowlist of exact, fully qualified URIs registered for the client. Never usestartsWith,endsWithor substring tests. - Require PKCE: the code can only be exchanged by the party that holds the original verifier, so a stolen code is useless.
- Scope and shorten code lifetimes, and alert on authorize requests whose redirect host is not the registered one.