
CVE-2026-65971에 대한 개념 증명 및 기술 문서 — power-components/livewire-powergrid (< 6.10.4)의 sortDirection Livewire 속성을 통한 SQL 인젝션
sortDirection을 통한 SQL 인젝션CVE-2026-65971 / GHSA-7fgc-3h6c-698r에 대한 개념 증명 및 전체 기술 문서입니다. 이는 power-components/livewire-powergrid의 SQL 인젝션으로, 공개된 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는 Laravel + Livewire용 데이터테이블 컴포넌트입니다 (~2k stars, Laravel 관리자 패널에서 널리 사용됨). 정렬 상태는 두 개의 **public Livewire properties**에 위치합니다:```php
public string $sortField = 'id';
public string $sortDirection = 'asc';
In Livewire에서 public 프로퍼티는 컴포넌트의 wire format의 일부입니다 — 컴포넌트에 접근 가능한 클라이언트는 POST /livewire/update를 통해 이를 설정할 수 있습니다. 이는 의도된 설계이며, 보안 경계는 서버가 해당 값을 어떻게 처리하는지에 있습니다.
PowerGrid의 naturalSort() 기능은 리터럴 플레이스홀더 {sortDirection}을 포함하는 원시 ORDER BY 표현식을 생성하며, 파이프라인은 이 플레이스홀더를 원시, 검증되지 않은 프로퍼티 값으로 대체한 후 문자열을 orderByRaw()에 전달합니다. 따라서 방향 키워드는 SQL 내부에 그대로 삽입됩니다.
Laravel 자체의 orderBy()는 asc/desc가 아닌 것은 거부하며, 이 검증이 일반적인 정렬 경로를 안전하게 만듭니다. 버그는 동일한 절에 대한 두 번째 검증되지 않은 경로가 존재하며 — 공격자가 검증을 완전히 건너뛰고 이 경로에 도달할 수 있다는 것입니다 (우회 경로 참조).
결과: ORDER BY 절에서의 임의 SQL로, 데이터베이스 사용자가 읽을 수 있는 모든 데이터를 읽기 위한 블라인드 불리언/시간 기반 오라클로 악용 가능합니다.
naturalSort를 사용하는 PowerGrid 테이블에 접근할 수 있는 모든 사용자는 데이터베이스의 임의 데이터 — 다른 테이블, 비밀번호 해시, 세션 토큰, 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() plants a placeholdersrc/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', // <-- raw sink
'sql' => Sql::sortStringAsNumber($this->dataField),
'bindings' => [],
];
}
return $this;
});
`Sql::sortStringAsNumber()`는 `src/DataSource/Support/Sql.php` (60–100행)에 있는 `getSortSqlByDriver()`가 구축한 드라이버별 표현식으로 해석됩니다. 모든 드라이버 변형은 동일한 리터럴 플레이스홀더로 끝납니다:```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
---
## 🔓 우회 — 라라벨의 유효성 검사가 당신을 구하지 못하는 이유
이 부분이 "원시 문자열 보간법"을 실제로 악용 가능한 버그로 만드는 부분이며, 이것이 성숙하고 널리 사용되는 패키지에서 이 문제가 살아남은 이유입니다.
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()는 Laravel의 유효성 검사 API입니다. asc/desc 이외의 값을 주면 다음과 같은 오류가 발생합니다:```
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.