
Proof-of-concept and technical write-up for CVE-2026-65971 — SQL injection via the sortDirection Livewire property in power-components/livewire-powergrid (< 6.10.4)
sortDirectionProof-of-concept and full technical write-up for CVE-2026-65971 / GHSA-7fgc-3h6c-698r,
an SQL injection in power-components/livewire-powergrid
reachable through the public Livewire property sortDirection.
| CVE | CVE-2026-65971 |
| GHSA | GHSA-7fgc-3h6c-698r |
| Package | power-components/livewire-powergrid (Composer / Packagist) |
| Affected | >= 6.0.0, < 6.10.4 |
| Patched | 6.10.4 |
| Weakness | CWE-89 — Improper Neutralization of Special Elements used in an SQL Command |
| Severity | 7.6 High — CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:L |
| Reported by | Caio Fabrício (@BiiTts) |
| Disclosure | Coordinated, via GitHub private security advisory |
├── 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 is a datatable component for Laravel + Livewire (~2k stars, widely used in Laravel admin panels). Its sort state lives in two public Livewire properties:
public string $sortField = 'id';
public string $sortDirection = 'asc';
In Livewire, a public property is part of the component's wire format — any client that can
reach the component can set it through POST /livewire/update. That is by design; the security
boundary is what the server does with the value.
PowerGrid's naturalSort() feature builds a raw ORDER BY expression containing the literal
placeholder {sortDirection}, and a pipeline substitutes that placeholder with the raw,
unvalidated property value before handing the string to orderByRaw(). The direction keyword
therefore lands verbatim inside SQL.
Laravel's own orderBy() rejects anything that is not asc/desc, and that validation is what
makes the ordinary sort path safe. The bug is that a second, unvalidated path to the same
clause exists — and an attacker can reach it while skipping the validating one entirely
(see The bypass).
Result: arbitrary SQL in the ORDER BY clause, exploitable as a blind boolean/time-based oracle
to read any data the database user can read.
Anyone who can reach a PowerGrid table that uses naturalSort can read arbitrary data from
the database — other tables, password hashes, session tokens, API keys, cross-tenant records —
using a time-based / boolean oracle.
SELECT.; UPDATE ... does not execute. Write impact is limited to what a subquery can trigger.SLEEP() and heavy subqueries —
trivially abusable to pin database threads.Privileges required is PR:L because a datatable normally sits behind application
authentication. If the affected table renders on an unauthenticated page, recompute with
PR:N → 8.2 High.
Three files, three stages. All references are to the vulnerable tag v6.10.3.
src/Concerns/Sorting.php
public string $sortField = 'id'; // line 11
public string $sortDirection = 'asc'; // line 13
Neither property has a whitelist, a validation rule, or a normalizing setter. sortDirection is
only ever assigned or flipped:
public function reverseSort(): string // line 37
{
return $this->sortDirection === 'asc' ? 'desc' : 'asc';
}
updatedSortDirection() (line 103) exists — the natural place for validation — but in v6.10.3
it only handles lazy-loading bookkeeping. It never inspects the value.
Because Livewire hydrates public properties straight from the request, sortDirection is
fully attacker-controlled, as an arbitrary string, at this point.
naturalSort() plants a placeholdersrc/Providers/Macros.php, lines 102–116 — the naturalSort column macro:
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() resolves to a per-driver expression built by
getSortSqlByDriver() in src/DataSource/Support/Sql.php (lines 60–100). Every driver variant
ends with the same literal placeholder:
$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
The vulnerability is driver-independent — every branch interpolates {sortDirection}.
src/DataSource/Processors/Database/Pipelines/ColumnRawQueries.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);
}
and the execution, line 52:
$query->{$method}($resolvedSql, $resolvedBindings); // $method === 'orderByRaw'
data_get($this->component, 'sortDirection') returns the attacker's string, preg_replace_callback
splices it into the SQL text, and orderByRaw() — which by contract does not escape its
argument — passes it to the database.
Note the bitter irony one line below: resolveBindings() (line 69) exists, and naturalSort
declares 'bindings' => []. The mechanism for safe parameterization is right there. It cannot be
used for a direction keyword — ORDER BY x ? is not valid SQL, a direction can never be a bound
parameter — which is precisely why a direction keyword must be allowlisted instead.
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