Prototype Pollution Attacks
The Attack That Changes Every Object at Once
Prototype pollution is deceptively simple: an attacker modifies Object.prototype, and suddenly every object in your application inherits the attacker's properties. It's not a browser bug. It's a language feature being abused — and it has led to critical vulnerabilities in some of the most downloaded npm packages on earth.
Here's the core of it:
const malicious = JSON.parse('{"__proto__": {"isAdmin": true}}')
merge({}, malicious) // if merge is naive, this pollutes Object.prototype
const user = {}
console.log(user.isAdmin) // true — every object is now an admin
One bad merge() call, and your entire application's security model is compromised. Authorization checks that rely on user.isAdmin being undefined suddenly see true. Template engines that check for properties on options objects find attacker-controlled values. Server-side code that uses polluted properties in shell commands gets remote code execution.
Think of Object.prototype as the water supply for an entire city. Every building (object) gets its water (properties) from this supply. Prototype pollution is like poisoning the reservoir — you don't need to target individual buildings. One injection point, and every building is affected. The defense isn't filtering each building's water at the tap — it's protecting the reservoir itself.
How Prototype Pollution Works
Every JavaScript object has a prototype chain. When you access a property that doesn't exist on the object, the engine walks up the chain until it finds it or reaches null:
const obj = { name: 'Alice' }
obj.toString() // found on Object.prototype, not on obj itself
Pollution happens when an attacker writes to Object.prototype through an unsafe operation:
// Direct pollution (rarely possible in real apps)
Object.prototype.polluted = 'yes'
// Indirect pollution through __proto__ (the real attack vector)
const payload = JSON.parse('{"__proto__": {"polluted": "yes"}}')
// A naive recursive merge function walks into the trap:
function merge(target, source) {
for (const key in source) {
if (typeof source[key] === 'object' && source[key] !== null) {
if (!target[key]) target[key] = {}
merge(target[key], source[key])
} else {
target[key] = source[key]
}
}
return target
}
merge({}, payload)
// Now Object.prototype.polluted === 'yes'
// Every object in the runtime is affected
The merge function hits __proto__ as a key, traverses into it (which points to Object.prototype), and writes polluted: 'yes' directly onto the prototype.
Real CVEs: When Pollution Ships to Production
CVE-2019-10744: lodash.merge and lodash.defaultsDeep
Lodash, with over 200 million weekly npm downloads, had a prototype pollution vulnerability in its merge and defaultsDeep functions. An attacker who could control the input to these functions could pollute Object.prototype.
const lodash = require('lodash')
lodash.merge({}, JSON.parse('{"__proto__": {"isAdmin": true}}'))
const user = {}
console.log(user.isAdmin) // true
This was particularly dangerous because lodash is used everywhere — including server-side code where pollution could escalate to denial of service or remote code execution through template engines.
CVE-2024-21529: dset
The dset package (deep object setter) was vulnerable because it didn't guard against __proto__ in property paths:
const dset = require('dset')
const obj = {}
dset(obj, '__proto__.polluted', true)
// Object.prototype.polluted === true
Serialization Library Vulnerabilities
A 2024 vulnerability in a popular serialization library allowed attackers to craft payloads that manipulate object prototypes or assign array prototype methods to object properties through the library's parse function.
CVE-2024-38986: @75lb/deep-merge
This package relied on lodash's merge methods internally, inheriting the vulnerability. This is a supply chain amplification — one vulnerable function in a dependency pollutes every package that uses it.
Exploitation Chains: From Pollution to Impact
Prototype pollution by itself just adds properties to objects. The real damage comes when polluted properties are read by other code in security-sensitive contexts.
Chain 1: Pollution to XSS (via template engines)
Many template engines check for options on the data object:
// After pollution: Object.prototype.escape = false
const template = handlebars.compile('{{userInput}}')
// Handlebars checks if escape is set on the options object
// Finds it on Object.prototype (set to false)
// Renders unescaped HTML — XSS
Chain 2: Pollution to Denial of Service
// After pollution: Object.prototype.length = 0
// Any code that checks .length on an object breaks
const items = getItems() // returns { a: 1, b: 2 }
if (items.length === 0) {
// This branch executes even though items has properties
// because items.length finds 0 on the prototype
}
Chain 3: Pollution to Authorization Bypass
// After pollution: Object.prototype.role = 'admin'
function checkAccess(user) {
if (user.role === 'admin') {
return true // grants admin access to everyone
}
return false
}
const regularUser = { name: 'Alice' } // no role property
checkAccess(regularUser) // returns true — role found on prototype
The most dangerous thing about prototype pollution is that the vulnerable code and the exploitable code can be in completely different parts of the application — or even in different libraries. The merge vulnerability might be in a configuration parser, but the exploitation happens in a template engine or authorization check that reads the polluted property. This makes it incredibly hard to find through traditional code review of individual modules.
Defensive Patterns That Actually Work
1. Freeze Object.prototype (Nuclear Option)
Object.freeze(Object.prototype)
This prevents any modification to Object.prototype. The downside: some libraries legitimately polyfill methods on prototypes. Test thoroughly before deploying.
2. Use Object.create(null) for Untrusted Data
// Regular objects inherit from Object.prototype
const config = {} // config.__proto__ === Object.prototype
// Null-prototype objects have no prototype chain
const safeConfig = Object.create(null)
// safeConfig.__proto__ === undefined
// Pollution of Object.prototype doesn't affect this object
3. Guard Merge Functions
const BLOCKED_KEYS = new Set(['__proto__', 'constructor', 'prototype'])
function safeMerge(target, source) {
for (const key of Object.keys(source)) {
if (BLOCKED_KEYS.has(key)) continue
if (typeof source[key] === 'object' && source[key] !== null && !Array.isArray(source[key])) {
if (!target[key] || typeof target[key] !== 'object') {
target[key] = {}
}
safeMerge(target[key], source[key])
} else {
target[key] = source[key]
}
}
return target
}
Key changes from the naive version:
- Use
Object.keys()instead offor...in— skips inherited properties - Explicitly block
__proto__,constructor, andprototype
4. Use Map Instead of Object for Dynamic Keys
// BAD: user-controlled keys go directly onto an object
const settings = {}
settings[userKey] = userValue // if userKey is '__proto__', pollution
// GOOD: Map doesn't have prototype chain issues
const settings = new Map()
settings.set(userKey, userValue) // safe regardless of key value
5. Schema Validation at Boundaries
import { z } from 'zod'
const UserInputSchema = z.object({
name: z.string(),
email: z.string().email(),
}).strict() // rejects unknown keys like __proto__
const validated = UserInputSchema.parse(untrustedInput)
Production Scenario: Auditing for Prototype Pollution
When reviewing a codebase for prototype pollution risk, search for these patterns:
// HIGH RISK: dynamic property assignment with user input
obj[userInput] = value
obj[a][b] = value // nested path assignment
// HIGH RISK: deep merge/extend/assign with untrusted data
_.merge(config, userSettings)
Object.assign(target, untrusted)
{ ...defaults, ...untrusted } // shallow spread is safe, but watch for nested
// HIGH RISK: JSON.parse without schema validation
const data = JSON.parse(req.body)
deepMerge(state, data)
// MEDIUM RISK: bracket notation with computed keys
function setPath(obj, path, value) {
const keys = path.split('.')
let current = obj
for (const key of keys) {
current = current[key] // traverses __proto__ if key is '__proto__'
}
}
Automated detection: use ESLint with eslint-plugin-security which flags some of these patterns, and run npm audit regularly to catch known vulnerable dependencies.
| What developers do | What they should do |
|---|---|
| Assuming spread syntax prevents prototype pollution Spread ({...obj}) creates a shallow copy. The new object does not inherit polluted properties. But if obj contains nested objects, those are shared by reference — pollution of their prototypes still affects the spread result. | Understanding that spread is safe for shallow copies but nested objects still reference the original prototype chain |
| Only blocking __proto__ in merge functions constructor.prototype is another path to Object.prototype. An attacker can use {constructor: {prototype: {polluted: true}}} to bypass __proto__-only checks. Block all three keys and use Object.keys() to skip inherited properties. | Also blocking constructor and prototype keys, and using Object.keys() instead of for-in |
| Relying on JSON.parse to strip __proto__ JSON.parse preserves __proto__ as a regular key in the parsed object. While it does not set it on the prototype during parsing, passing the result to a vulnerable merge function will trigger pollution. Always validate parsed JSON against a schema before using it. | Using a reviver function or schema validation after parsing |
Try to solve it before peeking at the answer.
function loadConfig(defaults, userOverrides) {
const config = {}
for (const key in defaults) {
config[key] = defaults[key]
}
const parsed = JSON.parse(userOverrides)
for (const key in parsed) {
if (typeof parsed[key] === 'object') {
config[key] = config[key] || {}
for (const subKey in parsed[key]) {
config[key][subKey] = parsed[key][subKey]
}
} else {
config[key] = parsed[key]
}
}
return config
}- 1Never use for-in loops on untrusted data — use Object.keys() or Object.entries() to skip inherited properties
- 2Block __proto__, constructor, and prototype keys in any merge or deep-set function
- 3Use Object.create(null) for dictionaries that hold untrusted data — no prototype to pollute
- 4Validate all JSON input against a schema before merging into application state
- 5Run npm audit regularly — prototype pollution CVEs in popular packages are discovered every few months
- 6The vulnerable code and the exploitable code are often in different modules — look for pollution sources and exploitation sinks separately