Writing to __proto__: prototype pollution in Node.js and the lodash defaultsDeep bug
Prototype pollution lets an attacker set a property on every object in a Node process by writing through __proto__. CVE-2019-10744 in lodash is the canonical case. How the merge goes wrong, how to find it, and how it differs from Python class pollution.
By Ahmed PingerPublished
9 min read1 view
JavaScript objects inherit from a shared prototype. When you read a property that an object does not have, the engine walks up to Object.prototype and looks there. That is normal and useful. It becomes a vulnerability when an attacker can write to that shared prototype, because a value they set once is then inherited by every object in the process that does not define the property itself.
Prototype pollution is exactly that write. An attacker who controls a property path reaches the key __proto__ (or constructor.prototype), sets a property on Object.prototype, and from then on every plain object appears to carry the attacker's value. The consequences range from denial of service to authentication bypass to remote code execution, depending on what code later reads a property it assumes is absent.
The bug in one distinction
The class needs one capability: attacker-controlled assignment to an object path. That path almost always comes from a recursive merge or a deep-set helper, the kind of function that walks a source object and copies its keys into a target.
The dangerous version does something like "for each key in the source, if the target's value at this key is an object, recurse; otherwise assign." Feed it a source whose key is __proto__, or constructor then prototype, and the recursion walks straight onto the shared prototype and writes there. The function believed it was assigning to a normal property. It was assigning to the prototype every object inherits from.
So the one distinction: a safe merge copies a key onto this object; a vulnerable merge follows __proto__ and copies onto every object.
Why it varies
What the write is worth depends entirely on what reads the polluted property afterwards, and that is why the same bug ranges from harmless to critical.
- If later code does
if (!options.isAdmin)on an object that never setisAdmin, and the attacker pollutedObject.prototype.isAdminto a truthy value, the check flips. That is authorization bypass from a merge function. - If a template engine or a child-process helper reads a configuration property off a plain object and the attacker polluted that property with a payload, the pollution becomes a gadget toward code execution. The pollution is the primitive; a second, unrelated piece of code is the sink.
- If the polluted property breaks an assumption the runtime relies on, the process throws or hangs, and you have denial of service.
The write is always the same. The blast radius is decided by code the attacker never touched, which is what makes prototype pollution hard to reason about in review: the vulnerable line and the dangerous line can be in different files owned by different teams.
Not the same as Python class pollution
The Python analogue has its own post, prototype pollution's Python cousin, and the distinction matters because the two are often conflated.
JavaScript has real prototypes. Object.prototype is a single shared object, and writing to it changes lookup for the whole process. That is what this post is about, and it is why __proto__ is the magic key.
Python has no prototypes. It cannot have prototype pollution in the literal sense. What it has is class pollution: a vulnerable merge in Python walks __class__, __bases__ and a function's writable __globals__ to reach and mutate another module's global namespace. Same category of bug, attacker-controlled recursive assignment, but different plumbing and different magic attributes. If you carry a mental model from one language to the other, the specific keys you search for are wrong. The Node keys are __proto__, constructor, prototype. The Python chains are __class__, __bases__, __globals__. Read the two posts side by side and the family resemblance is clear, but do not grep for __proto__ in a Python codebase or for __globals__ in a Node one.
The real CVE
CVE-2019-10744 is the canonical Node case. NVD published it on 2019-07-26 with a CVSS 3.1 base score of 9.1 (Critical) and classified it as CWE-1321, improper modification of an object prototype attribute. The affected function is lodash's defaultsDeep, in versions below 4.17.12, and the standalone lodash.defaultsdeep package below 4.6.1.
Snyk and the lodash advisory describe the trigger without ceremony: defaultsDeep could be made to modify Object.prototype by passing a source object shaped around a constructor and prototype path. The recursive default-merging logic followed that path onto the shared prototype and wrote there. The fix, in lodash pull request #4336, added guards so the merge refuses the keys that lead to the prototype. lodash 4.17.12 carries it. Because lodash is a dependency of an enormous fraction of the npm ecosystem, this single merge function put prototype pollution in front of a very large number of applications that never wrote a merge of their own.
The takeaway from the CVE is that the vulnerable code was a utility library doing something entirely reasonable-looking. Nobody wrote Object.prototype.x = y. They called a deep-merge helper on attacker-influenced JSON, and the helper walked where the attacker pointed it.
A lab you can build
No Docker, no framework. Node and a text editor on any machine.
- Install an affected lodash into a throwaway project directory:
npm install lodash@4.17.11inside a folder you created for this. This is a local project dependency, not a global install. - Write a few lines that call
defaultsDeepon an empty target and a source object parsed from a JSON string, then, on a separate fresh object, read a property you never set. - Observe that the fresh object reports the value the source planted on the prototype. Then
npm install lodash@4.17.12, run the identical script, and observe that the fresh object no longer inherits it.
The whole demonstration is those two runs. The first shows a value leaking onto an object that never defined it; the second shows the patched merge refusing the same input. You have reproduced the class and validated the fix without building an end-to-end exploit, which is exactly the right stopping point.
To feel the guardrail rather than the library fix, run either script under node --disable-proto=throw, added in Node 13.12.0 and 12.17.0, and watch the write attempt throw ERR_PROTO_ACCESS instead of silently succeeding.
Finding it in source
Prototype pollution is one of the more grep-able server-side bugs, because the sinks and the magic keys are specific.
- Find the merges. Search for
merge,extend,defaults,deepmerge,assign,set,_.set, and any hand-written recursive copy overfor...inorObject.keys. Each is a candidate sink for attacker-controlled keys. - Trace the source of the keys. The bug needs attacker-controlled property names, not just values. Follow
JSON.parseof request bodies, query-string parsers that build nested objects from bracket notation, and any place a user-supplied string becomes an object key. A merge is only dangerous if the keys can come from the request. - Look for the reads that turn pollution into impact. Property reads on plain objects with no own value, used in security decisions or passed to templating and process-spawning helpers, are the sinks that give the write its consequence.
- Check dependencies, not just your code. As CVE-2019-10744 shows, the vulnerable merge is often in a library. Run
npm auditand check lockfiles for lodash below 4.17.12 and other known-polluting helpers.
Fixing it
Patch the libraries. lodash 4.17.12 and later. npm audit and dependency updates are the first move, because most real-world instances are transitive.
Reject the dangerous keys at the boundary. Before any merge or deep-set, refuse property names of __proto__, constructor and prototype from untrusted input. Do it once, at the parse or validation step, rather than trusting every downstream merge to be safe.
Use data structures that have no prototype to pollute. Object.create(null) makes a bare object with no prototype chain, and Map keys are not object properties at all. Configuration and lookup tables built from user input are good places for both.
Freeze the prototype where you can. Object.freeze(Object.prototype) prevents writes to it entirely, and node --disable-proto=delete or =throw removes or traps the __proto__ accessor process-wide. These are blunt but effective backstops for services that do not need to touch the prototype at runtime.
Validate shape, not just presence. A schema validator that rejects unexpected keys stops attacker-chosen property names from reaching a merge in the first place.
Detecting it
The request must carry the magic keys and the pollution marks the process, so both ends are watchable.
- The keys themselves in requests. No legitimate client sends
__proto__,constructororprototypeas a property name in a JSON body, a query string, or bracket notation such asa[__proto__][isAdmin]. Match them, URL-encoded forms included, in the WAF or request-logging middleware and alert on the first hit. - Rejections from the boundary validator. Every rejection logged by the key filter or schema validator from the fix section is an attempt that reached a parser. Emit it as a structured event with the route and offending key, not a generic 400.
ERR_PROTO_ACCESSin the process log. Undernode --disable-proto=throw, a write that reaches__proto__throws that error code instead of succeeding. One occurrence in production means input reached an unguarded merge.- Prototype drift. Record
Object.getOwnPropertyNames(Object.prototype)at startup, have the health check compare the live list against it, and fail the instance when a new name appears. - Impact-side anomalies. Authorization checks passing for accounts that should fail, or
TypeErrorcrashes across unrelated routes shortly after a request carried the keys above, are the sink firing. Correlate them against the request log by time.
Take this away
Prototype pollution is a write to the one object every other object inherits from, usually delivered through a recursive merge that followed __proto__ where it should have refused. CVE-2019-10744 is worth keeping in mind because the vulnerable code was a popular utility behaving normally, and the impact was decided elsewhere by code that read a property it assumed was absent.
In review, hold two questions together: can attacker-controlled keys reach a merge or deep-set, and does any later code make a decision on a property it never explicitly set. Where both are true, you have the write and the read, and prototype pollution is the line between them. And when you move between Node and Python, change the keys you hunt for: __proto__ here, __globals__ there.
Further reading
- NVD: CVE-2019-10744: the lodash
defaultsDeepentry, CWE-1321, CVSS 9.1 - Snyk: SNYK-JS-LODASH-450202: affected and patched versions across the lodash packages
- lodash pull request #4336: the fix that guards the merge against prototype keys
- Assumed Breach: prototype pollution's Python cousin: the class-pollution analogue and why the magic attributes differ
- Node.js command-line API: the
--disable-protoflag, which can delete or throw on__proto__access process-wide - CWE-1321: Improperly Controlled Modification of Object Prototype Attributes: the weakness class
Was this useful?
Get the next write-up by email
New technical write-ups by email when we publish. No sales sequence, and every email has an unsubscribe link.
Prefer a feed? RSS. How we handle your address: privacy notice.
Comments
Loading comments…