Prototype pollution
CWE-1321OWASP A03:2021Bijgewerkt 31 augustus 20267 min leestijd
Prototype pollution is een kwetsbaarheid waarbij een aanvaller via sleutels als __proto__ eigenschappen toevoegt aan Object.prototype, die vrijwel elk object in het proces daarna erft. De route loopt meestal via een recursieve merge en eindigt in een omzeilde autorisatiecontrole of in code-uitvoering. De oplossing is de gevaarlijke sleutels weigeren en invoer tegen een strikt schema valideren.
Veel JavaScript-applicaties voegen gebruikersinvoer samen met een object dat zij al in handen hebben: een gewijzigde instelling, een aangevulde configuratie, een bijgewerkt profiel. Neemt een recursieve merge daarbij elke aangeleverde sleutel klakkeloos over, dan kan een aanvaller een sleutel meesturen die niet in het object belandt, maar in de gedeelde blauwdruk erachter. Dat is prototype pollution. Hieronder leest u hoe dat werkt en wat u eraan doet.
Wat is prototype pollution?
Prototype pollution is een kwetsbaarheid waarbij een aanvaller eigenschappen toevoegt aan of wijzigt in het prototype waarvan JavaScript-objecten erven, waarna vrijwel elk ander object in het proces die eigenschappen overneemt. Ieder gewoon object erft in JavaScript van Object.prototype. Vraagt code een eigenschap op die een object zelf niet heeft, dan zoekt de engine verder in die prototypeketen en levert wat zij daar aantreft. Wie in het prototype schrijft, antwoordt dus namens alle objecten tegelijk.
Een alledaagse vergelijking: een drukkerij bewaart één moederformulier en elk blanco exemplaar aan de balie is daar een kopie van. Zet iemand op dat moederformulier een regel kleine lettertjes bij, dan draagt elk formulier dat daarna van de pers komt die regel, terwijl niemand aan de uitgedeelde exemplaren heeft gezeten. Object.prototype is dat moederformulier.
Er leiden twee deuren naartoe: bij een gewoon object verwijst __proto__ naar zijn prototype, en de omweg via constructor gevolgd door prototype komt op dezelfde plek uit. Een filter dat alleen op de eerste naam let, laat de tweede openstaan.
De kwetsbaarheid komt in twee smaken voor. In de browser bereikt de invoer de prototypeketen via de query string, de fragment-identifier of een bericht tussen vensters, en eindigt de aanval meestal in DOM-gebaseerde cross-site scripting. Serverzijdig, doorgaans in Node.js, komt zij binnen in een JSON-body of een genest geschreven query string, met als uitkomst een omzeilde autorisatiecontrole of remote code execution (RCE), oftewel willekeurige code op uw server.
Hoe werkt een prototype pollution-aanval?
Neem een endpoint waarmee een gebruiker zijn eigen instellingen bijwerkt: de applicatie laadt de opgeslagen instellingen, voegt de aangeleverde velden erin en schrijft het resultaat terug. De merge is zelfgeschreven en loopt recursief door geneste objecten.
Kwetsbaar:
// Zelfgeschreven deep merge: neemt elke aangeleverde sleutel over
function merge(target, source) {
for (const key of Object.keys(source)) {
const value = source[key];
if (value !== null && typeof value === "object") {
// Het uitlezen van target[key] levert bij __proto__ het prototype zelf op
if (typeof target[key] !== "object" || target[key] === null) {
target[key] = {};
}
merge(target[key], value);
} else {
target[key] = value;
}
}
return target;
}
app.post("/api/settings", express.json(), (req, res) => {
const settings = merge(loadSettings(req.user.id), req.body);
saveSettings(req.user.id, settings);
res.json(settings);
});
Een gewoon verzoek bevat een thema en krijgt de bijgewerkte instellingen terug. Een aanvaller voegt aan de body één extra sleutel toe:
POST /api/settings HTTP/1.1
Host: app.example.com
Content-Type: application/json
{"theme":"dark","__proto__":{"isAdmin":true}}
Twee eigenschappen van de taal maken dit mogelijk. Ten eerste behandelt JSON.parse de sleutel __proto__ als een gewone eigen eigenschap van het object dat hij opbouwt, zodat de sleutel het inlezen overleeft en in de lus terechtkomt. Ten tweede gedraagt het uitlezen van target["__proto__"] zich juist bijzonder: het levert geen eigenschap van het doelobject op, maar Object.prototype zelf. De recursie schrijft isAdmin vervolgens rechtstreeks in het prototype.
Vanaf dat moment is het hele proces vervuild, voor elke gebruiker die het bedient:
const account = {};
account.isAdmin; // true, zonder dat het ooit is toegekend
Object.hasOwn(account, "isAdmin"); // false
Elke controle die enkel vraagt of de waarde waar is, geeft nu iedereen groen licht: een middleware die het beheergedeelte opent zodra die vlag staat, ziet hem overal staan. Dezelfde truc werkt op opties die een bibliotheek uitleest en waarvan zij aanneemt dat ze ontbreken. Zo’n optie heet een gadget. Vervuil de optie die bepaalt of een subproces via een shell wordt gestart, of een veld dat een templateverwerker als code uitvoert, en de vervuiling wordt code-uitvoering.
Het herstel bestaat uit twee helften: weiger de gevaarlijke sleutels en daal nooit af in een object waarvan u het prototype niet zelf koos.
Veilig:
const VERBODEN = new Set(["__proto__", "constructor", "prototype"]);
function safeMerge(target, source) {
for (const key of Object.keys(source)) {
if (VERBODEN.has(key)) continue;
const value = source[key];
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
const bestaand = Object.hasOwn(target, key) ? target[key] : null;
if (bestaand === null || typeof bestaand !== "object") {
// Zonder prototype: "__proto__" is hierin een gewone sleutel
target[key] = Object.create(null);
}
safeMerge(target[key], value);
} else {
target[key] = value;
}
}
return target;
}
Beter nog is het om vormvrije invoer helemaal niet samen te voegen. Beschrijf de verwachte vorm in een schema en verwerp onbekende velden, dan haalt __proto__ de merge niet eens:
import { z } from "zod";
const Settings = z.object({
theme: z.enum(["light", "dark"]),
notifications: z.object({ email: z.boolean() }).strict()
}).strict();
app.post("/api/settings", (req, res) => {
const parsed = Settings.safeParse(req.body);
if (!parsed.success) {
return res.status(400).send("Ongeldige instellingen");
}
const settings = safeMerge(loadSettings(req.user.id), parsed.data);
saveSettings(req.user.id, settings);
res.json(settings);
});
Bepaalt de gebruiker de sleutels zelf, houd ze dan buiten gewone objecten: een Map en een object zonder prototype kennen geen prototypeketen.
const pogingen = new Map();
pogingen.set(req.ip, aantal); // sleutels blijven data, nooit eigenschappen
const labels = Object.create(null);
labels[req.query.name] = value; // "__proto__" is hier een gewone sleutel
__proto__ volstaat niet, want via constructor gevolgd door prototype bereikt een aanvaller hetzelfde object. Ook een filter dat de tekst eenmalig uit een sleutel knipt is te omzeilen, doordat er na het knippen opnieuw een geldige sleutel kan ontstaan. Weiger de drie sleutels volledig in plaats van ze op te schonen.Wat is de impact van prototype pollution?
Prototype pollution is zelden op zichzelf het einde en zelden onschuldig. De ernst hangt af van de gadget: de vervuilde eigenschap doet pas iets zodra andere code haar uitleest en er een beslissing op baseert. Ontbreekt die, dan houdt u vreemd gedrag of een vastgelopen proces over, op zichzelf al een denial of service, want de vervuiling geldt voor het hele proces en verdwijnt pas bij een herstart.
Is er wel een gadget, dan gaat het snel. Een vervuilde eigenschap in een autorisatiecontrole levert een omzeiling van de autorisatie op, waarmee een aanvaller beheerfunctionaliteit bereikt zonder ooit een wachtwoord te raden. Een gadget in een templateverwerker of in de opties van een aanroep naar een extern programma levert code-uitvoering op de server op: sleutels lezen, gegevens wegsluizen, doorstoten naar interne systemen. In de browser eindigt dezelfde fout in cross-site scripting en dus in overgenomen sessies.
Zakelijk weegt één eigenschap zwaar: de schade blijft niet beperkt tot de sessie van de aanvaller. Omdat alle objecten in het proces dezelfde blauwdruk delen, kan één verzoek de verwerking van gelijktijdige gebruikers beïnvloeden, wat in een door meerdere klanten gedeelde omgeving bijzonder ongelukkig uitpakt. De classificatie loopt daarom van middel tot hoog.
Hoe spoort u prototype pollution op?
Begin waar gebruikersinvoer een object binnenkomt zonder vaste vorm: functies met namen als merge, extend, clone of set, parsers voor query strings met geneste notatie, configuratieladers en endpoints die een JSON-body rechtstreeks over een opgeslagen object heen leggen. In code van derden is dit een klassieker, dus een analyse van uw afhankelijkheden hoort bij dezelfde ronde.
Testen doet u door een payload met __proto__ in de JSON-body te sturen, daarna via de query string als ?__proto__[vervuild]=ja en nog eens met constructor en prototype, en telkens te controleren of een vers object de eigenschap heeft geërfd. Blijft een reactie uit, dan is het geval blind en zoekt u een waarneembaar neveneffect: vervuil een instelling die het framework uitleest, bijvoorbeeld de optie waarmee Express de inspringing van JSON-antwoorden bepaalt, en let op de opmaak van elk volgend antwoord. Aan de clientzijde zet u een eigenschap via de URL en kijkt u in de console of een leeg object haar erft.
Scanners melden bekende kwetsbare afhankelijkheden, maar zien een zelfgeschreven merge vrijwel nooit, laat staan de gadget die de vervuiling tot een exploit maakt. Dat vraagt om het lezen van code en handmatig doorredeneren van vervuiling naar effect. AssistSec neemt dit mee in een penetratietest en toont per bevinding welke sleutel het prototype bereikte en welke gadget daar iets mee deed.
Hoe voorkomt u prototype pollution?
- Weiger de sleutels
__proto__,constructorenprototypeaan de rand. Doe dat bij het inlezen van alles wat van buiten komt, niet diep in de verwerking, waar een codepad de controle kan overslaan. - Valideer tegen een strikt schema. Beschrijf de verwachte velden met een validator zoals zod, Joi of Ajv en wijs onbekende velden expliciet af. Dezelfde maatregel sluit mass assignment uit.
- Gebruik
Object.create(null)of eenMapvoor sleutels die de gebruiker bepaalt. Geen van beide heeft een prototypeketen, dus een sleutel die naar het prototype is vernoemd, wordt als data opgeslagen. - Schrijf geen eigen deep merge voor onvertrouwde invoer. Gebruik een onderhouden bibliotheek, controleer eigen eigenschappen met
Object.hasOwnen daal nooit recursief af in een object dat u niet zelf hebt aangemaakt. - Zet de runtime op slot waar dat kan. Node.js kent de optie
--disable-protomet de waarden throw en delete. Het bevriezen vanObject.prototypehelpt ook, maar test dat eerst: sommige bibliotheken schrijven zelf in prototypes. - Houd afhankelijkheden actueel en laat de code beoordelen. Merge-, query string- en configuratiebibliotheken zijn de bekendste bron; een review van elke samenvoegroutine en een gerichte penetratietest vangen wat een scanner overslaat.
Bronnen
Veelgestelde vragen
Wat is het verschil tussen prototype pollution en mass assignment?
Bij mass assignment schrijft een aanvaller naar een veld dat wel degelijk bestaat maar niet bewerkbaar had mogen zijn, bijvoorbeeld een rolveld op zijn eigen account. Bij prototype pollution schrijft hij buiten het record om, in de gedeelde blauwdruk waarvan alle objecten erven. Het gevolg lijkt op elkaar, de reikwijdte niet: een vervuilde eigenschap duikt op in elk object dat die eigenschap niet zelf zet, ook in objecten van andere gebruikers.
Speelt prototype pollution alleen in Node.js?
Nee. De kwetsbaarheid zit in de taal en niet in de runtime, dus code in de browser is even goed kwetsbaar. Aan de clientzijde komt de invoer meestal binnen via de query string, de fragment-identifier van de URL of een bericht tussen vensters, en eindigt de aanval in DOM-gebaseerde cross-site scripting. Serverzijdig in Node.js zijn de gevolgen doorgaans zwaarder, omdat daar meer gadgets richting code-uitvoering binnen bereik liggen.
Is het blokkeren van __proto__ genoeg?
Nee. Hetzelfde prototype is bereikbaar via de sleutel constructor gevolgd door prototype, dus een filter op één naam laat de andere route open. Filters die de tekst eenmalig uit een sleutel knippen zijn eveneens te omzeilen, doordat er na het knippen opnieuw een geldige sleutel kan ontstaan. Weiger de drie sleutels volledig, of gebruik een strikt schema dat onbekende velden verwerpt.
Hoe ernstig is prototype pollution in de praktijk?
Dat hangt af van de gadget, want een vervuilde eigenschap doet pas iets zodra andere code haar uitleest en er een beslissing op baseert. Zonder bruikbare gadget houdt u vreemd gedrag of een vastgelopen proces over, wat op zichzelf al een denial of service is. Met een gadget in een autorisatiecontrole, een templateverwerker of de opties van een aanroep naar een extern programma loopt het op tot volledige overname, en daarom varieert de classificatie van middel tot hoog.
Verwante artikelen
- KwetsbaarhedenCWE-284A01:2021Broken access controlBroken access control uitgelegd: horizontale en verticale escalatie, forced browsing, en hoe u met deny by default serverzijdig autoriseert.
- KwetsbaarhedenCWE-915A01:2021Mass assignmentMass assignment uitgelegd: hoe een ongefilterde request body velden als isAdmin of balance schrijfbaar maakt en hoe een allowlist dat voorkomt.
- KwetsbaarhedenCWE-1104A06:2021Verouderde en kwetsbare componentenEen verouderde bibliotheek of webserver draagt publiek bekende kwetsbaarheden met kant-en-klare exploits. Lees hoe u dat beheersbaar maakt.
- KwetsbaarhedenCWE-94A03:2021Remote code execution (RCE)Remote code execution (RCE) uitgelegd: hoe aanvallers via ongefilterde invoer eigen commando's of code op uw server draaien, en hoe u het voorkomt.