Skip to content
pwnsy
web-securityadvanced#prototype-pollution#web-security#javascript#nodejs#appsec

Prototype Pollution Explained: Poisoning JavaScript Objects

How prototype pollution works in JavaScript: injecting into __proto__, gadget-driven escalation to DoS, bypasses and RCE, and how to shut it down.

JavaScript objects inherit from a shared prototype. When you read a property that an object does not have of its own, the language walks up the prototype chain to find it. That inheritance is convenient and, from a security point of view, dangerous: if an attacker can write to a shared prototype, they change the default behaviour of objects they never touched. That is prototype pollution.

It is one of the more conceptually slippery web bugs because the injection and the damage usually happen in different places. An attacker pollutes a prototype in one part of the code, and something entirely unrelated misbehaves later because it inherited the poisoned property.

The inheritance that makes it work

In JavaScript, plain objects inherit from Object.prototype. Read a property that is not defined directly on an object, and the engine looks it up the chain, ending at Object.prototype. Anything defined there is visible, as a fallback, on nearly every object in the program.

The special property __proto__ is an accessor that points at an object's prototype. Assigning through it reaches the prototype rather than the object itself. So code that sets a key named __proto__ on an object, using attacker-controlled input, can end up writing onto Object.prototype, where the new property becomes a default seen everywhere.

target["__proto__"]["isAdmin"] = true

After that line runs, an ordinary object that never had an isAdmin property may now report true when asked, because it inherits the polluted default. That is the entire trick, and its reach is what makes it dangerous.

Where the injection lives

Prototype pollution needs a sink: code that copies attacker-controlled property names into an object without filtering the dangerous ones. The usual sinks are all forms of "merge this untrusted structure into that object."

  • Recursive merge and deep-extend functions that walk a source object and copy every key into a target.
  • Deep clone routines that reconstruct an object key by key.
  • Object-path setters that take a string like a.b.c and build the nested path, where the attacker supplies __proto__.polluted.
  • Query-string, JSON, and form parsers that turn nested input such as ?__proto__[isAdmin]=true into an object graph and then merge it.

The common thread is untrusted keys reaching an assignment. If the attacker controls a property name, not just a value, and that name is written without a check, the sink is vulnerable.

Pollution is only step one

Setting a property on a prototype is not, by itself, an exploit. It matters because of what happens next: some existing code path reads the polluted property and does something with it. That code path is the gadget. The severity of the whole thing is decided by which gadget is reachable.

Gadget locationWhat the gadget doesTypical impact
Access-control checkReads an inherited flag like isAdmin or rolePrivilege escalation, authorization bypass
Configuration lookupReads options with an inherited defaultLogic change, denial of service
HTML templating or DOM sinkRenders an inherited value into the pageClient-side XSS
Child-process or require optionsPasses inherited options into command executionRemote code execution on the server

The escalation from a client-side gadget to script execution is a form of cross-site scripting, and the reverse also holds: a DOM XSS defense that ignores prototype pollution can be walked around. See XSS Attacks Explained. The server-side path, where a polluted option flows into process spawning, is how prototype pollution turns into command execution. That mechanism is the subject of Command Injection Explained, and the deserialization-style trust failure behind it is covered in Insecure Deserialization Explained.

The damage shows up far from the bug

The hardest part of prototype pollution is that the sink and the gadget are usually in different files, often in different libraries. You pollute a prototype through a vulnerable merge in your own code, and the exploit fires inside a dependency that reads an inherited option minutes later. This is why grepping only for obvious injection points misses it. You have to reason about which properties, once defaulted globally, would change behaviour somewhere in the whole dependency tree.

Why input filtering alone is fragile

The instinct is to block the string __proto__ in input. It helps, but it is incomplete. Pollution can also be reached through the constructor and prototype keys, for example constructor.prototype.polluted, which lands on the same shared prototype by a different route. A filter that blocks only the literal __proto__ misses this. Encoding and nested-structure tricks give attackers further variations.

Blocklisting specific key strings is the same losing game as blocklisting bad URLs in SSRF or bad characters in injection: the attacker needs one route the list forgot. The durable fixes change the data structures and freeze the prototype so there is nothing to pollute, rather than trying to enumerate every dangerous key.

How to defend against prototype pollution

Attack the sink and remove the gadgets. Doing both means a missed injection point still has nothing to exploit.

  1. Reject dangerous keys at every merge and setter. In any code that copies untrusted keys into an object, explicitly skip __proto__, constructor, and prototype. Do this at the sink, not just at the edge, so intermediate parsers cannot smuggle them in.
  2. Use null-prototype objects for untrusted data. Create containers with Object.create(null), which have no prototype chain, so writing __proto__ sets an ordinary property that pollutes nothing.
  3. Prefer Map over plain objects for key-value stores. A Map keyed by untrusted strings has no prototype semantics, which removes the sink for lookup tables and caches entirely.
  4. Freeze the prototype. Object.freeze(Object.prototype) prevents new properties from being written to it at all, closing the injection globally, though it should be tested for compatibility with your dependencies.
  5. Validate against a schema. Enforce an explicit schema on incoming JSON and form data so only known, typed properties are accepted, and unexpected keys like __proto__ are dropped before any merge.
  6. Keep dependencies current and audit merge utilities. Many pollution findings live in third-party deep-merge, clone, and object-path libraries. Track advisories and update, since maintainers have hardened many of these functions.
  7. Enable the runtime protections you have. Modern JavaScript runtimes offer flags and options that harden prototype handling; use them as a backstop, not a primary control.
Change the structure, not just the filter

The strongest prototype-pollution defenses are structural. A null-prototype object or a Map has no shared prototype to poison, so the attacker's crafted key becomes an inert ordinary property. Freezing Object.prototype does the same globally. Reach for these first, and treat key-name filtering as a secondary layer, because filters can be bypassed but a frozen or absent prototype cannot be polluted.

The three routes to the shared prototype

Filtering the string __proto__ feels like a fix until you see the other ways in. Three property names reach the same shared prototype, and a defense that blocks one and forgets the others is not a defense.

The first route is __proto__ directly. It is an accessor on objects that points at the prototype, so writing through a key named __proto__ reaches Object.prototype. This is the route most people know and the one most filters catch.

The second route is constructor.prototype. Every object has a constructor property inherited from its prototype, and that constructor function has a prototype property that points back at the same shared prototype. So a path like constructor.prototype.polluted lands on the identical target by walking through the constructor rather than through __proto__. A filter that only rejects __proto__ leaves this route wide open.

The third route is any parser or setter that builds these paths for the attacker. An object-path setter given the string constructor.prototype.polluted, or a query parser that expands constructor[prototype][polluted], constructs the dangerous path from flat input the attacker fully controls. The attacker never has to type the payload as a nested object; the parser assembles it.

The lesson is that the target is a shared, writable prototype, and the property name is only one of several handles onto it. This is why the durable defenses attack the target itself, by removing the prototype (null-prototype objects, Maps) or freezing it, rather than trying to enumerate every name and encoding that could reach it.

A worked example: from a merge to an inherited flag

Walk one concrete path from injection to effect, without any library-specific magic. Picture an API endpoint that accepts a JSON body describing user preferences and merges it into a stored settings object with a recursive deep-merge helper. The helper walks the incoming object and, for every key, copies the value into the target, recursing into nested objects.

The attacker sends a body whose top-level key is __proto__, and under it a nested object with a key such as isAdmin set to true. The merge helper reaches the __proto__ key, treats it as an ordinary nested object to recurse into, and follows it. Because __proto__ is an accessor that points at Object.prototype, recursing into it and assigning isAdmin writes that property onto the shared prototype rather than onto the settings object.

Nothing visible breaks at this point. The response looks normal. The damage waits for a gadget. Elsewhere in the same process, an authorization check reads user.isAdmin on a freshly constructed user object that was never given an isAdmin property of its own. Before the pollution, that read returned undefined, which the code treats as not-admin. After the pollution, the read walks the prototype chain, finds the inherited true, and the check passes. A request that should have been denied is now allowed, and the two events, the merge and the check, are in different files with no direct call between them.

The same shape produces other outcomes depending on the gadget. Pollute a property that a configuration lookup reads, and you flip a default across the whole process. Pollute a property that a templating layer renders, and you can reach the client. Pollute an option that flows into process spawning, and you reach command execution. The injection is identical; the ceiling is set by which gadget is downstream.

Detection signals: finding pollution in code and at runtime

Prototype pollution is quiet by design, so detection combines code review, dynamic probing, and runtime signals.

  • Sink discovery in code. Search for recursive merge, deep-extend, deep-clone, and object-path setter functions, whether hand-written or pulled from dependencies. Any function that copies keys from untrusted input into an object is a candidate sink. Confirm whether it filters __proto__, constructor, and prototype.
  • Nested key parsing. Look for parsers that expand bracketed or dotted query strings and form bodies into nested objects, such as input of the form a[b][c]=1. These turn a flat request into an object graph and are a common way an attacker-controlled __proto__ key is introduced before a merge.
  • A safe runtime probe. In a test environment, send a request that would set a harmless, uniquely named property on the prototype, then read that property on a plain object elsewhere. If the plain object reports the value you injected, pollution reached a shared prototype. Use a property name that no code depends on so the probe changes nothing real.
  • Unexpected inherited properties. At runtime, an object that reports a property it was never assigned is the direct fingerprint. A defensive check that compares an own-property test against a plain property read can surface the discrepancy.
  • Anomalous behaviour after specific inputs. Logic that changes for all users right after a particular request, options that revert to unexpected defaults, or authorization decisions that flip without a code change are behavioural hints that a global default was altered.
Own properties versus inherited ones

The cleanest runtime test asks whether a property lives on the object itself or was inherited. A direct read walks the prototype chain and cannot tell the difference. An own-property check answers only for the object in hand. When code that should see nothing suddenly sees a value, and an own-property check on the same object returns nothing, the value is coming from a polluted prototype. Building that distinction into sensitive checks both detects pollution and blunts many gadgets.

Pollution sits in a family of trust-in-structure bugs. Seeing the neighbours sharpens what is specific to it.

Bug classWhat the attacker controlsWhere it landsTypical ceiling
Prototype pollutionA property name in merged inputA shared prototype, changing defaults everywhereAuth bypass, DoS, client XSS, or RCE via a gadget
Insecure deserializationThe serialized object graphReconstructed objects and their callbacksRCE through crafted object state
Mass assignmentField names bound to a modelObject fields the developer did not intendPrivilege or state changes on that object
SQL or command injectionData interpreted as syntaxA query or command stringData theft or code execution

The distinguishing trait of prototype pollution is reach. Mass assignment changes fields on one object. Prototype pollution changes a default that nearly every object in the process inherits, so a single injected key has process-wide scope, and the gadget that reads it can be anywhere in your code or your dependencies. That combination of global reach and action-at-a-distance is what makes it hard to trace and worth defending structurally.

Common mistakes

Filtering only the literal __proto__ string. The constructor.prototype route reaches the same shared prototype without the word __proto__ appearing. A block that checks for one spelling misses the others, and encoding or nesting adds more variants.

Filtering at the edge but not at the sink. A check on the outermost request body does not protect an inner merge that an intermediate parser feeds. Reject the dangerous keys at the assignment itself, where the write happens, so nothing can smuggle them past an earlier layer.

Assuming client-side pollution is low severity. A polluted property that a DOM sink or template reads becomes cross-site scripting. Client-side gadgets can also undermine a page's own XSS defenses. It is not merely a server problem.

Trusting a dependency to be safe forever. Many pollution findings live in third-party merge, clone, and object-path utilities. A version that was safe can regress, and a transitive dependency you did not choose can reintroduce a sink. Track advisories and pin with care.

Freezing the prototype without testing. Object.freeze(Object.prototype) is a strong global control, and some libraries write to the prototype legitimately. Apply it, then test the full application and dependency tree, so a defense does not become an outage.

How it evolved

Prototype pollution moved from a curiosity to a recognised class as JavaScript spread to the server through Node.js and as deep-merge and object-path utilities became ubiquitous in the ecosystem. Early findings clustered in popular merge and clone libraries, where a single vulnerable function was reused across thousands of projects, so one fix or one miss propagated widely.

Understanding then shifted from the injection to the gadget. Researchers catalogued the code paths that read inherited properties and turned pollution into concrete impact, including server-side chains that reach command execution and client-side chains that reach script execution. That gadget-centric view is why modern advice pairs sink hardening with structural defenses: even a perfectly filtered merge is safer when the data lives in a structure that has no shared prototype to poison. The framing now maps cleanly onto the integrity-failure category in the OWASP Top 10, where untrusted data is allowed to change program behaviour in ways the developer never modelled.

Frequently asked questions

Is prototype pollution only a Node.js problem? No. It affects JavaScript wherever objects share a prototype, which includes browser code. Server-side gadgets tend to have the highest ceiling because they can reach process spawning, and client-side gadgets reach DOM XSS. Both matter.

If pollution happens but no gadget reads the property, is there impact? Often none directly, which is exactly why it is deceptive. The injected default sits inert until some code path reads it. Because you cannot easily prove no gadget exists across your whole dependency tree, treat any confirmed pollution as exploitable and fix the sink.

Does a null-prototype object fully prevent pollution? It removes the sink for data held in that object, because there is no prototype chain to walk, so a __proto__ key becomes an ordinary property that poisons nothing. It protects that container. It does not retroactively fix other merges elsewhere, so use it everywhere untrusted key-value data is stored.

Why prefer a Map for untrusted key-value data? A Map keyed by strings has no prototype semantics for its entries, so lookups cannot be redirected through a polluted prototype. For caches, lookup tables, and anything keyed by attacker-influenced strings, a Map removes a whole category of risk that plain objects carry.

Can input validation alone stop it? Schema validation that accepts only known, typed properties and drops everything else is a strong layer, because keys like __proto__ are simply not in the schema. Pair it with sink hardening and structural defenses, since a single missed path is enough when the control is a blocklist.

Does freezing Object.prototype break my app? It can, if a dependency writes to the prototype legitimately. It is a powerful global backstop when it is compatible. Apply it in a test environment first and exercise the full application before relying on it in production.

How is this different from mass assignment? Mass assignment writes fields onto one specific object the developer did not mean to expose. Prototype pollution writes onto a shared prototype, so the effect is a default inherited by nearly every object in the process. The scope and the action-at-a-distance are what set it apart.

Where should I put the dangerous-key check? At the point of assignment inside the merge or setter, and applied to the property name before it is written. A check placed only on the outer request body can be bypassed by an intermediate parser that constructs the object graph after the check runs. Guard the write itself.

Can I detect pollution attempts in logs? Requests containing __proto__, constructor, or prototype in body or query keys are worth logging and alerting on, since legitimate clients rarely send them as structural keys. Treat such requests as probes and confirm your sinks reject them, rather than relying on the log alone to stop the attack.

Prototype pollution is a reminder that shared mutable defaults are a security liability. One writable prototype turns a single injected key into behaviour that ripples across every object and every dependency in the process, and the payoff for the attacker scales with whatever gadget happens to read that key. Block the dangerous keys at your merges, hold untrusted data in structures that have no prototype to poison, freeze what you can, and validate inputs against a schema. For how this class fits the wider integrity-failure landscape, see the OWASP Top 10, and see the payload side of the story on our attacker file-formats reference.

Sources & further reading

Sharetwitterlinkedin

Related guides