0x55aa
Back to Blog

Prototype Pollution: JavaScript's Sneakiest Vulnerability ๐Ÿงฌโ˜ ๏ธ

|
7 min read

Prototype Pollution: JavaScript's Sneakiest Vulnerability ๐Ÿงฌโ˜ ๏ธ

Here's a JavaScript quiz. What does this print?

const user = {};
console.log(user.isAdmin); // What do you get?

You'd say undefined, right? Obviously. The object is empty.

But what if I told you an attacker could make that print true โ€” without ever touching the user object โ€” just by sending a crafted JSON payload to your API? And what if that isAdmin check was guarding your /admin route?

Welcome to Prototype Pollution, the vulnerability hiding in plain sight inside your lodash.merge, deepClone, and Object.assign calls. ๐Ÿ˜ˆ

JavaScript Prototypes: A 30-Second Recap ๐Ÿ”ฌ

Every object in JavaScript has a hidden link to a prototype. When you access a property that doesn't exist on an object, JS walks up the prototype chain to find it.

const dog = { name: "Rex" };

// dog doesn't have a .toString() method
// But Object.prototype does!
console.log(dog.toString()); // "[object Object]"

// The chain: dog โ†’ Object.prototype โ†’ null

Object.prototype is the grandparent of every plain object in JavaScript. It's shared. Globally. By everything.

You see where this is going. ๐Ÿ˜ฌ

The Attack: Poisoning the Well ๐Ÿงช

Here's the core of prototype pollution:

const payload = JSON.parse('{"__proto__": {"isAdmin": true}}');

// A naive deep merge function (like the ones in lodash < 4.17.12)
function merge(target, source) {
  for (const key in source) {
    if (typeof source[key] === 'object') {
      if (!target[key]) target[key] = {};
      merge(target[key], source[key]);
    } else {
      target[key] = source[key];
    }
  }
}

const config = {};
merge(config, payload);

// config looks fine...
console.log(config); // {}

// But Object.prototype is now poisoned!
const newObj = {};
console.log(newObj.isAdmin); // true ๐Ÿ˜ฑ
console.log({}.isAdmin);     // true ๐Ÿ˜ฑ๐Ÿ˜ฑ
// EVERY new plain object now has isAdmin: true

The attacker didn't touch your user object. They didn't touch your config object. They poisoned the prototype that all objects inherit from. Now every authorization check in your app that looks like:

if (user.isAdmin) {
  // grant access
}

...will silently pass for any user, even unauthenticated ones. ๐Ÿ’€

Real-World Impact: This Isn't Theoretical ๐Ÿ’ฅ

CVE-2019-10744 โ€” Lodash < 4.17.12: The merge, mergeWith, defaultsDeep, and zipObjectDeep functions were all vulnerable. Lodash has hundreds of millions of weekly downloads. This was a very big deal.

CVE-2020-28282 โ€” getobject npm package: Used by Grunt.js (yes, the build tool). Prototype pollution via property path setting.

Real exploit scenario I've seen in the wild:

// Express.js app
app.post('/api/settings', (req, res) => {
  // User can update their settings
  const userSettings = {};
  _.merge(userSettings, req.body); // lodash merge โ€” VULNERABLE!
  saveSettings(req.user.id, userSettings);
  res.json({ success: true });
});

// Attacker sends:
// POST /api/settings
// {"__proto__": {"isAdmin": true, "role": "superuser"}}

// Now somewhere else in the codebase...
app.get('/admin', (req, res) => {
  const tempObj = {};
  if (tempObj.isAdmin) { // This is now TRUE for everyone!
    res.render('admin-panel');
  }
});

The attacker hit /api/settings (an endpoint they're allowed to use), poisoned the prototype, and now has access to /admin which they're not allowed to use. No credentials stolen. No SQL injection. Just a crafty JSON object. ๐ŸŽฉ

Spotting Vulnerable Patterns ๐Ÿ”

Here are the patterns that scream "prototype pollution risk" in your codebase:

// โŒ Vulnerable: Naive recursive merge
function deepMerge(target, source) {
  for (const key in source) {           // 'in' iterates __proto__ too!
    if (typeof source[key] === 'object') {
      target[key] = target[key] || {};
      deepMerge(target[key], source[key]); // Recursion into __proto__!
    } else {
      target[key] = source[key];
    }
  }
}

// โŒ Vulnerable: Setting properties by path string
function setByPath(obj, path, value) {
  const parts = path.split('.');
  let current = obj;
  for (let i = 0; i < parts.length - 1; i++) {
    current = current[parts[i]] = current[parts[i]] || {};
  }
  current[parts[parts.length - 1]] = value; // obj["__proto__"]["isAdmin"] = true ๐Ÿ’€
}

// โŒ Vulnerable: Using user-controlled keys directly
app.post('/config', (req, res) => {
  const { key, value } = req.body;
  config[key] = value; // key = "__proto__" ... oops
});

The Fixes: Lock It Down ๐Ÿ”’

Fix 1: Block Dangerous Keys

// โœ… Validate keys before merging
function safeMerge(target, source) {
  const BLOCKED_KEYS = ['__proto__', 'constructor', 'prototype'];

  for (const key of Object.keys(source)) { // Object.keys() โ€” doesn't include __proto__!
    if (BLOCKED_KEYS.includes(key)) {
      console.warn(`Blocked dangerous key: ${key}`);
      continue;
    }
    if (typeof source[key] === 'object' && source[key] !== null) {
      target[key] = target[key] || {};
      safeMerge(target[key], source[key]);
    } else {
      target[key] = source[key];
    }
  }
  return target;
}

Fix 2: Use Object.create(null) for Data Containers

// โœ… Objects with NO prototype โ€” can't be polluted!
const safeConfig = Object.create(null);
safeConfig.theme = 'dark';
safeConfig.language = 'en';

// Attacker tries:
safeConfig['__proto__'] = { isAdmin: true };
// This just sets a regular key named "__proto__" โ€” not the actual prototype
// Object.prototype remains untouched โœ…

// Bonus: hasOwnProperty now safe too
// (regular objects can have hasOwnProperty overridden โ€” prototype-less ones can't!)

Fix 3: Use JSON.parse with a Reviver

// โœ… Sanitize parsed JSON before use
function safeJSONParse(str) {
  return JSON.parse(str, (key, value) => {
    if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
      return undefined; // Drop it
    }
    return value;
  });
}

const payload = safeJSONParse('{"__proto__": {"isAdmin": true}}');
// payload.__proto__ is undefined โ€” attack neutralized โœ…

Fix 4: Update Your Libraries (Seriously, Do It)

# Check for known prototype pollution CVEs
npm audit

# Update lodash (CVE-2019-10744 fix is in 4.17.12+)
npm install lodash@latest

# Use safer alternatives for merging
npm install deepmerge  # Prototype-pollution safe by design
// โœ… deepmerge handles this correctly
import merge from 'deepmerge';

const result = merge({}, JSON.parse('{"__proto__": {"isAdmin": true}}'));
// Object.prototype.isAdmin is still undefined โœ…

Fix 5: Freeze the Prototype (Nuclear Option)

// โœ… Prevent ANY modifications to Object.prototype
Object.freeze(Object.prototype);

// Now prototype pollution attempts just silently fail
const payload = JSON.parse('{"__proto__": {"isAdmin": true}}');
const merged = {};
Object.assign(merged, payload); // __proto__ assignment is a no-op
console.log({}.isAdmin); // still undefined โœ…

// Add this to your app entry point (index.js / server.js)
// Before any user input is processed!

Detection: Find It Before Attackers Do ๐Ÿ•ต๏ธ

# Scan for vulnerable patterns in your code
grep -r "__proto__" src/ --include="*.js"
grep -r "constructor\[" src/ --include="*.js"

# Check for vulnerable lodash version
npm list lodash

# Run automated scan
npx is-website-vulnerable https://your-site.com

# Check all dependencies for prototype pollution CVEs
npm audit --json | jq '.vulnerabilities | to_entries[] | select(.value.title | contains("Prototype"))'

The Quick Defense Checklist โœ…

Before you deploy your next Node.js app:

  • Updated lodash to 4.17.12+ (or replaced it entirely)
  • Object.freeze(Object.prototype) at app startup
  • Using Object.keys() instead of for...in in merge functions
  • Validating/sanitizing JSON before deep merging user input
  • No property-path setters accepting user-controlled strings
  • npm audit shows zero prototype pollution CVEs

The Bigger Picture ๐ŸŽฏ

Prototype pollution is a reminder that JavaScript's dynamic nature is both its superpower and its Achilles heel. The same flexibility that makes JS expressive is what lets a {"__proto__": ...} payload silently rewrite the rules of your entire application.

The attack surface is everywhere user-controlled data gets merged, cloned, or used as property keys. That's a lot of places in a typical API.

The good news: the fixes are straightforward, the tooling is mature, and awareness is half the battle. Most prototype pollution vulnerabilities exist because developers simply didn't know the attack was possible.

Now you do. ๐Ÿ˜Ž


Found a prototype pollution bug in the wild? Let's talk on LinkedIn โ€” I collect war stories.

Want to see secure Node.js patterns in action? My GitHub has production-grade examples.

P.S. โ€” Go run npm audit right now. I'll wait. Check for any lodash/merge library CVEs. Seriously. ๐Ÿ”

P.P.S. โ€” If you're still on lodash 4.17.11 or older, stop reading this and go update. Right now. This second. ๐Ÿƒโ€โ™‚๏ธ

Thanks for reading!

Back to all posts