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)
- 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.
- PortSwigger "DOM clobbering" lab; and the mXSS / sanitiser-bypass material in the XSS track.
- Locally: a Node app with
lodash.merge(config, req.body)and a downstreamchild_processcall. Achieve RCE via__proto__. Then apply each defence and re-test. - A client-side app that does
let opts = {}; render(userData, opts)whererenderreadsopts.template; pollute via?__proto__[template]=<img onerror>; fix. - 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.innerHTMLand setting it again. The round-trip is where mXSS lives.- Old DOMPurify. mXSS bypasses are found periodically; pin and update.
- Security state in
window/documentglobals. Clobberable. Use modules and explicit checks.
Check yourself
- What does prototype pollution corrupt, and why does that affect code that never touched the attacker's input?
- Give a server-side (Node) prototype-pollution-to-RCE gadget.
- What is DOM clobbering and when is it useful to an attacker (vs plain XSS)?
- What is mutation XSS, and why can a sanitiser that "passed" still result in execution?
- List the three input keys a merge/set function must reject.
- 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-protodocs. - Reference: the
Client-Side-Prototype-Pollutiongadget list (BlackFan); Snyk's prototype-pollution advisories for common libs.