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`