OAuth 2.0 授权码流程实测:4 次跳转,少个 state 就被劫持

23 阅读8分钟

点一次微信登录,浏览器先跳 2 次 302,后端再发 2 次请求,才换回 1 个 access_token。

整个过程中,access_token 在浏览器地址栏里出现 0 次。

我把它做成一个可点的网页实验室,跑了 10 条断言,又试了 3 种攻击。

少一个 state,账号就能被绑到攻击者的会话里。

图 1:开始界面,5 段消息、2 次 302、前端 URL 里 token 出现 0 次、10/10 断言

一、为什么值得看:登录按钮背后有 4 段请求

微信登录、GitHub 登录、Google 登录,点下去都做同一件事。

浏览器被交给授权服务器,再带着一张一次性票据回来。

这张票据叫授权码。

它只能用一次,有效期通常 10 分钟,换 token 的动作由后端完成。

授权码模式因此有两个硬要求:state 防 CSRF,PKCE 防授权码被截。

少一个,登录流程看起来照常,账号却可能换成别人的。

我把这四段请求逐步点了一遍,又把三个开关逐个关掉,看攻击从哪里进来。

二、为什么我把这件事做成网页

文档里的时序图是静态的。

字段在哪一步出现、在哪一步消失,光看箭头很难记住。

我想同时回答三个问题。

code 在浏览器里停留多久。

token 什么时候进后端。

关掉 state 或 PKCE 后,攻击具体走到哪一步。

网页把这些问题放在同一屏:左边改参数,中间看消息,右边看断言。

码道有三种使用方式:WebUI(浏览器对话)、TUI(终端命令行)和桌面 IDE(IDE 插件)。本文用 WebUI 版。

三、这轮码道实际做了什么

我给码道的需求是一句话:做一个单文件 index.html 的 OAuth 2.0 授权码流程实验室,三泳道、四模式、三种攻击、十条断言。

它交付了一个 954 行、38.7 KB 的 index.html,没有外部 CDN、字体和图片。

左侧是参数配置和攻击入口,中间是浏览器、授权服务器、业务后端三条泳道,右侧是模式对照表和安全断言,底部是时间轴。

首版能跑,但断言是装饰性的。

比如 code 只能用一次这一条,直接写死 pass,没有任何计算。

隐式模式 token 在 fragment 这一条,判断条件是 currentMode !== 'implicit' || true,永远为真。

我把它重写成 10 条带返回值的模拟,页面上显示的是函数真实返回的状态码。

第二个问题是消息卡片重叠。

首版每张卡片按 90px 递进,但 PKCE 模式有 7 个字段,一张卡片就有 150px 高。

改成 170px 递进后,再把长字段单独占一行,字段和方向才不再压在一起。

第三个问题是第三条消息的宽度溢出。

它从浏览器跨到业务后端,宽度算出来比泳道还宽,直接盖住了右侧面板。

我按泳道宽度做了截断,卡片才回到自己的区域。

图 2:授权码模式跑到第 4 段,浏览器拿 code,后端换 token

四、四步流程,每一步传了什么

授权码模式在页面里是 5 段消息。

前 4 段是请求,最后 1 段是 token 响应。

步骤方向带什么谁看得见
1浏览器 → 授权服务器response_type=code、client_id、redirect_uri、scope、state浏览器地址栏
2授权服务器 → 浏览器code、state浏览器地址栏
3浏览器 → 业务后端code后端日志
4业务后端 → 授权服务器grant_type、code、client_id、client_secret后端
5授权服务器 → 业务后端access_token、refresh_token、expires_in后端

严格说,只有前两步是浏览器 302。

后两步是后端请求和响应。

标题里的 4 次跳转,把 4 段请求都算上,最后那段 token 返回没有单独算一次跳转。

这样数更接近开发者嘴里的“走一遍登录流程”。

code 在浏览器里只出现一次。

第 3 步把它交给后端后,浏览器手里就没有 token 了。

页面里的“前端 URL 里的 token”计数器一直是 0。

隐式模式跳过第 3、4 步,token 直接放在地址栏 fragment 里。

同一个计数器会跳到 1。

这就是它被废弃的原因,不是 token 不够随机,是它出现在了不该出现的地方。

五、三种攻击,只有一种能拦住

页面左侧有三个攻击入口。

我把每个开关单独关掉,再点一次攻击,结果如下。

攻击关闭的配置攻击结果打开配置后
CSRF 登录劫持state 校验被攻破已拦截
授权码截获PKCE被攻破已拦截
redirect_uri 子域拼接精确匹配被攻破已拦截

CSRF 的路径最容易被忽略。

攻击者先用自己的账号拿到 code。

再把带 code 的回调链接发给受害者。

后端不校验 state,就会用这个 code 换 token,把受害者的浏览器绑到攻击者账号。

页面把这条链路拆成四步显示,最后一句结论是“受害者浏览器绑定了攻击者的账号”。

修法不复杂:state 随机、一次性、和当前会话绑定,回调时严格比对。

PKCE 解决的是公共客户端没有 client_secret 的问题。

verifier 留在客户端,challenge 先发给授权服务器,换 token 时再交 verifier。

我用 RFC 7636 的示例向量对了一遍。

verifier 是 dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk。

算出的 challenge 是 E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM。

篡改 verifier 最后一位,S256 对不上,返回 400 invalid_grant。

redirect_uri 的通配符更隐蔽。

规则写成 https://myapp.com/* 时,callback.evil.com 和 /x?code=1 都能通过前缀检查。

页面里精确规则放行 0/3 个伪造域名,通配符规则放行 2/3。

剩下那个是 myapp.com.evil.com,前缀检查也拦住了它,但精确匹配同样不会放行。

结论还是那句:redirect_uri 只做精确匹配。

图 3:授权码+PKCE,S256 challenge 和 verifier 出现在消息里

六、验证与踩坑:10 条断言和三次修正

10 条断言打开就自动跑。

授权码模式 10/10。

授权码加 PKCE 同样 10/10。

几条关键断言的返回值如下。

断言实测返回值
code 只能使用一次首次 200,二次 400 invalid_grant
code 有效期 600 秒599s 有效,601s 返回 400
state 不匹配必须拒绝匹配 200,篡改 400 state_mismatch
PKCE verifier 不匹配S256 匹配 200,篡改末位 400 invalid_grant
access_token 不写进历史授权码模式 0 条,隐式模式 1 条
redirect_uri 精确匹配精确放行 0/3,通配符放行 2/3

隐式模式只有 9/10。

红的是一条“access_token 不写进浏览器历史”。

这不是页面算错,是隐式模式本身的问题。

客户端凭证模式也是 10/10,但它没有用户参与,不能拿来当登录方案。

三次修正里,有一次是断言自己写错。

第一版第 8 条把精确规则和通配符规则的放行数写反了,页面显示 9/10。

改成同时计算两套规则后,才回到 10/10。

还有一次是隐式模式的 refresh_token 断言。

隐式模式根本不发 refresh_token,判 FAIL 不符合事实。

改成“本模式不发放”后,失败项只剩 token 进 URL 那一条。

失败路径也留了入口。

把 redirect_uri 改成 http://myapp.com/callback,页面弹出中文提示。

提示里写了两条原因:不能用 HTTP,以及未在登记白名单里。

关掉弹窗后,输入框回到上一次有效值。

图 4:隐式模式,token 在 fragment,URL 计数变 1,断言 9/10

图 5:关闭 state 校验后,CSRF 攻击显示被攻破,断言变红

边界也要说清楚。

这是一个前端模拟器,不是真实的身份提供方。

token 字符串是假的,时间轴是动画,不是真实网络延迟。

S256 用了 WebCrypto 真算,但 token endpoint 没有真的发请求。

它能解释流程和攻击路径,不能用来给一个线上系统做安全验收。

七、本地复现

整页只有一个 index.html。

在文件所在目录起一个静态服务:

python -m http.server 8080

浏览器打开 http://localhost:8080/index.html 就能跑。

五张截图都是 1920×1080,分别是开始界面、授权码流程、PKCE、隐式模式和攻击失败路径。

案例和源码放在公开仓库:gitcode.com/OPCTeam/dem…

打开后直接点“开始授权流程”,不需要装依赖。

八、使用码道体会

这次最省事的是界面骨架。

一句话需求,码道把三泳道、模式表、时间轴和攻击卡片都搭齐了。

需要我补的是协议细节和判定逻辑。

第一版断言写死 PASS,就是典型:界面像样,证据不成立。

我的用法是:让码道做界面和计算骨架,自己盯住字段、断言和失败路径。

下一步想加的是本地 token endpoint mock,把过期、刷新和撤销也跑通。

那一步更接近真实授权服务器。

九、准备环境:进入码道 Web

浏览器打开码道 Web 版:devcloud.cn-north-4.huaweicloud.com/chat?source…

登录后就能在对话窗口输入需求,不需要装软件。

本文的页面就是用这条入口生成的。