Prototype pollution w JavaScript: testy i obrona
Prototype pollution w JavaScript i Node.js: zobacz źródła skażenia, groźne gadżety, bezpieczną metodykę testów oraz skuteczny hardening aplikacji.
- AUTOR
- Karol Rapacz / Pentester (OSCP, PNPT)
- PUBLIKACJA
- 5 maja 2026
- CZAS CZYTANIA
- 16 min czytania
- TEMAT
- AppSec
Prototype pollution to podatność charakterystyczna dla ekosystemu JavaScript, w której dane kontrolowane przez użytkownika modyfikują prototyp obiektu. Skażona właściwość może zostać odziedziczona przez wiele pozornie niezależnych obiektów i zmienić konfigurację zapytania, decyzję autoryzacyjną, działanie sanitizera albo parametry funkcji uruchamianej na serwerze.
Najkrótsza odpowiedź: pełny atak ma zwykle dwie fazy. Najpierw potrzebne jest źródło skażenia — kod zapisujący kontrolowany klucz do obiektu lub niebezpiecznie scalający dane. Potem potrzebny jest „gadżet”, czyli fragment aplikacji, który odczyta odziedziczoną właściwość i nada jej znaczenie bezpieczeństwa. Filtrowanie samego ciągu
__proto__nie wystarcza; trzeba walidować schemat, odrzucać nieznane klucze i nie ufać właściwościom odziedziczonym.
MITRE opisuje tę klasę jako CWE-1321: Improperly Controlled Modification of Object Prototype Attributes. MDN udostępnia osobny przewodnik JavaScript prototype pollution, który rozdziela źródła skażenia od celów wykorzystania i wskazuje konkretne mechanizmy obronne.
Dlaczego prototyp zmienia zachowanie wielu obiektów
JavaScript używa łańcucha prototypów. Jeżeli właściwości nie ma bezpośrednio w obiekcie, silnik szuka jej w prototypie, potem w prototypie prototypu, aż do Object.prototype. Ta cecha pozwala współdzielić metody, ale staje się niebezpieczna, gdy aplikacja zakłada, że brak pola oznacza wartość fałszywą lub bezpieczny domyślny stan.
const user = { name: "tester" };
console.log(Object.hasOwn(user, "isAdmin")); // false
console.log(user.isAdmin); // undefined
Jeżeli podatny kod dopuści ustawienie Object.prototype.isAdmin = true, odczyt user.isAdmin zwróci wartość odziedziczoną. Nie oznacza to, że każda aplikacja automatycznie przyzna rolę administratora. Musi istnieć gadżet: logika autoryzacji, która sprawdza wartość pola bez potwierdzenia, że pole należy do konkretnego obiektu.
Najważniejsze rozróżnienie brzmi:
| Element | Pytanie testowe | Przykład |
|---|---|---|
| source | czy wejście pozwala modyfikować prototyp? | deep merge, parser ścieżek, dynamiczny zapis |
| propagation | gdzie skażona wartość jest widoczna? | ten obiekt, lokalny realm, cały proces |
| gadget | czy kod nadaje właściwości skutek? | autoryzacja, fetch, template, proces potomny |
| impact | co realnie może osiągnąć atakujący? | XSS, bypass, SSRF, DoS, czasem RCE |
Bez gadżetu znalezione źródło jest ważnym błędem integralności, ale nie należy automatycznie deklarować zdalnego wykonania kodu. Dobry raport rozdziela potwierdzone zachowanie od hipotetycznych łańcuchów.
Skąd bierze się skażenie prototypu
Dynamiczne przypisanie klucza
Kod w rodzaju target[key] = value jest ryzykowny, jeżeli key pochodzi z requestu i nie przechodzi allowlisty. Klucz może wskazywać __proto__ albo tworzyć ścieżkę constructor.prototype. Niebezpieczne są zwłaszcza helpery obsługujące notację kropkową, na przykład profile.preferences.theme.
Rekurencyjne scalanie obiektów
Funkcja deep merge zwykle przechodzi po kluczach źródła i kopiuje je do celu. Jeżeli traktuje specjalne nazwy jak zwykłe dane, może zejść do obiektu prototypu. Podatność może znajdować się w kodzie aplikacji, bibliotece narzędziowej, parserze parametrów albo warstwie konfiguracji.
Parsery query string i formularzy
Rozbudowane parsery potrafią zamieniać nazwy pól w zagnieżdżone struktury. Warto sprawdzić, czy blokują klucze specjalne na każdym poziomie, a nie tylko na początku ciągu. Aktualizacja zależności jest istotna, ale zarządzanie podatnościami nie zastąpi przeglądu własnych funkcji transformujących dane.
Łączenie JSON z obiektem konfiguracji
Samo JSON.parse() tworzy zwykłe dane; problem często pojawia się później podczas przypisywania lub scalania. MDN pokazuje, że zachowanie Object.assign() może różnić się od spread syntax w kontekście settera __proto__. Wniosek nie brzmi „spread zawsze jest bezpieczny”, lecz „trzeba rozumieć semantykę każdej transformacji i walidować wejście przed nią”.
Gadżety: moment, w którym skażenie staje się atakiem
Najciekawsze cele to obiekty opcji. Aplikacja przekazuje częściowo zdefiniowaną konfigurację do fetch(), biblioteki szablonów, mechanizmu renderowania, klienta HTTP lub funkcji systemowej. Brakujące wartości są wtedy odczytywane z prototypu.
Typowe klasy gadżetów obejmują:
- autoryzację — kod zakłada, że brak
isAdminoznaczafalse; - żądania HTTP — odziedziczone
method,body,headerslub URL zmieniają połączenie; - DOM i templating — skażona konfiguracja dociera do
srcdoc, HTML lub niebezpiecznego sinka; - sanitizery — domyślna konfiguracja zostaje poszerzona o właściwość zmieniającą listę dozwoloną;
- procesy serwerowe — opcje funkcji uruchamiającej polecenie lub moduł mogą stać się gadżetem;
- odmowę usługi — zmiana oczekiwanego typu powoduje wyjątki w wielu requestach.
W OWASP Top 10 dla aplikacji webowych prototype pollution może materializować się jako błąd kontroli dostępu, injection lub problem integralności. Sama etykieta technologiczna nie określa skutku; decyduje przepływ danych.
Metodyka bezpiecznego testu
Test wykonuj w środowisku uzgodnionym z właścicielem. Skażenie globalnego prototypu w długowiecznym procesie Node.js może wpłynąć na requesty innych użytkowników. Na produkcji preferuj nieszkodliwe, unikalne znaczniki, minimalną liczbę prób i natychmiastowy restart procesu, jeżeli zespół uzna go za konieczny.
1. Inwentaryzacja transformacji danych
W przeglądzie kodu szukaj dynamicznych zapisów, funkcji merge/clone/defaults, parserów ścieżek, deserializacji konfiguracji i miejsc kopiujących request do obiektu domenowego. SAST i code review są tu szczególnie wartościowe, bo źródło i gadżet mogą znajdować się daleko od siebie.
2. Potwierdzenie source bez wpływu biznesowego
Użyj niekolizyjnej nazwy właściwości i neutralnej wartości. Sprawdź, czy pojawia się ona jako odziedziczona na nowym, pustym obiekcie albo w konkretnym obiekcie docelowym. Nie ustawiaj pól takich jak isAdmin, shell, method czy ścieżek wykonawczych, dopóki zakres nie przewiduje takiej eskalacji.
3. Ustalenie zasięgu
Czy efekt dotyczy jednego obiektu, jednego requestu, workera, całego procesu czy wielu instancji? Serverless i klastry mogą dawać wyniki pozornie losowe, ponieważ kolejne żądanie trafia do innego procesu. Zapisuj identyfikator instancji, czas oraz stan przed i po restarcie.
4. Poszukiwanie gadżetu
Przeglądaj odczyty właściwości o znaczeniu bezpieczeństwa. Interesują Cię obiekty opcji z brakującymi defaultami, pętle for...in, decyzje typu if (obj.flag) i wywołania wrażliwych API. Najpierw udowodnij przepływ neutralnym markerem. Dopiero potem — jeżeli jest to bezpieczne i uzgodnione — pokaż kontrolowany skutek na własnym koncie lub zasobie.
5. Sprzątanie i reprodukcja
Właściwość na prototypie może przetrwać do restartu procesu. Usuń ją jawnie, odtwórz instancję lub zrestartuj usługę testową. Raport powinien zawierać sposób przywrócenia stanu. W teście regresyjnym sprawdzaj zarówno odrzucenie złośliwego klucza, jak i brak odczytu wartości odziedziczonych.
Jak naprawić prototype pollution u źródła
Waliduj pełny schemat
Najsilniejszą kontrolą wejścia jest allowlista pól i typów. W JSON Schema ustawienie additionalProperties: false odrzuca nieoczekiwane klucze, jeżeli walidator jest poprawnie użyty. W bibliotekach takich jak Zod analogicznie definiuje się ścisły obiekt. Walidację trzeba wykonać przed merge, mapowaniem i logiką domenową.
Nie ograniczaj się do blacklisty __proto__. Ścieżka constructor.prototype i warianty zagnieżdżone mogą ominąć zbyt prosty filtr. Jeżeli aplikacja musi przyjmować dynamiczne klucze, waliduj je allowlistą znaków i nazw, a specjalne segmenty odrzucaj na każdym poziomie.
Odczytuj tylko własne właściwości
Object.hasOwn(obj, key) pozwala odróżnić dane obiektu od łańcucha prototypów. Object.keys() i iteracja for...of po tej liście są bezpieczniejszym wyborem niż for...in, które obejmuje właściwości enumerable odziedziczone z prototypu.
const isAdmin = Object.hasOwn(user, "isAdmin") && user.isAdmin === true;
Najlepiej także ustawiać jawne wartości domyślne. Jeżeli isAdmin zawsze istnieje i ma typ boolean, logika nie musi interpretować braku pola.
Używaj struktur bez prototypu
Dla słowników z dynamicznymi kluczami rozważ Map albo Object.create(null). Obiekt z prototypem null nie dziedziczy z Object.prototype. To nie zwalnia z walidacji wartości, ale usuwa ważną klasę kolizji nazw.
Ogranicz mechanizmy runtime
Node.js oferuje flagę --disable-proto=delete albo --disable-proto=throw, opisaną również przez MDN. Usuwa ona jeden punkt wejścia przez Object.prototype.__proto__, lecz nie blokuje ścieżki constructor.prototype. Jest warstwą hardeningu, nie pełną naprawą.
W środowiskach wysokiego zaufania można rozważyć zamrożenie wbudowanych obiektów lub mechanizmy typu SES/realm lockdown. Trzeba jednak przetestować kompatybilność bibliotek, ponieważ część kodu legalnie rozszerza prototypy.
Bezpieczny wzorzec projektowy
| Ryzykowny wzorzec | Lepszy wzorzec |
|---|---|
| dowolny JSON scalany z konfiguracją | ścisły DTO z allowlistą pól |
target[userKey] = value | Map lub zweryfikowany klucz |
if (user.isAdmin) | własna właściwość + ścisły boolean |
for...in po danych | Object.keys() + for...of |
| częściowa konfiguracja bez defaultów | pełny, niezmienny obiekt opcji |
| wiara w aktualną wersję biblioteki | test regresyjny source + gadget |
Secure by Design oznacza tutaj, że granica zaufania kończy się przed utworzeniem obiektu domenowego. Kontroler HTTP nie powinien przekazywać surowego requestu do głębokiego merge. Warstwa mapująca wybiera znane pola, konwertuje typy i tworzy nowy obiekt o przewidywalnym kształcie.
Detekcja i monitoring
Logowanie całych payloadów może ujawnić dane osobowe, więc lepiej rejestrować naruszenie schematu, nazwę odrzuconego pola, endpoint i identyfikator korelacyjny. Alertuj na specjalne segmenty kluczy, ale pamiętaj, że brak tych ciągów w logach nie dowodzi bezpieczeństwa.
W CI dodaj testy dla __proto__, constructor, prototype, zagnieżdżonych ścieżek oraz nieznanych właściwości. Po próbie sprawdzaj, że pusty obiekt nie zawiera markera i że logika autoryzacyjna odczytuje wyłącznie własne pola. OWASP ASVS 5.0 może pomóc przypisać wymagania do walidacji, integralności i kontroli dostępu.
Frontend i backend mają inny zasięg skutków
Client-side prototype pollution zwykle żyje w kontekście strony i może prowadzić do DOM XSS, zmiany konfiguracji requestu albo obejścia logiki interfejsu. Nie należy jednak uznawać walidacji w przeglądarce za kontrolę serwerową — atakujący może wysłać request bez frontendu.
Server-side pollution w Node.js może dotknąć wiele żądań obsługiwanych przez ten sam proces. Skutek bywa trwały do restartu workera i trudny do odtworzenia w klastrze, bo ruch trafia do różnych instancji. W raporcie zaznacz, gdzie wykonano próbę, który proces został skażony i czy marker przeniósł się między sesjami. To odróżnia problem lokalnego obiektu od globalnej zmiany stanu usługi.
W aplikacji izomorficznej ta sama biblioteka może działać po obu stronach. Poprawka zależności powinna zatem objąć bundle przeglądarkowy, serwer SSR, narzędzia build i funkcje edge, a test regresyjny — każdy rzeczywisty runtime.
Checklista dla zespołu JavaScript
- Czy każdy payload przechodzi ścisłą walidację przed scalaniem?
- Czy odrzucasz nieznane pola i specjalne segmenty na każdym poziomie?
- Czy słowniki z dynamicznymi kluczami używają
Maplub null-prototype? - Czy decyzje bezpieczeństwa opierają się na
Object.hasOwn()i ścisłych typach? - Czy obiekty konfiguracji mają komplet jawnych wartości domyślnych?
- Czy zależności parserów i helperów merge są aktualizowane i testowane?
- Czy proces można bezpiecznie odtworzyć po próbie skażenia?
- Czy test regresyjny obejmuje osobno source, propagation i gadget?
Prototype pollution jest dobrym przykładem luki, której nie da się ocenić po samym payloadzie. Trzeba zrozumieć semantykę języka, przepływ danych i konkretne gadżety aplikacji. Jeżeli rozwijasz frontend lub backend Node.js, zamów test penetracyjny aplikacji — połączymy black-box z ukierunkowanym code review i wskażemy dokładne miejsce naprawy.


