Frontend Labs
⋯

Chapter 34 — Prototype pollution, DOM clobbering, and mutation XSS

In one sentence

Three JavaScript-specific classes: prototype pollution corrupts Object.prototype so unrelated code inherits attacker-controlled properties (server-side: RCE; client-side: XSS); DOM clobbering uses injected HTML (no script) to shadow globals; mutation XSS defeats sanitisers by exploiting how browsers re-parse "safe" markup.

Where we are

G1 Ch 43 covered XSS and CSP. These are the modern client-and-Node classes that bypass the usual defences: prototype pollution needs no injection sink in the classic sense, DOM clobbering works where <script> is blocked, and mXSS turns a passing DOMPurify run into an XSS.

How it works

Prototype pollution:

   In JS, obj.__proto__ === Object.prototype. If an attacker can set a
   property PATH that starts with __proto__ / constructor.prototype, they
   add/override a property on Object.prototype -- which EVERY object
   inherits.

   SOURCES (attacker controls a key path + value):
     - recursive merge/extend/clone: `merge(target, JSON.parse(body))`
       body = {"__proto__": {"isAdmin": true}}
       (historic: lodash.merge/defaultsDeep, jQuery.extend(true,...),
        many "deepmerge" libs, Object.assign used recursively)
     - query-string parsers: `?__proto__[isAdmin]=true` (qs, older
       versions), `?constructor[prototype][x]=y`
     - path-set: `_.set(obj, userControlledPath, value)`
     - config/YAML loaders, `JSON.parse` + a reviver + a later merge

   SERVER-SIDE (Node) IMPACT -- pollute a prop that library code later
   reads off a FRESH object literal that doesn't define it:
     - child_process options: pollute `shell`, `NODE_OPTIONS`, `env`,
       `execArgv` -> when the app spawns anything -> RCE (e.g.
       `NODE_OPTIONS=--require /proc/self/environ` tricks, or
       `execArgv:["--eval","..."]`).
     - template engines: pug/handlebars compile options -> RCE.
     - `Object.prototype.status` / headers / `Object.prototype.then`
       (makes non-promises thenable -> breaks control flow).
     - security flags: `isAdmin`, `authenticated`, feature toggles.
     - DoS: pollute a prop that breaks the app for everyone.

   CLIENT-SIDE IMPACT -- a "gadget": library code does `if (config.debug)`
   / `el[opt.prop]` / `element.innerHTML = tpl[key]` where the key is
   absent -> reads the polluted proto value -> XSS, CSP bypass (pollute a
   nonce/src), open redirect, or DOM-XSS via a sink.

DOM clobbering:

   No script needed. Injected HTML with `id`/`name` attributes creates
   named properties on `document` / `window` / on form elements:
     <a id="config"></a>                 -> window.config is that <a>
     <a id="x"><a id="x" name="y" href="j">  -> window.x is an
        HTMLCollection; x.y is the second anchor; String(x.y) is its href
     <form id="self"><input name="nonce" value="...">  -> shadow
        form.nonce
   If code does `var CFG = window.config || {url:'/safe'}` or
   `if (!window.alreadyRun)` or reads `something.src` expecting undefined,
   the clobbered element changes the logic -> config injection, XSS,
   auth-check bypass. Works wherever HTML injection is possible but
   scripts are filtered (e.g. sanitised rich text that keeps id/name).

Mutation XSS (mXSS):

   A sanitiser (DOMPurify) sees markup, decides it's safe, returns it.
   The app sets `element.innerHTML = sanitised`. The BROWSER'S PARSER then
   "fixes up" the markup on insertion/re-serialisation -- moving nodes,
   closing tags, switching parsing context (HTML vs SVG vs MathML
   "foreign content", `<template>`, `<noscript>`, `<style>`, `<xmp>`) --
   and the fixed-up result contains executable markup the sanitiser never
   saw.
   DOMPurify has had multiple mXSS bypass CVEs (2019, 2020, 2024). It is
   still the right tool -- but only if kept current and used without
   re-parsing its output.

Defence:

   PROTOTYPE POLLUTION
     [ ] Reject keys `__proto__`, `constructor`, `prototype` in any
         merge/set/parse of untrusted input.
     [ ] Use `Object.create(null)` or `Map` for dictionaries you build
         from input.
     [ ] `Object.freeze(Object.prototype)` at startup (test for breakage).
     [ ] Node: run with `--disable-proto=delete` (or `throw`).
     [ ] Schema-validate input (JSON Schema / zod) -> unexpected keys
         rejected.
     [ ] Prefer `structuredClone` over hand-rolled deep clone; keep
         lodash/qs current; avoid recursive merge of untrusted data
         entirely.
   DOM CLOBBERING
     [ ] Sanitiser strips or namespaces `id`/`name` (DOMPurify:
         `SANITIZE_DOM: true` is default; `SANITIZE_NAMED_PROPS: true`).
     [ ] Never store security-relevant data in globals; use clos-ures /
         modules / explicit `const`.
     [ ] Explicit type checks: `typeof window.config === 'object' &&
         !(window.config instanceof HTMLElement)`.
   MUTATION XSS
     [ ] Keep DOMPurify (or your sanitiser) UPDATED; subscribe to its
         advisories.
     [ ] Don't re-parse sanitiser output (no innerHTML round-trip after
         sanitising).
     [ ] Prefer setting `textContent`, or building DOM nodes explicitly,
         over `innerHTML`.
     [ ] Trusted Types (`require-trusted-types-for 'script'`) to force all
         sink writes through a vetted policy.
     [ ] A strict CSP as the backstop (G1 Ch 43).

Worked example

Server-side prototype pollution to RCE in a Node API.

   POST /api/preferences   body: JSON, merged into the user's prefs:
     app.prefs[userId] = deepMerge(defaults, req.body)   // vulnerable
   Later, an unrelated feature renders a report:
     const pdf = await renderPdf(html, {})   // options object, no `shell`

   ATTACK:
     POST /api/preferences
     {"__proto__": {"shell": "/bin/sh", "env": {"X":"1"},
       "argv0": "node"}}
   deepMerge walks into `__proto__` -> sets Object.prototype.shell etc.
   Now EVERY `{}` inherits `shell: "/bin/sh"`. When renderPdf internally
   does `child_process.spawn(bin, args, opts)` with `opts = {}` (no own
   `shell`), Node reads the inherited `shell` -> the command runs via a
   shell -> if any arg is influenceable, RCE; even without, polluting
   `execArgv`/`NODE_OPTIONS` on a spawned `node` gives code exec.

   FIX:
     - deepMerge rejects `__proto__`/`constructor`/`prototype` keys (or
       use `structuredClone` + explicit allowlisted field copy).
     - Validate `req.body` against a schema (only known preference keys).
     - `Object.freeze(Object.prototype)` at boot.
     - Node `--disable-proto=delete`.
     - Re-test: the payload's keys are stripped; `{}` has no `shell`.

Practice (75 min)

  1. PortSwigger Academy "Prototype pollution" track (client-side via various sources and gadgets, browser-API-based, and server-side pollution to RCE and to other impacts) — do all of it. Use DOM Invader's prototype-pollution mode.
  2. PortSwigger "DOM clobbering" lab; and the mXSS / sanitiser-bypass material in the XSS track.
  3. Locally: a Node app with lodash.merge(config, req.body) and a downstream child_process call. Achieve RCE via __proto__. Then apply each defence and re-test.
  4. A client-side app that does let opts = {}; render(userData, opts) where render reads opts.template; pollute via ?__proto__[template]=<img onerror>; fix.
  5. Run ppmap / a prototype-pollution scanner against a lab.

Common mistakes

  • Deep-merging untrusted JSON. The core sink. Reject dangerous keys, use a schema, or don't merge untrusted data.
  • Thinking CSP stops prototype pollution. The gadget may not need a script injection at all (server-side), or may abuse a CSP-allowed path.
  • Sanitising HTML but keeping id/name. Enables DOM clobbering; also feeds mXSS. Strip or namespace them.
  • innerHTML = DOMPurify.sanitize(x) then reading .innerHTML and setting it again. The round-trip is where mXSS lives.
  • Old DOMPurify. mXSS bypasses are found periodically; pin and update.
  • Security state in window/document globals. Clobberable. Use modules and explicit checks.

Check yourself

  1. What does prototype pollution corrupt, and why does that affect code that never touched the attacker's input?
  2. Give a server-side (Node) prototype-pollution-to-RCE gadget.
  3. What is DOM clobbering and when is it useful to an attacker (vs plain XSS)?
  4. What is mutation XSS, and why can a sanitiser that "passed" still result in execution?
  5. List the three input keys a merge/set function must reject.
  6. Why is "don't re-parse the sanitiser's output" a specific mXSS defence?

Further reading

  • Research: PortSwigger — "Server-side prototype pollution" (Gareth Heyes, 2023) and the client-side prototype-pollution research; "DOM Clobbering strikes back" (Gareth Heyes); the mXSS papers (Mario Heiderich et al.) and DOMPurify's security advisories.
  • Labs: PortSwigger Academy prototype-pollution and DOM-clobbering tracks.
  • Tools: DOM Invader (Burp), ppmap, protofuzz; Object.freeze / --disable-proto docs.
  • Reference: the Client-Side-Prototype-Pollution gadget list (BlackFan); Snyk's prototype-pollution advisories for common libs.

Generated from real-life-security-guide-v1.md · 8 Oct 2026 16:36