Prototype Pollution to Remote Code Execution in Node.js

A flaw in how JavaScript handles object inheritance can let attackers execute arbitrary code.

Editor at Large · · 9 min read
Cover illustration for “Prototype Pollution to Remote Code Execution in Node.js”
Exploitation Techniques · September 23, 2026 · 9 min read · 1,954 words

Every object in JavaScript inherits from somewhere. Follow that chain far enough and it lands on Object.prototype, the root of the whole system, the thing every plain object in a Node.js process shares. Prototype pollution is what happens when an attacker gets to write to that root object. Once that write succeeds, the damage isn't local. It doesn't sit inside one variable or one broken function. It sits at the top of the inheritance tree. Every object built afterward, across every module in the process, can inherit whatever the attacker planted, for as long as that process stays alive.

JavaScript doesn't stop this by design. Nothing in the runtime checks whether a property name came from a developer or from a hostile HTTP request. That permissiveness is the whole reason prototype pollution exists as a bug class, and it's also why fixing it takes more than a quick patch on one function.

How attacker-controlled input reaches Object.prototype

The pattern that causes almost all of this looks deceptively small: obj[a][b] = value, where a is a string the attacker controls. If a can be set to __proto__, the assignment stops writing to a normal object and starts writing to the shared prototype instead. That's the entire vulnerability, in one line of code that looks completely ordinary in a code review.

Where does that pattern actually show up in real apps? A few places, over and over:

  • Deep merge and recursive assign helpers, the kind found in lodash-style utility libraries and query-string parsers
  • JSON body parsing that feeds straight into object property writes without a filter
  • Object cloning code that walks through keys but never checks for __proto__ or constructor.prototype along the way

The NPM CLI itself had a real version of this. Its diffApply function walks an array path built from an attacker-controlled diff parameter, calling shift() to peel off each segment. Because the attacker indirectly controls what thisProp becomes, setting it to __proto__ let an attacker assign any value to any property on the root prototype. That's a toy example built from a tool that sits on nearly every developer's machine. That's a tool that sits on nearly every developer's machine.

Blitz.js turned up a version of the same problem, flagged by Sonar's security research: user input merged into objects without enough sanitization first. The pattern keeps repeating because full-stack frameworks that handle input through merging or deep cloning perform the object manipulation that opens the door.

What gadgets are and why they are dangerous

Polluting the prototype is only half the attack. On its own, a polluted property sitting on Object.prototype does nothing. It needs something else in the codebase to read it and act on it. That something is called a gadget: a piece of otherwise normal, legitimate code, sitting in the app or one of its dependencies, reads a property off an object without checking whether the property was put there by a developer or slipped in by an attacker.

Think of it as a two-stage job. Stage one is the injection sink, where the attacker plants the payload. That's loading the gun. Stage two is the gadget, the code that later reads that property and passes it, unchecked, into something dangerous, like a file write, a command execution call, or a deserialization routine. That's pulling the trigger.

The attacker doesn't need to find some careless line of user-facing code that calls a dangerous API directly. They only need one code path, anywhere in the running process, that eventually reads from the prototype and hands the value to something that executes it. The vulnerable function and the exploited function can be strangers to each other, written by different people, in different packages, years apart.

It's a close cousin of insecure deserialization bugs in statically typed languages, except JavaScript's duck typing makes it worse. Mutate the root prototype, and every object's effective "type" just changed, all at once. Code paths that were dead, or that the developer assumed could never run with attacker data, wake up. It's code reuse, just sliced much finer and scattered far less predictably than a normal deserialization gadget chain.

Universal gadgets in core Node.js APIs: what the Silent Spring research found

The paper that put numbers on this is "Silent Spring: Prototype Pollution Leads to Remote Code Execution in Node.js," written by Mikhail Shcherbakov and Musard Balliu at KTH Royal Institute of Technology, along with Cristian-Alexandru Staicu at CISPA Helmholtz Center for Information Security. It was presented at the 32nd USENIX Security Symposium in Anaheim, California, in August 2023.

Their tooling was built on GitHub's CodeQL. For finding pollution, they used a multi-label taint analysis, and for finding gadgets, they combined static analysis with dynamic testing in a hybrid approach.

The result: 11 universal gadgets, found inside core Node.js APIs, each one capable of leading to code execution. "Universal" is the word to sit with here. These aren't gadgets buried in some obscure third-party package that a security team could just avoid depending on. They're baked into the Node.js runtime itself. Every Node.js application has access to them, regardless of what's in its package.json.

Walking the exploit chain: from polluted property to arbitrary command execution

Diagram: From Polluted Prototype to Remote Code Execution: The Six-Step Chain. Visualizes: Visualize the prototype pollution exploit chain as a numbered vertical or horizontal flow with six distinct steps, using the exact stages described in the…

One of the clearest examples of a universal gadget runs through execArgv. Pollute the prototype with a malicious execArgv property that inserts --eval into the arguments passed to a spawned child process, and Node.js itself will run the attacker's code the next time any child process gets spawned.

Broken into steps, the chain looks like this:

  1. The attacker finds an input that feeds an unsafe merge or assign operation, the injection sink.
  2. The payload sets __proto__.execArgv (or another universal gadget property) to a value containing --eval <malicious code>.
  3. Somewhere later, completely unrelated application code spawns a child process, maybe for a shell command, a build step, or a background job.
  4. Node.js reads execArgv off the prototype chain. The attacker's --eval argument is now baked into that spawn call.
  5. The child process runs the attacker's JavaScript, inside the server's own context.

None of the application's user-facing code ever touches a command execution API. The gadget can sit deep in infrastructure code or in a dependency several layers removed from anything a developer wrote by hand. The payload travels through the codebase invisibly, waiting for whichever unrelated function happens to spawn a process next.

The NPM CLI's diffApply bug matters here because it grounds all of this in something concrete. That injection point wasn't a research contrivance. It existed, for real, in a widely used tool found across developer environments.

Why NPM ecosystem scale amplifies this vulnerability class

NPM is a massive software package repository with a deeply interconnected dependency graph. That scale isn't just a fun fact, it's the reason this bug class is so hard to stamp out. Research on the npm dependency graph has shown that its deeply interconnected structure makes the risk worse, not just wider: a gadget sitting in one transitive dependency can surface in applications that pull that dependency in, even indirectly.

The injection point and the gadget are almost never written by the same people. The merge utility that lets pollution happen might come from one maintainer with no idea their code even touches user input from someone else's app. The child-process spawn that eventually triggers the RCE might come from a completely different package, written by someone who's never heard of the merge utility and never will.

That disconnect breaks most standard dependency auditing. Audits are built to catch known CVEs, matched against a database. A package that introduces a gadget, on its own, is often doing nothing wrong. It's reading a property off an object, which is completely normal code. There's no CVE for "this function will eventually become dangerous if some other package pollutes the prototype first."

Detecting prototype pollution and gadgets in a real codebase

Finding the injection sinks takes real data flow analysis, tracing the pattern obj[prototype][property] = value through the code. The Silent Spring researchers did this with a flow-sensitive, context-sensitive taint analysis using multiple labels to capture the order in which the attacker-controlled property gets touched.

CodeQL is the practical tool here. Since the Silent Spring framework was built on top of it, security teams with CodeQL access can adapt or extend those same queries against their own repositories rather than starting from scratch.

Gadgets are harder to detect. Static analysis alone tends to fall short, because knowing a property gets read isn't the same as knowing that read leads somewhere dangerous under a real, reachable set of conditions. That's why the hybrid approach, static analysis paired with dynamic execution, matters.

Automated scanning repeatedly misses two blind spots:

  • Cross-package gadget chains. A scanner that only looks at one library in isolation has no way to see the gadget sitting three packages downstream in a completely different dependency.
  • Context sensitivity. A property read that's harmless in one call context turns into a live gadget in another. Scanners that don't account for context either drown teams in false positives or miss the real chains.

Remediating prototype pollution correctly

Fixing this properly means working at three different layers, not just one.

The first layer is blocking the injection itself. That means validating property names before any recursive merge or assign runs, rejecting __proto__, constructor, and prototype as keys. For dictionary-style objects that need to accept arbitrary keys from users, Object.create(null) builds an object with no prototype chain at all, so there's nothing to pollute. Where it fits the use case, a Map is a safer choice than a plain object for any key-value store built from user-controlled keys.

The second layer is hardening the runtime itself. Calling Object.freeze(Object.prototype) during application bootstrap locks the root prototype against runtime mutation entirely, though it needs testing against the full dependency tree first since some libraries genuinely do mutate prototypes on purpose. Node.js also ships a --frozen-intrinsics flag that freezes built-in objects at process start.

The third layer is shrinking gadget exposure. That means auditing every use of spawn, exec, and fork, preferring explicit argument arrays over shell string interpolation, and applying least privilege to whatever gets spawned, so that even a gadget that does fire has as little room to do damage as possible.

Incomplete fixes tend to follow one of two patterns. One is patching __proto__ handling in a single merge utility while leaving every other merge or clone function in the dependency tree untouched, so the attacker just finds a different door into the same universal gadget. The other is bumping a library's version without ever confirming that the gadget it exposed is actually unreachable now, through any other path. Both leave the real vulnerability standing while looking, on paper, like the ticket got closed.

What finding this vulnerability chain requires from a penetration test

Finding these chains systematically took a purpose-built, multi-stage framework, CodeQL taint analysis paired with hybrid dynamic gadget detection, which is not what a generic dynamic application scanner running against a live URL is built to do. A black-box scan can sometimes notice that a prototype got polluted. It has almost no way of proving that pollution reaches a gadget that turns into code execution.

That proof requires whitebox access to source. Finding that diffApply in the NPM CLI was pollutable meant reading the actual source code line by line. Confirming that a gadget existed downstream meant tracing execution through Node.js's own core APIs, something no amount of external probing from outside the box can substitute for.

A finding without a working exploit chain behind it is a guess dressed up as a vulnerability report. Real assurance here means walking the whole path, from the injection sink through the gadget to the execution sink, and proving each link actually holds.

Sources

  1. Silent Spring: Prototype Pollution Leads to Remote Code Execution in Node.js
  2. Silent Spring: Prototype Pollution Leads to Remote Code Execution in Node.js | USENIX
  3. arxiv.org
  4. Remote Code Execution via Prototype Pollution in Blitz.js
  5. github.com
  6. portswigger.net
  7. Prototype Pollution Prevention - OWASP Cheat Sheet Series
  8. Understanding and Preventing Prototype Pollution in Node.js

More in Exploitation Techniques