●
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")
工程加固检查清单
- 配置授权服务器,将
redirect_uri与预登记 URI 按字符串精确比较。仅保留 RFC 9700 为原生应用 localhost 端口规定的例外。 - 不要把用户可控的目标地址放进 OAuth callback URI。移除 callback 路径中的开放重定向,只接受允许列表中的本地路由标识。
- 所有客户端类型都使用 Authorization Code Flow 和 PKCE S256。授权请求与令牌请求必须使用同一个已登记的
redirect_uri。 - 将 callback 绑定到登录尝试:验证一次性且绑定会话的
state;或者仅在授权服务器强制支持 PKCE 时依赖 PKCE 防御 CSRF。两者并用也可形成纵深防御。 - 单独截获授权码并不足够。确保令牌兑换还要求正确的客户端认证和 PKCE verifier;绝不要把访问令牌放进重定向 URL。
- 不要在日志中记录授权码;为 callback 页面设置严格的
Referrer-Policy,并在清除敏感查询参数前不要渲染第三方内容。