How an unsafe merge function lets attackers manipulate the global Object.prototype and thereby compromise the entire application
Prototype pollution is a JavaScript-specific vulnerability class in which an attacker, via an unsafe object merge, writes properties onto `Object.prototype` itself instead of onto the actually intended target object, causing these manipulated properties to subsequently appear on every newly created object across the entire application, often with serious consequences up to remote code execution. This vulnerability affects both client-side browser code and server-side Node.js code and is especially tricky because the triggering merge code looks completely harmless at first glance.
Table of Contents
- 1. A brief detour: how JavaScript prototypes work
- 2. The attack vector: __proto__ injection via merge functions
- 3. A vulnerable merge example and its impact
- 4. From logic bugs to remote code execution
- 5. Safe merge implementation with explicit key checking
- 6. Object.freeze(Object.prototype) as an additional defense layer
- 7. Map instead of a plain object for user-controlled keys
- 8. Auditing dependencies for known prototype pollution CVEs
- 9. Deliberately testing for and detecting prototype pollution
- 10. Summary
- 11. FAQ
1. A brief detour: how JavaScript prototypes work
In JavaScript, practically every object has an internal link to a prototype object from which it inherits properties and methods when these aren't defined directly on the object itself. The prototype of an ordinary object created with `{}` or `new Object()` is `Object.prototype`, and since practically every object in a JavaScript application ends up in this prototype chain eventually, any change to `Object.prototype` itself potentially affects every existing AND every future object across the entire application, regardless of which module or library later uses that object.
This global reach makes `Object.prototype` an exceptionally attractive attack target: if an attacker manages to write a property onto `Object.prototype` instead of onto an ordinary, locally scoped object, this manipulation potentially affects every code path in the application that at some point creates a plain object and accesses a property with the same name, even if that code path lives in a completely different, seemingly unrelated part of the application.
2. The attack vector: __proto__ injection via merge functions
The classic attack path runs through a recursive merge or extend function, as exists in countless utility libraries and hand-written code to deeply combine two objects, say to combine user configuration options with default values. If this function processes the source object's property names unfiltered, an attacker can inject a JSON object with the key `__proto__` (say, as the body of an API request) that the merge function interprets as an ordinary property name, but that the JavaScript engine treats as special access to the target object's prototype.
Since `__proto__` is a special accessor in most JavaScript engines that directly accesses an object's internal prototype instead of setting an ordinary property, `merge(targetObject, JSON.parse(attackerInput))` with a crafted input like `{"__proto__": {"isAdmin": true}}` causes the property `isAdmin` to land not on `targetObject`, but on `Object.prototype` itself, after which every newly created, plain object across the entire application is affected.
3. A vulnerable merge example and its impact
The following example shows a naive, recursive merge function, of the kind that actually ran in production in many projects (and also in older versions of well-known npm packages like lodash before corresponding patches) before this vulnerability class became widely known, with the property name never checked against `__proto__`, `constructor`, or `prototype`.
// VULNERABLE: no check for dangerous key names
function merge(target, source) {
for (const key in source) {
if (typeof source[key] === 'object' && source[key] !== null) {
if (typeof target[key] !== 'object') target[key] = {};
merge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
// Attacker input as JSON body of an API request:
// { "__proto__": { "isAdmin": true } }
merge({}, JSON.parse(attackerInput));
// From now on EVERY newly created plain object has
// the property isAdmin === true, application-wide:
console.log({}.isAdmin); // true
4. From logic bugs to remote code execution
The immediate consequences of a successful prototype pollution range from seemingly harmless logic bugs (say, an authorization check that mistakenly treats `isAdmin` as true on every object) to considerably more serious attacks. It becomes especially critical when a property injected via prototype pollution is interpreted by a downstream library as a configuration option that internally calls `eval()`, `child_process.exec()`, or a comparably dangerous function, turning a seemingly harmless object pollution into actual remote code execution.
Several real, documented CVEs in well-known npm packages (among others in template engines and configuration libraries) showed exactly this escalation chain: prototype pollution via an unchecked merge function, followed by exploiting a downstream, ostensibly trustworthy library that translated the manipulated prototype property into code execution without checking it.
5. Safe merge implementation with explicit key checking
The most direct fix is to explicitly check every recursive merge or extend function against the three dangerous key names `__proto__`, `constructor`, and `prototype` and consistently skip these keys before any assignment happens at all, instead of relying on an external library whose security posture you don't know.
// SAFE: dangerous keys are explicitly blocked
const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
function safeMerge(target, source) {
for (const key of Object.keys(source)) {
if (DANGEROUS_KEYS.has(key)) continue;
if (typeof source[key] === 'object' && source[key] !== null) {
if (typeof target[key] !== 'object') target[key] = {};
safeMerge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
6. Object.freeze(Object.prototype) as an additional defense layer
As an additional, defensive measure, `Object.prototype` can be frozen at application startup with `Object.freeze(Object.prototype)`, causing every later attempt to write a new property onto `Object.prototype` to fail in strict mode (throwing a `TypeError`) instead of silently succeeding. This approach acts as a safety net that still catches a prototype pollution vulnerability even if it exists in an unexpected, not-yet-hardened place in the code.
This measure is not a replacement for a safe merge implementation, though, but an additional layer, since it can cause compatibility issues in a few rare, legitimate use cases (say, some polyfill libraries that deliberately extend `Object.prototype`) and should therefore be carefully tested against your own library dependencies before being enabled in production.
7. Map instead of a plain object for user-controlled keys
Wherever application code uses an object as a lookup table with user-controlled keys, say to store configuration values keyed by a name coming from the request, a native `Map` object is the structurally safer replacement for a plain `{}` object, since `Map` instances don't have a prototype chain in the same sense and keys like `__proto__` are treated there as perfectly ordinary, harmless strings, with no special meaning at all.
This architectural shift from object to `Map` fixes the problem at its root instead of merely catching it via a key blocklist, and is therefore the most robust long-term solution wherever new code is written for user-controlled lookup tables.
8. Auditing dependencies for known prototype pollution CVEs
Since many prototype pollution vulnerabilities have historically lived in widely used npm packages themselves, not just in hand-written code, a regular check of your own dependencies with `npm audit` or a comparable tool belongs to basic hardening, since these tools match known CVEs, including numerous documented prototype pollution cases, against installed package versions and flag outdated, vulnerable versions.
This check is especially relevant for packages that merge, parse, or clone objects from external, user-controlled data, say query string parsers, JSON schema validators, or configuration loaders, since exactly this category of libraries has historically been most frequently affected by prototype pollution CVEs.
9. Deliberately testing for and detecting prototype pollution
A deliberate test sends purposely crafted payloads with the keys `__proto__`, `constructor.prototype`, and related variants to every endpoint accepting user-controlled JSON data, then checks whether `Object.prototype` was actually altered, say by reading a test key on a freshly created empty object after the request. Automated tools and specialized prototype pollution scanners in common security testing suites handle this check systematically across the entire API surface, instead of trying every endpoint by hand one at a time.
It's also worth running a static code scan that automatically identifies and flags recursive merge, extend, and clone functions in your own code, so each of these functions can be deliberately checked against the protective measures described in this article, instead of relying on a random discovery during a penetration test.
| Measure | Protective effect | Effort |
|---|---|---|
| Key checking in merge functions | Blocks the attack vector directly at the source | Low, one-time implementation |
| Object.freeze(Object.prototype) | Safety net against unknown gaps | Low, check compatibility beforehand |
| Map instead of object | Fixes the problem structurally | Medium, some refactoring needed |
| npm audit / dependency scanning | Uncovers vulnerabilities in dependencies | Low, can be automated in CI |
Mironsoft
Security audits, OWASP-compliant hardening, and secure architecture
Applications that actually hold up against a real attack attempt?
We review existing applications for classic OWASP vulnerabilities, insecure authentication, and missing input validation, then build an architecture that structurally reduces attack surface instead of just patching individual symptoms.
Security Audit
Systematically checking OWASP Top 10, auth flows, and input validation for vulnerabilities.
Secure Architecture
Building rate limiting, encryption, and access controls correctly from the ground up.
Incident Readiness
Establishing logging, monitoring, and response processes for when things go wrong.
10. Summary
Prototype Pollution: The Essentials at a Glance
Core idea
An unsafe merge function can accept __proto__ as a key and thereby alter Object.prototype itself.
Reach
A change to Object.prototype affects every newly created, plain object across the entire application.
Best fix
Explicitly block dangerous keys (__proto__, constructor, prototype) in every merge function.
Escalation
Can escalate up to remote code execution if a downstream library translates the manipulated property into code execution.