Prototype Pollution is one of those vulnerabilities that looks completely harmless in isolation. A utility function. A merge helper. Nothing that screams “security issue” on first read.
But in JavaScript, every object inherits from Object.prototype. Pollute that — and you affect every object in the entire runtime. That’s where things get interesting.
The Code
function deepMerge(target, source) {
for (const key in source) {
if (source.hasOwnProperty(key)) {
if (typeof source[key] === 'object' && source[key] !== null) {
target[key] = target[key] || {};
deepMerge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
}
return target;
}
app.post('/api/config', (req, res) => {
const config = deepMerge({}, req.body);
res.json({ success: true, config });
});
Read it carefully. The vulnerability isn’t obvious — but it’s there.
Breaking It Down
deepMerge recursively copies properties from source into target. For each key in source:
- If the value is an object — recurse deeper
- If the value is a primitive — assign it directly
The endpoint at /api/config calls deepMerge({}, req.body) — merging user-controlled JSON body into a fresh empty object. Looks safe because we start with {}. But that assumption is wrong.
Understanding JavaScript’s Prototype Chain
Every JavaScript object has a hidden link to its prototype. When you access a property that doesn’t exist on an object, JavaScript walks up the prototype chain looking for it.
const obj = {};
console.log(obj.toString); // found on Object.prototype
The key insight: if you can set a property on Object.prototype, that property becomes available on every object in the application — because every object inherits from it.
That’s prototype pollution.
The Vulnerability — __proto__ Key Traversal
JavaScript objects have a special property — __proto__ — which is a reference to the object’s prototype. When you do:
someObject['__proto__']['polluted'] = true;
You’re not setting a property on someObject. You’re setting it on Object.prototype itself. Now every object in the runtime has polluted === true.
The deepMerge function never checks whether a key is __proto__. So when an attacker sends this JSON body:
{
"__proto__": {
"isAdmin": true
}
}
Here’s what happens step by step:
// target = {}, source = { "__proto__": { "isAdmin": true } }
for (const key in source) {
// key = "__proto__"
if (source.hasOwnProperty("__proto__")) { // true
if (typeof source["__proto__"] === 'object') { // true
target["__proto__"] = target["__proto__"] || {};
// target["__proto__"] is Object.prototype itself!
deepMerge(target["__proto__"], { isAdmin: true });
// Now Object.prototype.isAdmin = true
}
}
}
Object.prototype.isAdmin is now true — for every object in the entire Node.js process.
Attack 1 — Authorization Bypass
If anywhere in the application an authorization check looks like this:
function checkAdmin(user) {
if (user.isAdmin) {
return true;
}
return false;
}
After poisoning Object.prototype.isAdmin = true, any object passed to checkAdmin will pass the check — even an empty object {}.
checkAdmin({}); // true — because {}.isAdmin inherits from prototype
checkAdmin(req.user); // true — regardless of actual user role
Authentication controls bypassed without touching a single user record.
Attack 2 — Remote Code Execution via Child Process
In Node.js, when a child process is spawned, it inherits options from the options object. If Object.prototype is poisoned with a shell property, Node.js may use it when spawning processes.
Send this payload:
{
"__proto__": {
"shell": "node",
"env": {
"NODE_OPTIONS": "--require /proc/self/fd/0"
}
}
}
Or more directly via execArgv pollution depending on the Node.js version and how child processes are spawned in the target application. The exact gadget chain varies — but the principle is consistent: polluted prototype properties leak into internal Node.js operations.
This is how prototype pollution escalates from logic bypass to full RCE.
Attack 3 — Application-Wide State Corruption
Even without a specific gadget chain, polluting prototype properties can cause widespread application instability:
{
"__proto__": {
"toString": "corrupted",
"valueOf": "corrupted"
}
}
Overwriting built-in methods on Object.prototype breaks any code that calls .toString() or .valueOf() on an object — crashing request handlers, corrupting logs, or causing unpredictable behaviour across the entire application.
Why hasOwnProperty Doesn’t Help Here
The code uses source.hasOwnProperty(key) which looks like a safety check. It isn’t — not for this attack.
hasOwnProperty checks whether the property belongs directly to the object, not its prototype. When the attacker sends {"__proto__": {...}}, __proto__ IS a direct property of the parsed JSON object — so hasOwnProperty("__proto__") returns true.
The check passes. The traversal continues. The pollution happens.
Root Cause
One missing check:
// Missing — key is never validated before assignment
target[key] = source[key];
The function blindly assigns any key from user input — including __proto__, constructor, and prototype — all of which have special meaning in JavaScript’s object model.
The Fix
Fix 1 — Block dangerous keys explicitly:
function deepMerge(target, source) {
const dangerousKeys = ['__proto__', 'constructor', 'prototype'];
for (const key in source) {
if (source.hasOwnProperty(key)) {
// Reject any key that touches the prototype chain
if (dangerousKeys.includes(key)) continue;
if (typeof source[key] === 'object' && source[key] !== null) {
target[key] = target[key] || {};
deepMerge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
}
return target;
}
Fix 2 — Use Object.create(null) for the target:
// Creates an object with NO prototype — nothing to pollute
const config = deepMerge(Object.create(null), req.body);
An object created with Object.create(null) has no prototype chain. Assigning to __proto__ on it has no effect on Object.prototype.
Fix 3 — Use Object.defineProperty with safe assignment:
function safeSet(target, key, value) {
if (Object.getOwnPropertyDescriptor(Object.prototype, key)) {
return; // Skip keys that exist on Object.prototype
}
target[key] = value;
}
Fix 4 — Validate and schema-check incoming JSON:
const Joi = require('joi');
const configSchema = Joi.object({
theme: Joi.string().valid('light', 'dark'),
language: Joi.string().max(10),
// define exactly what's allowed
}).unknown(false); // reject unknown keys entirely
Schema validation at the API boundary rejects unexpected keys like __proto__ before they ever reach the merge function.
Key Takeaways
- Never blindly merge user-controlled objects — always validate keys before assignment
__proto__,constructor, andprototypeare reserved — treat them as forbidden inputhasOwnPropertydoes not protect against prototype pollution —__proto__passes that checkObject.create(null)eliminates the prototype chain entirely — use it for data containers that process untrusted input- Prototype pollution can escalate to RCE — especially in Node.js where spawned processes inherit polluted properties
- Schema validation is the cleanest defence — define what’s allowed and reject everything else
Found this useful? More code reviews coming. Hit me up on X if you want to discuss.