Skip to content

CWE-1321: Improperly Controlled Modification of Object Prototype Attributes ('Prototype Pollution')

Overview

Every object literal in JavaScript inherits from Object.prototype, and a property written onto that one object is visible from every other object in the process that does not shadow it. Prototype pollution is the weakness where untrusted input reaches that write - typically because a key from a request body is used to index into an object graph, and one of the keys along the way is __proto__, constructor or prototype.

The parse step is not the pollution step, and confusing the two sends the fix to the wrong place. JSON.parse('{"__proto__":{"isAdmin":true}}') creates an ordinary own property named __proto__ - measured on Node 24.3.0, Object.getOwnPropertyNames() on the result returns [ '__proto__' ] and Object.prototype is untouched. Express is the same: express.json() on Express 5.2.1 hands the handler a body carrying that own key, with nothing polluted yet. The payload is inert data until something walks it - a recursive merge, a set(obj, path, value) helper, a config loader - and that walk is what performs the write.

Primary Defence: Do not walk untrusted keys into an object graph. Validate the input against a schema and use the parsed result, or hold user-controlled keys in a Map or a null-prototype object. Where a deep merge is needed, use a maintained implementation that refuses these keys rather than writing one.

Relationship to Other CWEs

  • CWE-1321 (this page) - the JavaScript case, where the attribute lands on a prototype and the blast radius is every object in the process rather than one record.
  • CWE-915 - the parent, and the general case: untrusted input decides which attributes of an object get set. A mass-assignment finding that sets isAdmin on a user document is CWE-915; the same request setting isAdmin on Object.prototype is CWE-1321.
  • CWE-502 - where prototype pollution often arrives. It is the deserialization risk that survives the move to JSON.parse: the parser is safe, the code that consumes its output frequently is not. The CWE-502 JavaScript page covers the merge shape from the deserialization side.
  • CWE-913 - where MITRE's simplified mapping (view-1003) puts CWE-1321 directly, while the research view (view-1000) reaches it through CWE-915. A scanner following view-1003 reports CWE-1321 for anything in this family, including a plain mass-assignment finding with no prototype in it - check what the code actually writes to before re-triaging.

Risk

High: The write is process-wide and lasts for the life of the process, so a single request can change how every later request behaves, including unauthenticated ones. What that is worth to an attacker depends on the gadget - some code that reads a property it never set. An options object checked with if (opts.isAdmin), a template engine reading an inherited field, or a library that branches on an option the caller never passed all turn a polluted prototype into privilege escalation, authentication bypass or denial of service. Where a polluted property reaches a child_process option or a template compiler, it reaches remote code execution.

Common Vulnerable Patterns

Recursive merge of a parsed request body

// VULNERABLE - the merge walks whatever keys the request supplies
function merge(target, source) {
    for (const key of Object.keys(source)) {
        if (source[key] && typeof source[key] === 'object') {
            target[key] = target[key] || {};
            merge(target[key], source[key]);       // recurses into Object.prototype
        } else {
            target[key] = source[key];
        }
    }
    return target;
}

app.post('/settings', express.json(), (req, res) => {
    const settings = merge({ theme: 'light' }, req.body);
    res.json(settings);
});

// Attack: POST /settings  {"theme":"dark","__proto__":{"isAdmin":true}}
// Result on Node 24.3.0: ({}).isAdmin === true, for every object in the process

Why this is vulnerable:

  • Object.keys(source) includes __proto__, because JSON.parse created it as an own property rather than as a prototype change. The merge then evaluates target['__proto__'], which reads Object.prototype - a truthy object, so the || {} never fires - and the recursive call assigns isAdmin onto it.
  • The obvious fix is not the fix. Rejecting the literal key __proto__ leaves {"constructor":{"prototype":{"isAdmin":true}}}, which reaches the same object by a different route; both were measured to pollute.

A path helper with an attacker-controlled path

// VULNERABLE - the path decides which object gets written
function set(obj, path, value) {
    const keys = path.split('.');
    let node = obj;
    for (const key of keys.slice(0, -1)) {
        node[key] = node[key] || {};
        node = node[key];
    }
    node[keys.at(-1)] = value;
}

app.patch('/profile', express.json(), (req, res) => {
    set(profile, req.body.field, req.body.value);   // field comes from the request
    res.sendStatus(204);
});

// Attack: {"field":"__proto__.isAdmin","value":true}
// Attack: {"field":"constructor.prototype.isAdmin","value":true}

Why this is vulnerable:

  • The intermediate node = node[key] step is the whole weakness: once key is __proto__, node is Object.prototype, and the final assignment writes a property onto it. Measured on Node 24.3.0, both payloads above leave ({}).isAdmin === true.
  • Validating value does nothing here. It is the path, not the value, that chooses the target object.

A single dynamic write, which is the near miss

// VULNERABLE - but not in the way it is usually described
const target = {};
const key = req.body.key;          // "__proto__"
target[key] = req.body.value;      // { isAdmin: true }

Why this is vulnerable:

  • This one does not pollute Object.prototype. target['__proto__'] = obj invokes the inherited __proto__ setter, which replaces that object's prototype; measured on Node 24.3.0, Object.getPrototypeOf(target) changes and ({}).isAdmin stays undefined. Object.assign(target, JSON.parse(payload)) behaves the same way, because it assigns through the setter rather than defining an own property.
  • It is still a defect, and it is worth knowing which one. The value never becomes an own property, so the object silently does not hold what the code just put in it, and every later read of an unrelated key now resolves against an attacker-supplied prototype. It becomes pollution the moment any code indexes one level deeper - which is what the two patterns above do.
  • Spread is different again: { ...JSON.parse(payload) } defines __proto__ as an own data property and leaves the prototype alone, so the copy carries a live payload into whatever consumes it next.

Secure Patterns

Validate against a schema and use the parsed result

// SECURE - the object that reaches application code is built from declared fields only
const { z } = require('zod');

const settingsSchema = z.object({
    theme: z.enum(['light', 'dark']),
    pageSize: z.number().int().min(10).max(100),
});

app.post('/settings', express.json(), (req, res) => {
    const settings = { ...defaults, ...settingsSchema.parse(req.body) };
    res.json(settings);
});

Why this works:

  • The parsed result contains only the keys the schema declares. Measured on Zod 4.4.3, parsing {"a":1,"__proto__":{"polluted":true}} against z.object({ a: z.number() }) returns an object whose own properties are [ 'a' ], with Object.prototype as its prototype and no payload left to carry.
  • The return value is what makes this hold, not the validation. Spreading req.body after validating it puts the payload straight back - the same mistake the CWE-915 JavaScript page records for mass assignment. Spread the parsed object.

Hold user-controlled keys in a Map or a null-prototype object

// SECURE - a Map has no prototype chain to walk into
const counters = new Map();
counters.set(req.body.key, (counters.get(req.body.key) ?? 0) + 1);

// SECURE - a null-prototype object where a plain object is required
const bag = Object.create(null);
bag[req.body.key] = req.body.value;    // '__proto__' becomes an ordinary own key

Why this works:

  • Map keys are values, not property names, so __proto__ is a string like any other - measured, new Map(Object.entries(payload)).get('__proto__') returns the attacker's object and nothing inherits from it.
  • Object.create(null) has no __proto__ accessor to invoke and no Object.prototype above it, so the write that changes an ordinary object's prototype instead creates an own key. Use this for anything that is a flat dictionary of caller-supplied keys - parsed headers, feature flags, per-tenant config.
  • This protects the target, and the protection is exactly one level deep. Measured on Node 24.3.0: the vulnerable merge above into a null-prototype target leaves ({}).isAdmin undefined for {"__proto__":{"isAdmin":true}}, and pollutes for {"profile":{"__proto__":{"isAdmin":true}}} - because bag.profile is created by the helper's own target[key] || {} as an ordinary object, and the recursion walks through that one. Neither of these patterns fixes a walker; use the schema above or the maintained merge below for that, and read a null-prototype target as hardening for the flat case rather than as the fix.

Use a maintained merge rather than writing one

// SECURE - lodash refuses the three prototype keys internally
const _ = require('lodash');

const settings = _.merge({}, defaults, req.body);
_.set(config, allowedPath, value);

Why this works:

  • Measured on lodash 4.18.1, _.merge, _.defaultsDeep and _.set all leave Object.prototype clean when handed {"__proto__":{"polluted":true}} or the path __proto__.p2. The check is inside the library, so it holds for every call site rather than for the one someone remembered to guard.
  • This is the same reasoning as using a library encoder instead of a hand-written one: the hard part is the list of keys and the routes to them, and that part is where hand-rolled versions fail. Keep the dependency current - the protections were added in response to CVEs, and a vendored copy that has not been updated does not have them.

Harden the runtime as defence in depth

// SECURE (defence in depth) - run this at startup, before any request is served
Object.freeze(Object.prototype);

// Or start the process with the prototype accessor disabled entirely:
//   node --disable-proto=throw server.js

Why this works:

  • Freezing makes the write fail. Measured on Node 24.3.0, the vulnerable merge above leaves Object.prototype clean once it is frozen. Know which failure you get: in strict mode or an ES module the assignment throws TypeError: Cannot add property x, object is not extensible, and in sloppy mode it fails silently - so a frozen prototype can turn an exploit into an unexplained "the setting did not save".
  • --disable-proto=throw removes the __proto__ accessor: measured, obj['__proto__']['x'] = 1 throws Error: Accessing Object.prototype.__proto__ has been disallowed. It does not touch the constructor.prototype route, and JSON.parse still produces the own __proto__ key, so it narrows the attack surface rather than closing it.
  • Both are process-wide changes and neither replaces the primary fix. They are worth having where the risk is a dependency you do not control.

Considerations

  • Exploitability depends on finding a gadget, and the absence of one today is not a fix. Look for the reads - opts.timeout ?? 30, if (config.debug), a template engine resolving an inherited field - across the whole process, including in dependencies. A gadget can also arrive with the next npm update, which is why "no gadget found" is a reason to prioritise lower rather than to close.
  • Freezing Object.prototype is a compatibility decision as much as a security one. Libraries that extend the prototype at load time will fail, and in sloppy mode they fail silently. Freeze after the module graph has loaded, and put it through a full staging run rather than shipping it with the patch.
  • Where the data really is a dictionary of user-controlled keys, sanitising the keys is the wrong shape of answer. A Map removes the question. Reach for key filtering only where an existing object-shaped API cannot be changed.
  • Scope the fix to every walker, not to the reported one. The finding names one merge; the same codebase usually holds a config loader, a query-string expander and a test helper doing the same walk. The reported line is a sample.

Common Pitfalls

  • Filtering keys at the top level only. Rejecting __proto__ from Object.keys(req.body) before the merge misses {"profile":{"__proto__":{"isAdmin":true}}}, because the dangerous key is one level down and the recursion reaches it anyway. The check has to sit inside the walk, at every level, which is most of the argument for using a maintained merge instead.
  • Treating a parsed body as safe data. The own __proto__ key survives everything that re-walks keys: measured on Node 24.3.0, structuredClone preserves it, and so does any hand-written deep copy. Passing a request body through a clone or a queue does not clean it.
  • Assuming the body parser handled it. Content types behave differently, measured on Express 5.2.1 with the same __proto__ payload: express.urlencoded({ extended: true }) drops the key outright, because qs 6.15.3 refuses it; express.urlencoded({ extended: false }) keeps __proto__[isAdmin] as one literal key name it never expands; and express.json() passes a real, nested __proto__ object through as an own property. None of the three pollutes anything by itself, but only the JSON body arrives carrying a payload a merge can act on. Testing the form-encoded route and concluding the application is safe is a false negative.
  • Fixing the spread and leaving the merge. Object.assign and spread are the two shapes that read as dangerous and are not the ones that pollute; replacing them while the recursive merge stays touches the wrong lines. Find the code that indexes into node[key] and writes one level deeper.
  • Allowlisting the prefix of a path and not the rest of it. With the hand-written helper above, set(config, 'features.' + req.body.name, value) is still exploitable when name is x.__proto__.isAdmin: the allowlist governed the part the attacker did not control, and the walk still reaches the prototype through the part they did.

Testing

Re-running the detector proves the pattern is gone from one file, not that the process is clean, and a fix that filters keys can also break legitimate nested updates - so assert both directions.

  • Malicious input, JSON route: POST {"__proto__":{"isAdmin":true}}, then assert ({}).isAdmin === undefined by reading a freshly created object rather than the response body. An endpoint that echoes what it was sent reports the attacker's key either way.
  • Malicious input, the other route: repeat with {"constructor":{"prototype":{"isAdmin":true}}} and with the payload nested one level down ({"profile":{"__proto__":{"isAdmin":true}}}). A fix that passes the first and fails these two is a denylist.
  • Persistence across requests: assert the clean value from a second, unauthenticated request after the malicious one. Pollution outlives the request that caused it, and a test that only checks the polluting response misses that entirely.
  • Normal input: confirm a legitimate nested update still merges - {"profile":{"theme":"dark"}} leaves settings.profile.theme === 'dark' and does not drop sibling keys. This is what catches an over-eager filter.
  • Prototype identity: in unit tests over the parsing or merging helper, assert Object.getPrototypeOf(result) === Object.prototype and Object.getOwnPropertyNames(result) contains no prototype key. That catches the Object.assign shape, which changes the object's own prototype without polluting anything global.

Additional Resources