better-auth:一个 OAuth state,为什么能当 Magic Link 登录别人
原创
云梦DC
云梦DC
云梦安全
2026年10月30日 08:44
河南
去阅读
2026 年 10 月披露的 CVE-2026-114607(GHSA-965c-763c-88jm,CVSS 9.1)影响 better-auth 1.4.0-beta.18 到 1.7.7 之前的版本。问题出在两类临时令牌共用了一张表:OAuth 登录产生的 state,可以被 Magic Link 的验证接口当成登录 token 消费。
本地用 1.7.6 发起一次 OAuth 登录,拿到 32 位 state,没有访问 GitHub,也没有完成回调。把这个值拼进 /magic-link/verify?token=... 后,服务端返回 302,并下发 better-auth.session_token。随后请求 get-session,返回的是预先建好的 [email protected]。
漏洞速览
| 项目 | 内容 |
|---|---|
| 漏洞编号 | CVE-2026-114607 / GHSA-965c-763c-88jm |
| 影响组件 | better-auth >= 1.4.0-beta.18, < 1.7.7 |
| 修复版本 | 1.7.7 |
| 漏洞类型 | 认证令牌类型混淆(CWE-287) |
| 危害等级 | CVSS 9.1 Critical |
| 触发前提 | 开启 Magic Link 与社交/Generic OAuth;OAuth state 存数据库;Magic Link 令牌默认明文存储 |
两条流程写进了同一张表
Magic Link 的流程不复杂。用户提交邮箱,服务端生成随机 token,把 {email, name} 和过期时间写进 verification 表,再把带 token 的链接发到邮箱。用户点击后,服务端按 token 查表,取出邮箱,建立会话。
默认 storeToken: \"plain\" 时,记录大致是这样:
verification
identifier = <token>
value = {\"email\":\"[email protected]\",\"name\":\"...\"}
expiresAt = now + 5min
OAuth 登录也需要一个 state 防 CSRF。当 account.storeStateStrategy 为 \"database\" 时,better-auth 同样往 verification 表写一行:
verification
identifier = <state>
value = {\"callbackURL\":\"/\",\"codeVerifier\":\"...\",\"oauthState\":\"<state>\",...}
expiresAt = now + 10min
两条流程单独看都没有明显问题:随机值够长、有有效期、消费一次就失效。缺陷在查表方式。Magic Link 验证只按 identifier 找记录,没有确认这条记录是哪条流程写进来的。OAuth state 恰好也落在同一个字段里。
把 state 拼进 Magic Link 验证地址
/sign-in/social 接受一个 additionalData 字段,原本是让业务把自定义信息带过 OAuth 流程。这个对象会被展开进 state 的 value。攻击者在发起 OAuth 时可以写入目标邮箱:
{
\"provider\": \"github\",
\"callbackURL\": \"/\",
\"additionalData\": {
\"email\": \"[email protected]\",
\"name\": \"Victim User\"
}
}
服务端返回的授权 URL 里带 state。攻击者不用去 GitHub,也不用拿授权码,只要把这个值换成 Magic Link 的 token:
GET /api/auth/magic-link/verify?token=<state>&callbackURL=/
验证逻辑拿 token 去 verification 表按 identifier 查,正好命中刚才那条 OAuth state。接着它把记录的 value 当成 Magic Link 载荷,取出 email,找到用户,创建会话并下发 cookie。攻击者拿到的会话和正常登录没有区别。
本地复现的结果
实验用 better-sqlite3 做内存数据库,1.7.6 和 1.7.7 各跑一遍。两边配置相同的 GitHub provider,client id 和 secret 都是虚构值;Magic Link 插件开启,OAuth state 存数据库。测试前先写入一个 emailVerified: true 的 victim 账号,模拟已经存在的正常用户。
1.7.6 的步骤和结果:
POST /api/auth/sign-in/social -> 200,授权 URL 内含 32 位 state
GET /api/auth/magic-link/verify?token=... -> 302,下发 better-auth.session_token
GET /api/auth/get-session -> [email protected], emailVerified=true
把脚本切到 1.7.7,第二步变成 302 /?error=INVALID_TOKEN,没有 set-cookie;第三步返回 null。state 的长度和有效期没有变,变化是两类记录被分到了不同的命名空间。
关闭注册并不能挡住
公告专门提醒,Magic Link 的 disableSignUp: true 只能阻止新建账号,挡不住接管已有账号。上面的复现登录的就是一个早已存在的用户,注册开关全程没有参与。
缩短 state 有效期、增加随机位数也没有解决问题。攻击者拿到 state 后可以立即使用,几分钟的窗口足够完成请求。根因是消费令牌时没有确认签发流程,跟随机值强度不是一回事。
日志里怎么串事件
单看一条日志,这次攻击很像正常的 Magic Link 登录。需要把几个动作放在一起:
- 同一 IP 或会话先调用
/sign-in/social,短时间内又调用/magic-link/verify; verification表里出现identifier与 OAuth state 相同、却被 Magic Link 路径消费的记录;- 用户没有请求过 Magic Link,却多出一条
method=magic-link的会话; - 登录后马上修改邮箱、密码、MFA 或 API key。
如果项目自定义了 verification.storeIdentifier,升级时要把 magic-link: 和 auth-state: 前缀一起纳入映射。否则旧记录可能无法解析,新记录也可能继续落到同一个键上。
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。












评论