0x55aa
← Back to Blog

Prototype Pollution: The JavaScript Attack That Breaks Everything Without Touching Anything 🧬

7 min read

Prototype Pollution: The JavaScript Attack That Breaks Everything Without Touching Anything 🧬

Here's a fun fact: some of the most popular JavaScript libraries in the world — lodash, jQuery, Handlebars — have shipped prototype pollution vulnerabilities. Libraries with hundreds of millions of weekly downloads. So if you think "that's not my problem," buckle up.

As someone who builds Node.js backends professionally and hangs around security communities in my spare time, prototype pollution is one of those attacks that makes you go "wait... that's it? That's the whole attack?!" — and then immediately audit every npm install you've ever run.

Let me explain what's going on.

JavaScript Objects and the Prototype Chain 🧬

Before we get to the attack, a tiny bit of JavaScript fundamentals. Every object in JavaScript has a prototype — think of it as a parent object it inherits from. When you access a property that doesn't exist on an object, JavaScript walks up the prototype chain looking for it.

const myObj = {};
console.log(myObj.toString); // Works! Inherited from Object.prototype

All plain objects in JavaScript ultimately inherit from Object.prototype. It's the grandparent of literally everything.

Here's the scary part: if an attacker can modify Object.prototype, they've just modified the behavior of every single object in your Node.js process. Every. Single. One.

That's prototype pollution.

The Attack — Simpler Than You Think 😱

// DANGEROUS: merging user-controlled input naively
function merge(target, source) {
  for (let key in source) {
    if (typeof source[key] === 'object') {
      target[key] = merge(target[key] || {}, source[key]);
    } else {
      target[key] = source[key];
    }
  }
  return target;
}

// Attacker sends this JSON payload:
const userInput = JSON.parse('{"__proto__": {"isAdmin": true}}');
merge({}, userInput);

// Now EVERY object has isAdmin = true
const innocentObj = {};
console.log(innocentObj.isAdmin); // true 🔥

The attacker sends __proto__ as a key. Your naive merge function walks right into it, setting properties directly on Object.prototype. Game over.

In my experience building production systems, I've seen this in "harmless-looking" utility functions for deep cloning objects, merging config options, or parsing query parameters. The vulnerability hides in boring, everyday code.

Real Talk: Why This Actually Matters 💀

"But wait, setting isAdmin: true on a random object doesn't actually give them admin rights..."

You're right — in isolation it doesn't. But here's where it gets nasty. Code like this exists everywhere:

// Somewhere deep in your authorization logic
function checkAdmin(user) {
  // Checks user.isAdmin, but if it doesn't exist, falls through
  if (user.isAdmin) {
    return true;
  }
  return false;
}

// After prototype pollution, EVERY user object "has" isAdmin = true
// because it's now on Object.prototype
const regularUser = { name: "Bob", role: "viewer" };
console.log(checkAdmin(regularUser)); // true 😱😱😱

In security communities, we often discuss how prototype pollution by itself is "just" a gadget — it needs something else in the codebase to make it dangerous. But modern Node.js apps are full of those gadgets. Connect it to an RCE (Remote Code Execution) via template engines like Handlebars, and you've got a critical severity vulnerability.

This exact chain: Prototype Pollution → Handlebars → RCE was a real CVE. In production. In the wild.

How Real Libraries Got Hit 🎯

lodash (CVE-2019-10744) — The merge, mergeWith, defaultsDeep functions were all vulnerable. lodash is downloaded 40+ million times a week. This hit everyone.

jQuery (CVE-2019-11358) — The $.extend() function. Used on practically every website ever built.

Handlebars (CVE-2019-19919) — Allowed prototype pollution that could lead to code execution through template compilation. That's RCE territory.

The sobering lesson I took from this: your dependencies' bugs are your bugs. When I audited one of our production Node.js services, we had transitive dependencies three levels deep pulling in a vulnerable version of lodash. We weren't using lodash directly, but our ORM was using a plugin that used a middleware that used lodash. Fun times.

Pro Tip: Finding It in Your Codebase 🔍

Check your code for these patterns — they're classic prototype pollution sinks:

// 🚨 DANGEROUS patterns to audit:

// 1. Naive recursive merge
obj[key] = value; // when key comes from user input

// 2. Deep clone with user input
JSON.parse(JSON.stringify(userControlledData));
// (actually safe for pollution, but watch what happens after)

// 3. Object spread with dynamic keys from user
const config = { ...defaults, ...userOptions }; // spread is actually SAFE
// but manual key assignment is not:
for (const [key, value] of Object.entries(userOptions)) {
  config[key] = value; // 🚨 if key is "__proto__"
}

The Fix: Defence in Depth 🛡️

Option 1: Use Object.create(null) for data containers

// Safe: no prototype chain to pollute
const safeContainer = Object.create(null);
safeContainer.__proto__ = "attack!"; // Just sets a regular property
console.log(Object.prototype.__proto__); // Still safe! ✅

Option 2: Validate keys before using them

// Block the magic properties
function safeMerge(target, source) {
  for (const key of Object.keys(source)) {
    // Explicitly block prototype-chain properties
    if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
      continue; // Skip! Don't touch these! 🚫
    }
    if (typeof source[key] === 'object' && source[key] !== null) {
      target[key] = safeMerge(target[key] || {}, source[key]);
    } else {
      target[key] = source[key];
    }
  }
  return target;
}

Option 3: Use Object.hasOwn() instead of in operator

// 🚨 Dangerous: checks prototype chain too
if ('isAdmin' in user) { ... }

// ✅ Safe: only checks own properties
if (Object.hasOwn(user, 'isAdmin')) { ... }

Option 4: Freeze Object.prototype (nuclear option)

// At app startup, before anything else loads:
Object.freeze(Object.prototype);

// Now prototype pollution attempts will silently fail (or throw in strict mode)

This is the nuclear option. It can break some legitimate code too, so test it thoroughly. But for security-critical apps? Worth considering.

Real Talk: Immediate Actions 🏃

  1. Run npm audit right now. Seriously, open a terminal.
  2. Check for lodash — if you're on < 4.17.19, update immediately
  3. Switch to safe alternativeslodash/merge was fixed, but also consider deepmerge or rfdc for cloning
  4. Add --noPrototypeBuiltins to your ESLint config — catches dangerous prototype chain access patterns statically
# Quick audit
npm audit --audit-level=high

# Check for known vulnerable versions
npx better-npm-audit audit

The Sneaky Cousin: constructor.prototype 🥷

Just when you thought blocking __proto__ was enough...

// Your filter blocks __proto__, but this still works:
const payload = '{"constructor": {"prototype": {"isAdmin": true}}}';

The attack works through constructor.prototype too. Your blocklist needs both:

  • __proto__
  • constructor
  • prototype

As someone passionate about security, this is what I love and hate about this field — it's a game of whack-a-mole, and the moles are creative.

Conclusion: Paranoia is a Feature ✅

Prototype pollution is one of those vulnerabilities that rewards attackers for reading JavaScript internals and punishes developers for trusting their own language. The JavaScript prototype chain is a beautiful feature that becomes terrifying when you realize it's globally mutable.

The core lessons:

  • Never blindly merge user-controlled objects
  • Block __proto__, constructor, and prototype as keys
  • Keep your dependencies updated — this is where most prototype pollution lives
  • Use Object.create(null) for data containers, not {}
  • Object.hasOwn() over in operator when dealing with untrusted data

In production, I now run npm audit as part of our CI pipeline — it's a 2-minute addition that's caught vulnerable transitive dependencies before they shipped to users.


Found a prototype pollution gadget chain in the wild? I'd love to hear about it — hit me up on LinkedIn or check out my work on GitHub.

Stay paranoid, stay safe, and for the love of all things holy — freeze your Object.prototype in production. 🔐