__proto__
the primary payload key used to inject properties directly onto Object.prototype via unsafe recursive merge functions
lodash < 4.17.11
the most widely deployed vulnerable version, affected by prototype pollution via _.merge() and _.defaultsDeep() before the January 2019 patch
RCE
achievable in Node.js when polluted prototype properties influence child_process.spawn() options or template engine rendering
Object.freeze
startup call on Object.prototype that prevents any subsequent modification, blocking all prototype pollution attempts globally

SponsoredRetool

Retool's new app builder is where AI-generated code ships safely

Building apps with AI is easy. Getting them to production safely is another story.

Start building for free today

JavaScript's prototype chain is a powerful feature that allows objects to inherit properties and methods from their prototypes. Object.prototype is at the top of this chain: every JavaScript object inherits from it, which is why all objects have methods like toString(), hasOwnProperty(), and valueOf() without explicitly defining them. Prototype pollution exploits this inheritance by injecting properties into Object.prototype itself, causing every object in the runtime to suddenly inherit those properties.

This vulnerability class was brought to mainstream security attention through research by Olivier Arteau (2018) and continued work by James Kettle at PortSwigger, who demonstrated server-side prototype pollution RCE in Node.js environments. The vulnerability typically enters through dependency code (lodash, jQuery, other deep merge utilities) or through custom merge/clone functions written without security considerations. Understanding the mechanics is necessary for both identifying vulnerable code and implementing correct fixes.

How prototype pollution works: the attack mechanics

Prototype pollution exploits a fundamental behavior of JavaScript's property lookup mechanism: when you access a property on an object that the object itself does not own, JavaScript walks up the prototype chain until it finds the property or reaches Object.prototype. An attacker who can inject properties into Object.prototype effectively injects them into every object in the runtime, because every plain object's prototype chain terminates at Object.prototype. This section covers the two primary access paths (proto and constructor.prototype), how they are triggered through unsafe merge operations on user-controlled JSON, and what an attacker can accomplish once pollution is achieved.

The __proto__ property access path

In JavaScript, obj['__proto__'] is equivalent to accessing the prototype of obj, which is Object.prototype for plain objects. An unsafe recursive merge function that does source[key] for each key in source, including keys named __proto__, will set properties on Object.prototype when it encounters {"__proto__": {"someProperty": "someValue"}}. After the merge: ({}).someProperty returns 'someValue', even though the empty object {} was created after the pollution. The prototype chain lookup finds the property on Object.prototype. Any code that relies on this check without verifying the property is the object's own property is compromised.

The constructor.prototype access path

An alternative pollution path uses the constructor property: obj['constructor']['prototype']['someProperty'] = 'value'. This is equivalent to Object.prototype['someProperty'] = 'value' for plain objects, because plain objects' constructor is Object, and Object.prototype is Object's prototype. Merge functions that recursively traverse nested object paths without filtering constructor will process this path. This bypass is important because some implementations filter __proto__ but not constructor['prototype'], leaving an alternative pollution vector.

Impact: authentication bypass via polluted property check

Consider an authorization check: if (req.user.isAdmin) { grantAccess(); }. If the attacker can trigger prototype pollution with {"__proto__": {"isAdmin": true}} before this check runs, req.user.isAdmin returns true via prototype chain lookup, even for a non-admin user object that has no own isAdmin property. The application grants access. This is bypass, not escalation: the attacker does not obtain administrative credentials, they bypass a check that assumes the absence of a property implies a denial. Checks using Object.prototype.hasOwnProperty.call(obj, 'isAdmin') instead of obj.isAdmin prevent this bypass because hasOwnProperty only returns true for own properties.

Server-side prototype pollution leading to RCE in Node.js

In Node.js, child_process.spawn() and child_process.fork() accept an options object. Some properties of this options object (env, argv0, shell) control how the spawned process is executed. Research by James Kettle demonstrated that when Object.prototype is polluted with properties that match spawn options (e.g., {"__proto__": {"shell": "/bin/bash", "argv0": "bash"}}), subsequent spawn() calls inherit those properties from Object.prototype even if the explicit options object does not set them. This can change what binary is executed or how arguments are passed, enabling command injection. The specific gadget chains depend on Node.js version and the spawn configuration used by the application.

Detection and testing

Detecting prototype pollution requires both static analysis of custom merge and clone functions and dynamic testing of API endpoints that accept arbitrary JSON. Static analysis focuses on identifying unsafe bracket-notation property assignments in recursive functions, particularly where the key name is not validated before the assignment. Dynamic testing sends proto and constructor payloads to live endpoints and then probes whether the injected properties propagate to other objects in the runtime. Both approaches are necessary because application-level prototype pollution through custom code is not detected by npm audit, which only covers known CVEs in dependencies.

Static analysis: scanning for unsafe merge patterns

Search for recursive object traversal functions that assign properties without filtering __proto__ and constructor: grep -r 'obj\[key\]\|target\[key\]\|\[prop\]\s*=' src/ and review each function for the unsafe merge pattern. Use Semgrep with the javascript.lang.security.audit.prototype-pollution rules to detect assignments through bracket notation in loops. Review all usages of lodash merge functions (_.merge, _.mergeWith, _.defaultsDeep) and verify the installed version is 4.17.11+. Check npm audit output for prototype pollution CVEs: npm audit --json | jq '.vulnerabilities | to_entries[] | select(.value.via[].title | test("prototype"; "i"))'.

Dynamic testing: confirming pollution via API probes

For server-side prototype pollution: send a POST request to any endpoint that accepts JSON with the payload {"__proto__": {"decryptionDigestPollutionTest": 1}}. Then send a GET request to the same or another endpoint and check whether the response reflects the polluted property. A simpler probe: if the API has any endpoint that returns status or debug information as a JSON object, check whether the response JSON contains decryptionDigestPollutionTest: 1 in any object (because {} .decryptionDigestPollutionTest would return 1 if polluted). Clean up pollution in a test environment by restarting the process. For client-side prototype pollution, use Burp Suite's DOM Invader or the prototype pollution browser extension.

Free daily briefing

Briefings like this, every morning before 9am.

Threat intel, active CVEs, and campaign alerts, distilled for practitioners. 50,000+ subscribers. No noise.

Prevention controls

Preventing prototype pollution requires controls at multiple layers because the vulnerability can enter through both third-party dependencies and application code. At the dependency layer, keeping lodash, jQuery, and other deep-merge utilities at patched versions and running npm audit in CI catches known CVEs before they ship. At the application code layer, filtering dangerous keys in custom merge functions and using Object.create(null) for accumulator objects eliminates application-level pollution vectors. At the runtime layer, calling Object.freeze(Object.prototype) at startup provides a global backstop that prevents any pollution from succeeding regardless of which code path triggers it. Finally, replacing property existence checks with hasOwnProperty in authorization-sensitive code limits the blast radius of any pollution that does occur.

Filter __proto__ and constructor in all merge operations

In every recursive merge, deep clone, or deep assignment function, skip keys that are named __proto__, constructor, or prototype. Example check: if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue. This is the minimum required change to make custom merge functions safe. Apply the same filter to any library-level utilities that accept raw user input and pass it through merge operations. Verify the filter covers both __proto__ and constructor['prototype'] paths.

Use Object.create(null) for accumulator objects

Objects created with Object.create(null) have no prototype. They do not inherit from Object.prototype and are immune to prototype pollution: polluting Object.prototype does not affect them because they have no prototype chain to traverse. Use Object.create(null) for configuration objects, cache dictionaries, and any object that accumulates user-controlled key-value pairs. This is also appropriate for objects used as hashmaps or sets, because it avoids property conflicts with Object.prototype's built-in properties (like toString, hasOwnProperty).

Freeze Object.prototype at application startup

Add Object.freeze(Object.prototype) at the very beginning of your application entry point, before any user input is processed or any third-party code runs. This prevents any modification to Object.prototype including pollution attempts. Test your application thoroughly after adding this, as some legacy code or libraries may attempt to add properties to Object.prototype (a practice that should be eliminated regardless). Modern npm packages generally do not modify Object.prototype, but verify with integration tests before deploying to production.

Use hasOwnProperty for property existence checks

Replace obj.property checks with Object.prototype.hasOwnProperty.call(obj, 'property') in security-relevant code paths (authorization checks, feature flag checks, configuration lookups). This ensures the check only returns true if the object has the property as its own property, not inherited from a polluted Object.prototype. Alternatively, use Object.hasOwn(obj, 'property') (available in Node.js 16.9+ and modern browsers) which is equivalent but more readable. This change limits the blast radius of any prototype pollution that occurs: even if Object.prototype is polluted, security checks using hasOwnProperty are not bypassed.

The bottom line

Prototype pollution is a JavaScript-specific vulnerability that requires JavaScript-specific fixes: filter dangerous keys in merge operations, use Object.create(null) for accumulator objects, freeze Object.prototype at startup, and use hasOwnProperty for security-critical property checks. Run npm audit regularly and address prototype pollution CVEs in dependencies promptly. Test API endpoints that accept arbitrary JSON with proto and constructor payloads using the probe technique, and use Semgrep or ESLint rules to catch unsafe merge patterns in CI. The vulnerability is consistently exploitable when present but consistently preventable with the patterns above applied at both the library layer and the application code layer.

Frequently asked questions

What is prototype pollution?

In JavaScript, every object inherits from Object.prototype, which is the top of the prototype chain. When you access a property on an object, JavaScript first checks the object itself, then its prototype, then Object.prototype. Prototype pollution exploits this by injecting properties into Object.prototype via operations like obj['__proto__']['isAdmin'] = true or obj['constructor']['prototype']['isAdmin'] = true. Once injected, every object in the runtime inherits isAdmin as a property, even newly created empty objects. Applications that check ({}).isAdmin === true as an authorization test would be bypassed.

How is prototype pollution triggered via a web API?

The attack requires a code path that recursively merges user-controlled JSON input into an object, without filtering __proto__ and constructor keys. A common vulnerable pattern is a REST API endpoint that accepts arbitrary JSON and passes it through a deep merge utility: const result = deepMerge(existingObject, JSON.parse(req.body)). If req.body contains {"__proto__": {"isAdmin": true}}, the merge operation sets Object.prototype.isAdmin = true. After this, any code that checks someObject.isAdmin without confirming the object's own property (using hasOwnProperty()) returns true for the injected value.

What can an attacker achieve via prototype pollution?

Authentication bypass: polluting a property used in authorization checks (isAdmin, role, authenticated). Template engine code injection: in Handlebars and Pug, polluted prototype properties can inject template code that executes when the template renders, achieving XSS (client-side) or RCE (server-side in Node.js). Process spawning argument injection: Node.js child_process.spawn() options can be influenced by polluted prototype properties in some configurations, enabling command execution. Application state corruption: polluting properties used in application logic (timeout values, feature flags, configuration defaults) to cause unexpected behavior. The impact depends heavily on what the application does with object properties after the pollution occurs.

How do I detect prototype pollution in my codebase?

Review all recursive merge, deep clone, and property assignment functions for __proto__ and constructor filtering. Semgrep rules detect the unsafe merge pattern: any function that uses object[key] = value where key is not validated. Check dependencies with npm audit for known prototype pollution CVEs. For client-side prototype pollution in browsers, the Prototype Pollution Scanner browser extension and Burp Suite's DOM Invader tool inject __proto__ payloads into URL parameters and monitor whether Object.prototype properties are subsequently injected. For server-side prototype pollution, send API requests with {"__proto__": {"testPollutionProp": 1}} and then check {} .testPollutionProp in a subsequent request to confirm pollution persisted.

Which popular npm libraries have had prototype pollution vulnerabilities?

lodash: _.merge(), _.mergeWith(), _.defaultsDeep() before version 4.17.11 (CVE-2019-10744, CVE-2018-16487). jquery: jQuery.extend() before version 3.4.0 when used with deep merge (CVE-2019-11358). hoek: before version 6.1.3 (HackerOne report). mixin-deep: before version 1.3.2. set-value: before version 2.0.1. flat: parse() function before version 5.0.1. These libraries are patched, but older versions remain widespread. Run npm audit regularly and upgrade to patched versions. Also check that deep merge operations in your own code apply the same protections.

How do I prevent prototype pollution in a custom deep merge function?

Filter __proto__, constructor, and prototype keys before processing them in recursive merge functions. Safe deep merge: function safeDeepMerge(target, source) { for (const key of Object.keys(source)) { if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue; if (typeof source[key] === 'object' && source[key] !== null) { target[key] = target[key] || {}; safeDeepMerge(target[key], source[key]); } else { target[key] = source[key]; } } return target; }. Alternatively, use Object.create(null) for accumulator objects that should not inherit from Object.prototype, so polluting Object.prototype does not affect them.

What is Object.freeze(Object.prototype) and does it prevent prototype pollution?

Object.freeze(Object.prototype) prevents any modifications to Object.prototype, including prototype pollution attempts. Once frozen, assignments like Object.prototype.isAdmin = true throw an error in strict mode and silently fail in non-strict mode. This is an effective global defense: execute Object.freeze(Object.prototype) at application startup before processing any user input. The limitation is that it must be called before any pollution occurs and before any library code runs that legitimately modifies Object.prototype (few modern libraries do this, but verify before applying). Also freeze Object and Function if your threat model requires it: Object.freeze(Object); Object.freeze(Function.prototype).

Sources & references

  1. PortSwigger Prototype Pollution Research
  2. PortSwigger Client-Side Prototype Pollution
  3. Snyk Prototype Pollution Advisory Database
  4. HackerOne Prototype Pollution Bug Bounty Reports

Free resources

25
Free download

Critical CVE Reference Card 2025–2026

25 actively exploited vulnerabilities with CVSS scores, exploit status, and patch availability. Print it, pin it, share it with your SOC team.

No spam. Unsubscribe anytime.

Free download

Ransomware Incident Response Playbook

Step-by-step 24-hour IR checklist covering detection, containment, eradication, and recovery. Built for SOC teams, IR leads, and CISOs.

No spam. Unsubscribe anytime.

Free newsletter

Get threat intel before your inbox does.

50,000+ security professionals read Decryption Digest for early warnings on zero-days, ransomware, and nation-state campaigns. Free, daily, no spam.

Unsubscribe anytime. We never sell your data.

Eric Bang
Author

Founder & Cybersecurity Evangelist, Decryption Digest

Cybersecurity professional with expertise in threat intelligence, vulnerability research, and enterprise security. Covers zero-days, ransomware, and nation-state operations for 50,000+ security professionals every morning.

Black Hat Giveaway

Win a $2,495 Black Hat pass.

Full-access to Black Hat USA 2026 in Las Vegas. Subscribe free to enter.

Joins Decryption Digest daily briefing. Unsubscribe anytime.

Giveaway: Black Hat USA 2026 Full-Access Pass ($2,495 value)

Details →
Daily Briefing

Subscribe to enter the giveaway

Every subscriber is automatically entered. You also get daily threat intel every morning: zero-days, ransomware, and nation-state campaigns. Free. No spam.

Already subscribed? You're already entered.

Giveaway

Win a $2,495 Black Hat USA 2026 pass.