Prototype Pollution: Code Review Guide
Table of Contents
1. Introduction to Prototype Pollution
Prototype pollution is a JavaScript vulnerability that allows an attacker to inject properties into the prototype of base objects like Object.prototype. Because nearly every JavaScript object inherits from Object.prototype, a single polluted property propagates to every object in the application — enabling authentication bypasses, remote code execution, cross-site scripting, and denial of service.
Why This Matters
Prototype pollution is uniquely dangerous because its impact is global and invisible. Unlike XSS or SQLi where the exploit targets a specific code path, a single prototype pollution gadget can compromise security checks, template rendering, and command execution across the entire application — often in code the attacker never directly touches.
In this guide, you'll learn how JavaScript's prototype chain creates this vulnerability, how to identify dangerous merge/clone/set patterns during code review, how server-side pollution leads to RCE and auth bypasses, how client-side pollution enables XSS via template engines, and how to prevent prototype pollution with secure coding patterns.
JavaScript Prototype Chain & Pollution Attack
Normal Prototype Chain
After Prototype Pollution
Why does polluting Object.prototype affect all objects in a JavaScript application?
2. How Prototype Pollution Works
Prototype pollution occurs when user-controlled input is used to set properties on objects without filtering special keys like __proto__, constructor, and prototype. These keys provide direct access to an object's prototype chain.
The fundamental mechanism
1// JavaScript's prototype chain in action
2const user = { name: "Alice" };
3
4// Normal property access — found on the object itself
5console.log(user.name); // "Alice"
6
7// Property NOT on the object — walks up to Object.prototype
8console.log(user.isAdmin); // undefined
9
10// ⚠️ PROTOTYPE POLLUTION: Inject into Object.prototype
11user.__proto__.isAdmin = true;
12// This is equivalent to: Object.prototype.isAdmin = true;
13
14// Now EVERY object inherits isAdmin!
15console.log(user.isAdmin); // true
16console.log({}.isAdmin); // true ⚠️
17console.log(new Object().isAdmin); // true ⚠️
18
19const anotherUser = { name: "Bob" };
20console.log(anotherUser.isAdmin); // true ⚠️ — Bob is now admin!The __proto__ property is the most well-known vector, but constructor.prototype achieves the same result:
Alternative pollution via constructor.prototype
1const obj = {};
2
3// These two are equivalent:
4obj.__proto__.polluted = true;
5obj.constructor.prototype.polluted = true;
6
7// Both pollute Object.prototype
8console.log({}.polluted); // trueKey Insight for Code Reviewers
The vulnerability is not in __proto__ itself — it's in any code that recursively sets object properties based on user input without filtering prototype-accessing keys. The most common culprits are deep merge functions, object cloning utilities, and path-based property setters (like lodash _.set()).
3. Common Attack Vectors
Prototype pollution typically enters an application through functions that recursively merge or set object properties. During code review, these are the primary patterns to flag.
Vector 1: Vulnerable recursive merge
1// ❌ VULNERABLE: Recursive merge without key filtering
2function merge(target, source) {
3 for (const key in source) {
4 if (typeof source[key] === 'object' && source[key] !== null) {
5 if (!target[key]) target[key] = {};
6 merge(target[key], source[key]);
7 } else {
8 target[key] = source[key];
9 }
10 }
11 return target;
12}
13
14// Attacker sends this JSON body:
15const maliciousInput = JSON.parse(
16 '{"__proto__": {"isAdmin": true}}'
17);
18
19const config = {};
20merge(config, maliciousInput);
21
22// Object.prototype is now polluted!
23console.log({}.isAdmin); // trueVector 2: Path-based property setting
1// ❌ VULNERABLE: Setting nested properties by path
2function setByPath(obj, path, value) {
3 const keys = path.split('.');
4 let current = obj;
5 for (let i = 0; i < keys.length - 1; i++) {
6 if (!current[keys[i]]) current[keys[i]] = {};
7 current = current[keys[i]];
8 }
9 current[keys[keys.length - 1]] = value;
10}
11
12// Attacker controls the path:
13setByPath({}, '__proto__.isAdmin', true);
14console.log({}.isAdmin); // true
15
16// Also works with constructor:
17setByPath({}, 'constructor.prototype.isAdmin', true);
18console.log({}.isAdmin); // trueVector 3: JSON body parsing (Express.js)
1// Express.js automatically parses JSON bodies
2// An attacker sends a POST request with:
3// { "__proto__": { "isAdmin": true } }
4
5app.post('/api/settings', (req, res) => {
6 const settings = {};
7
8 // ❌ VULNERABLE: Merging user input into an object
9 Object.assign(settings, req.body);
10 // Note: Object.assign is NOT vulnerable to __proto__
11 // but custom merge functions often ARE
12
13 // ❌ VULNERABLE: Using a deep merge library
14 const _ = require('lodash');
15 _.merge(settings, req.body); // Vulnerable in lodash < 4.17.12
16});Which of the following operations is MOST likely to be vulnerable to prototype pollution?
4. Real-World Scenario: Authentication Bypass
The Scenario: You're reviewing a Node.js Express application with a user settings endpoint that uses a custom merge function. The application checks user.role for authorization.
Vulnerable application: settings update + role check
1const express = require('express');
2const app = express();
3app.use(express.json());
4
5// ❌ VULNERABLE: Custom merge without prototype key filtering
6function deepMerge(target, source) {
7 for (const key of Object.keys(source)) {
8 if (source[key] && typeof source[key] === 'object') {
9 if (!target[key]) target[key] = {};
10 deepMerge(target[key], source[key]);
11 } else {
12 target[key] = source[key];
13 }
14 }
15 return target;
16}
17
18// User settings update endpoint
19app.put('/api/user/settings', authMiddleware, (req, res) => {
20 const user = getUserFromDB(req.userId);
21
22 // ❌ Merging user-controlled body into user object
23 deepMerge(user, req.body);
24 saveUserToDB(user);
25
26 res.json({ message: 'Settings updated' });
27});
28
29// Admin-only endpoint
30app.get('/api/admin/users', authMiddleware, (req, res) => {
31 const user = getUserFromDB(req.userId);
32
33 // ❌ This check is now bypassed!
34 if (user.role !== 'admin') {
35 return res.status(403).json({ error: 'Forbidden' });
36 }
37
38 res.json(getAllUsers());
39});The Attack: The attacker sends a PUT request to /api/user/settings with the body:
Malicious request body
1{
2 "__proto__": {
3 "role": "admin"
4 }
5}The deepMerge function processes __proto__ as a regular key, setting Object.prototype.role = "admin". Now every object without its own role property inherits "admin". The authorization check user.role !== 'admin' passes for ALL users — even those without a role explicitly set in the database.
Why This Is Especially Dangerous
Unlike a typical auth bypass that targets one user, prototype pollution grants admin access to every user in the system simultaneously. Worse, the pollution persists in the Node.js process until it restarts — there's no automatic cleanup. A single request can compromise the entire application.
After the prototype pollution attack, the developer adds user.role = 'member' explicitly for all users in the database. Does this fix the vulnerability?
5. Server-Side Prototype Pollution
Server-side prototype pollution in Node.js is particularly dangerous because it can escalate to Remote Code Execution (RCE). The key mechanism is polluting options objects consumed by Node.js built-in modules like child_process.
Prototype pollution to RCE via child_process
1// Step 1: Attacker pollutes Object.prototype via a vulnerable merge
2// payload: { "__proto__": { "shell": true, "NODE_OPTIONS": "--require /proc/self/environ" } }
3
4// Step 2: Somewhere in the application, child_process.exec or
5// child_process.fork is called:
6const { execSync } = require('child_process');
7
8// execSync reads options from its argument, but ALSO inherits
9// from Object.prototype for any options not explicitly set
10const output = execSync('ls');
11
12// With "shell": true polluted into the prototype, execSync
13// may interpret the command differently, allowing injection.
14
15// Even more dangerous: polluting "env" or "NODE_OPTIONS"
16// to inject environment variables into spawned processes.Known RCE Gadgets
Security researchers have documented multiple prototype pollution-to-RCE gadgets in the Node.js ecosystem: child_process.spawn/exec (polluting shell, env, or NODE_OPTIONS), ejs template engine (polluting outputFunctionName to inject code), pug/jade (polluting self-closing tags), and handlebars (polluting allowProtoMethodsByDefault). During code review, any application that merges user input AND spawns processes or renders templates is at critical risk.
RCE via EJS template engine pollution
1// EJS looks for a property called "outputFunctionName" in its options
2// If polluted, it gets injected into generated template code
3
4// Step 1: Attacker pollutes via vulnerable merge:
5// { "__proto__": { "outputFunctionName": "x;process.mainModule.require('child_process').execSync('id');x" } }
6
7// Step 2: Application renders an EJS template:
8const ejs = require('ejs');
9ejs.render('<h1><%= title %></h1>', { title: 'Hello' });
10
11// The polluted outputFunctionName is used in code generation,
12// resulting in arbitrary command execution on the server!This escalation path makes prototype pollution one of the few client-input vulnerabilities that can achieve full server compromise through a single HTTP request.
6. Client-Side Prototype Pollution
Client-side prototype pollution targets JavaScript running in the browser. The primary impact is XSS — by polluting properties that template engines, DOM manipulation libraries, or framework internals rely on.
Client-side pollution via URL parameters
1// Many SPAs parse URL parameters into objects
2// URL: https://app.com/?__proto__[innerHTML]=<img/src/onerror=alert(1)>
3
4function parseQueryParams(query) {
5 const params = {};
6 const pairs = query.replace('?', '').split('&');
7
8 for (const pair of pairs) {
9 const [key, value] = pair.split('=');
10 const keys = key.replace(/\]/g, '').split('[');
11
12 // ❌ VULNERABLE: Recursively setting nested properties
13 let current = params;
14 for (let i = 0; i < keys.length - 1; i++) {
15 if (!current[keys[i]]) current[keys[i]] = {};
16 current = current[keys[i]];
17 }
18 current[keys[keys.length - 1]] = decodeURIComponent(value);
19 }
20 return params;
21}
22
23// Parsing the malicious URL pollutes Object.prototype.innerHTML
24const params = parseQueryParams(window.location.search);Once the prototype is polluted on the client, the attacker needs a gadget — a piece of existing code that reads the polluted property and performs a dangerous action. Common gadgets include:
- innerHTML assignment — If a library checks
element.innerHTMLoroptions.innerHTMLand the property is inherited from the polluted prototype, it renders attacker-controlled HTML. - jQuery $.extend() — Older versions of jQuery's deep extend are vulnerable. Polluted properties can influence DOM manipulation.
- Script src injection — Polluting a
srcorhrefproperty that a library uses to dynamically load resources. - Event handler injection — Polluting
onclick,onerror, or similar properties that libraries read from option objects.
A user visits: https://app.com/?__proto__[transport_url]=data:,alert(1)//. The application uses a library that reads config.transport_url to load a script. What happens?
7. Detection During Code Review
Prototype pollution requires two ingredients: a source (where attacker input enters an unsafe merge/set operation) and a gadget (where a polluted property is consumed dangerously). During code review, systematically look for both.
Code Review Detection Patterns
| Pattern | What to Look For | Risk |
|---|---|---|
| Custom merge/extend | Functions that recursively copy properties from one object to another, especially if input comes from req.body, query params, or external data | Critical |
| Path-based setters | Functions like set(obj, path, value) where path is user-controlled — check for lodash _.set(), dot-prop, etc. | Critical |
| Object.keys() loops | for...in or Object.keys() iteration that assigns properties from untrusted sources without filtering __proto__, constructor, prototype | High |
| JSON.parse + merge | Parsed JSON body being spread or merged into configuration or state objects | High |
| URL query parsing | Custom query string parsers that handle bracket notation (param[key]=value) for nested objects | High |
| Template options | Template engine render calls where options might inherit polluted properties (ejs, pug, handlebars) | Critical (gadget) |
| child_process calls | spawn/exec/fork calls where options inherit from prototype (shell, env, NODE_OPTIONS) | Critical (gadget) |
Quick grep patterns for prototype pollution sources
1// Search your codebase for these patterns:
2
3// Custom merge functions
4// grep -r "function.*merge" --include="*.js"
5// grep -r "function.*extend" --include="*.js"
6// grep -r "function.*deepCopy" --include="*.js"
7
8// Dangerous property access patterns
9// grep -r "__proto__" --include="*.js"
10// grep -r "constructor\[" --include="*.js"
11
12// Known vulnerable library functions
13// grep -r "_.merge\|_.defaultsDeep\|_.set" --include="*.js"
14// grep -r "$.extend(true" --include="*.js"
15
16// Path-based property setters
17// grep -r "dot-prop\|set-value\|object-path" package.jsonYou find this code during review: Object.keys(req.body).forEach(key => config[key] = req.body[key]). Is this vulnerable to prototype pollution?