01 //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.
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.
Every object now inherits isAdmin: true — every obj.isAdmin check passes.
isAdmin = true inherited on every user object.
Pollute child_process options (shell, env, NODE_OPTIONS).
Pollute template engine props (EJS, Pug, Handlebars).
Crash on polluted toString / valueOf.
Why does polluting Object.prototype affect all objects in a JavaScript application?
02 //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.
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:
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); // trueThe 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()).
03 //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.
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); // true1// ❌ 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); // true1// 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?
04 //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.
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:
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.
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?
05 //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.
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.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.
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.
06 //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.
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?
07 //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) |
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?