Skip to content
All posts
webrevised June 26, 20266 min read

Prototype pollution: editing the base class of every object

Why `__proto__` in a JSON body changes objects the code never touched, how to find the merge that lets you do it, and the gadget hunt that turns a polluted property into XSS on the client or command execution on the server.

In JavaScript, almost every object inherits from Object.prototype. Read a property an object does not have and the lookup walks up to that shared parent. So if you can write a property *onto* the parent, you have added that property to essentially every object in the program at once - including objects created before you did it, and objects in library code you have never seen.

That is prototype pollution. It is a two-stage bug and both stages matter: a source that lets you write to the prototype, and a gadget - somewhere in the application or its dependencies that reads a property, does not find it, walks up to your value, and does something dangerous with it.

The mechanism in five lines

const user = {};
user.__proto__.isAdmin = true;     // or: user["__proto__"]["isAdmin"] = true

const somethingElse = {};
console.log(somethingElse.isAdmin);   // true - it was never assigned
console.log({}.isAdmin);              // true - every object, everywhere

__proto__ is an accessor on Object.prototype that gets and sets the internal prototype. constructor.prototype reaches the same place by a different route, which matters because filters often block only the first spelling.

Finding the source

The source is any code that copies attacker-controlled keys into an object recursively, or that sets a nested path from a string. Three patterns cover nearly all of them.

// 1. A recursive merge with no key check.
function merge(target, source) {
  for (const key in source) {
    if (typeof source[key] === "object") merge(target[key], source[key]);
    else target[key] = source[key];
  }
}
merge({}, JSON.parse(req.body));       // {"__proto__": {"isAdmin": true}}

// 2. A path setter.
function set(obj, path, value) {
  const keys = path.split(".");
  ...
}
set(config, req.query.k, req.query.v); // ?k=__proto__.isAdmin&v=true

// 3. A query-string parser that builds nested objects.
// ?__proto__[isAdmin]=true  or  ?constructor[prototype][isAdmin]=true
  • Client-side sources are the URL hash and query string parsed into an object, postMessage handlers, and JSON.parse of anything from localStorage. Libraries like older jQuery $.extend(true, ...) and many query-string parsers are the classic carriers.
  • Server-side sources are a JSON body merged into config or into a Mongoose model, an Express query parser with extended: true, YAML or TOML config merged with defaults, and any lodash.merge / defaultsDeep on old versions.
  • The tell in a challenge is a settings, preferences, or profile endpoint that accepts arbitrary JSON and merges it - especially one that responds identically whatever you send. That silence is what a merge looks like from outside.

Client-side gadgets

In the browser, the objective is script execution. The gadget is a library that reads an option from an object without checking that it was actually set.

Polluted propertyGadgetEffect
src, url, dataA script loader that builds a <script src> from optionsLoads your script.
html, template, contentA templating call that sinks into innerHTMLHTML injection, so XSS.
hitCallback, onload, callbackAn analytics or plugin library that calls itDirect function invocation.
sanitize, ALLOWED_TAGS, SAFE_FOR_TEMPLATESA sanitiser's option objectDisables sanitisation, re-enabling an otherwise-blocked payload.
transport_url, baseURLAn SDK that composes a request URLLoads code from your origin.

In a CTF this usually pairs with an admin bot: pollute via the URL, get the bot to visit that URL, and the gadget fires in the bot's session. The XSS post covers the exfiltration half; prototype pollution is just an unusual route to the same script execution, and it is the route that works when the obvious sinks are all sanitised.

Server-side gadgets

On Node the ceiling is higher, because several core APIs read options from objects and one of them spawns processes.

// child_process.spawn / exec read options off the object they are given.
// Polluting these makes any later spawn run your command.
{"__proto__": {"shell": "/proc/self/exe", "NODE_OPTIONS": "--require=/proc/self/environ"}}
{"__proto__": {"env": {"NODE_OPTIONS": "--require /tmp/x.js"}}}

// If the app renders EJS/Pug/Handlebars, pollute the compiler options.
{"__proto__": {"outputFunctionName": "x;process.mainModule.require('child_process').execSync('id');//"}}
The NODE_OPTIONS route is the general one: pollute the environment a child process inherits, and --require loads a file you control at startup. Combine with any file-write primitive, or point it at a file whose content you influence.
  • Template engine options are the most reliable server-side gadget. EJS, Pug and Handlebars all read compilation settings off an options object, and several of those settings are interpolated into generated code - which is server-side template injection reached without ever controlling a template.
  • Express and framework internals: polluting status, json spaces, views, or view engine changes how responses are built. Polluting a route's options can change which handler runs.
  • Authorisation objects: the mundane and very common one. If a permission check reads user.isAdmin and the user object does not define it, pollution supplies it. No RCE needed, and this is what most CTF challenges in this class actually want.
  • `toString` and `valueOf`: polluting these changes how objects stringify, which can inject into SQL strings, log lines, or file paths built by concatenation.

Getting past the filters

Most defences block the literal string __proto__, which leaves several routes open:

  • constructor.prototype.x reaches the same object without the blocked word.
  • constructor["prototype"]["x"] if dot notation specifically is filtered.
  • Unicode and encoding tricks where the check runs before a decode - the same failure as in path traversal.
  • Polluting Array.prototype or Function.prototype instead, when the gadget reads from an array or a function.
  • Nesting one level deeper than the sanitiser recurses: some checks only inspect top-level keys.

Finding and exploiting a pollution source

  1. Find an endpoint or a client-side parser that takes structured input and merges it into something.
  2. Send {"__proto__": {"ctfpal": "x"}} and then look for ctfpal appearing somewhere it should not. Try constructor.prototype if the first is filtered.
  3. Once pollution is confirmed, identify the framework and the template engine from the response headers, error pages, and the client bundle.
  4. Pick the gadget that matches: an authorisation property first, because it is the simplest and most often the intended one.
  5. Escalate only if needed - template options for server-side execution, a script-loading option for client-side.
  6. Remember it is global. Test on an endpoint you can afford to break, and expect to need a restart if you overreach.

The reason this class is worth knowing well is that it defeats reasoning about data flow. You look at the vulnerable function and there is no path from your input to it - because the path goes through an object that neither piece of code mentions. Recognising that shape is the skill; the payloads are three strings.