FlawOpen
● CWE-601 / CWE-352

OAuth 2.0 redirect_uri 攻击:授权码窃取与登录 CSRF

授权服务器对 redirect URI 校验过宽,或已注册的 callback 存在开放重定向,都可能暴露授权码。能否利用仍取决于授权码绑定和令牌端点检查;登录流程缺少绑定还可能单独导致 login CSRF。

💡 通俗解释

把授权服务器想成可信的邮局:它只会把一次性代码寄到应用预先登记的回邮地址。如果地址匹配规则过于宽松,或已登记的端点会把访客转到任意网站,代码就可能被带走。截获代码并不自动获得访问权限:攻击者仍须通过客户端的令牌交换检查,包括适用时的 PKCE 和客户端认证。另一类 login CSRF 问题则是网站无法确认浏览器是否发起了当前正在完成的登录。

核心概念与关键术语

授权码
通过浏览器重定向返回的短时凭据。客户端在令牌端点兑换该代码;它与客户端和 redirect URI 绑定,使用 PKCE 时还与 verifier 绑定。
redirect_uri 精确匹配
RFC 9700 要求授权服务器将请求的 redirect URI 与预登记 URI 按字符串精确比较。原生应用的 localhost 重定向有一个范围很窄的动态端口例外。
state 与 login CSRF
将不可预测的值绑定到浏览器会话,并在 callback 校验,可确认响应对应用户发起的登录。强制执行的 PKCE 也能提供 CSRF 防护;两者可以同时使用以纵深防御。
PKCE(Proof Key for Code Exchange)
客户端创建秘密 verifier,并在授权请求中发送其派生 challenge。令牌端点要求提供 verifier 才能兑换代码,从而降低公共客户端和机密客户端的代码拦截风险。

重定向与登录 CSRF 攻击步骤

Path A · 1

授权服务器接受了不安全的 redirect URI

有缺陷的授权服务器使用通配符、前缀或子串规则,而不是将请求的 redirect_uri 与登记值精确比较。RFC 9700 要求精确匹配,仅为原生应用的 localhost 端口保留特定例外。

Path A · 2

已登记端点会跳转到不可信网站

被接受的 URI 可能指向客户端控制的开放重定向,例如 https://app.example/redirect?next=...。这将授权服务器的宽松校验与应用中独立的 CWE-601 弱点组合起来。

Path A · 3

浏览器在跳转过程中携带授权码

用户授权后,授权服务器把代码返回到被接受的 URI。如果开放重定向继续转发请求并保留代码,攻击者可能截获它。授权码本身不是 bearer 访问令牌。

Path A · 4

兑换令牌仍须通过安全检查

攻击者只有满足令牌端点检查,才能使用截获的代码。没有原始 verifier 时,PKCE 会阻止兑换;机密客户端认证和 redirect URI 匹配还提供额外绑定。

Path B

响应未绑定登录尝试时会出现 login CSRF

如果 callback 接受未关联当前浏览器登录尝试的授权响应,攻击者可能诱使受害者完成与攻击者账户关联的登录。这是账户混淆,不代表攻击者自动进入受害者账户。

源代码:漏洞模式与安全实现

易受攻击的模式
# VULNERABLE: user input is embedded in redirect_uri; state and PKCE are absent.
from urllib.parse import urlencode
from flask import Flask, request, redirect

app = Flask(__name__)
AUTHORIZATION_ENDPOINT = "https://oauth-provider.example/authorize"

@app.route("/auth/login")
def oauth_login():
    next_url = request.args.get("next", "")
    # The callback is an open redirect, and this URI varies with user input.
    redirect_uri = "https://app.example/redirect?next=" + next_url
    params = {
        "client_id": "example-client",
        "response_type": "code",
        "redirect_uri": redirect_uri,
    }
    # Exploitation also requires the authorization server to accept this URI
    # under an unsafe wildcard or prefix rule.
    return redirect(AUTHORIZATION_ENDPOINT + "?" + urlencode(params))

@app.route("/auth/callback")
def oauth_callback():
    # No state check ties this response to the browser's login attempt.
    code = request.args.get("code")
    return exchange_code_for_tokens(code)  # no PKCE verifier provided
加固后的防护
# SECURE: fixed registered redirect URI, session-bound state, and PKCE.
import base64
import hashlib
import secrets
from urllib.parse import urlencode
from flask import Flask, request, redirect, session, abort

app = Flask(__name__)
AUTHORIZATION_ENDPOINT = "https://oauth-provider.example/authorize"
REDIRECT_URI = "https://app.example/auth/callback"  # exact registered value
CLIENT_ID = "example-client"

@app.route("/auth/login")
def oauth_login():
    state = secrets.token_urlsafe(32)
    verifier = secrets.token_urlsafe(64)
    challenge = base64.urlsafe_b64encode(
        hashlib.sha256(verifier.encode("ascii")).digest()
    ).rstrip(b"=").decode("ascii")
    session["oauth_state"] = state
    session["pkce_verifier"] = verifier
    params = {
        "client_id": CLIENT_ID,
        "response_type": "code",
        "redirect_uri": REDIRECT_URI,
        "state": state,
        "code_challenge": challenge,
        "code_challenge_method": "S256",
    }
    return redirect(AUTHORIZATION_ENDPOINT + "?" + urlencode(params))

@app.route("/auth/callback")
def oauth_callback():
    expected = session.pop("oauth_state", None)
    if not expected or not secrets.compare_digest(
        request.args.get("state", ""), expected
    ):
        abort(403, "Invalid OAuth state")

    code = request.args.get("code")
    verifier = session.pop("pkce_verifier", None)
    if not code or not verifier:
        abort(400, "Missing authorization response")

    # The token request must use this exact redirect_uri and verifier.
    tokens = exchange_code_for_tokens(
        code=code, redirect_uri=REDIRECT_URI, code_verifier=verifier
    )
    session["user_id"] = tokens["user_id"]
    return redirect("/dashboard")

工程加固检查清单

← 浏览完整安全目录 查看漏洞实战指南 → RFC 9700:OAuth 2.0 安全最佳实践 → RFC 7636:Proof Key for Code Exchange (PKCE) → RFC 6749:授权码重定向 URI 操纵 → CWE-601:开放重定向 → CWE-352:跨站请求伪造 →