← Back to all writeups

Code Review: Prototype Pollution via Unsafe Object Merge

A custom deep merge utility passed user-controlled input directly into object property assignment — allowing an attacker to pollute JavaScript's Object prototype and escalate to remote code execution.

prototype-pollutionjavascriptnodejsrcecode-review

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, and prototype are reserved — treat them as forbidden input
  • hasOwnProperty does not protect against prototype pollution__proto__ passes that check
  • Object.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.