Prototype Pollution: Client-side and Server-side Attacks

Depov

Activist
ULTIMATE
SUPREME
PREMIUM
MEMBER
Joined
Feb 18, 2025
Messages
126
Reaction score
115
Deposit
0$
Prototype Pollution rarely looks like a loud vulnerability. Usually it all starts with a piece of code that no one considers dangerous: the function of merging objects, analysis of parameters into the structure of settings, universal helper for deep copy, an empty config object, which then goes into the render, the stage loader, the template designer or system call. Before the first incident, this is perceived as a normal engineering strapping. The problem is that the error does not live in one particular place. One fragment receives data, another writes by the key, the third later reads the inherited property as a standard parameter. As a result, the bug does not occur at the level of one function, but at the junction of several harmless solutions.

Prototype Pollution is unpleasant. Separately JSON.parse(), Object.assign(), analysis of location.hash, the rendering of the component or the challenge child_process can look completely calm. But if the user value once reached the prototype, the application begins to work with other people’s properties as if with their own. On the client, this is poured out in the DOM XSS, the substitution of src, srcoc, handlers and widget configuration. On the server - in the substitution of the parameters of the framework, templater, intermediate handler or system API. That is why Prototype Pollution is poorly caught by intuition: the source is in one layer, the effect is manifested in the other, and between them there is often a normal official code that has not caused suspicion for years.

This vulnerability rarely lives like one bug in one place. This is usually a three-part chain:

  • the source of controlled data;
  • the route of recording in the prototype;
  • a portion of the code that later uses a contaminated property.

Where the object model breaks down​

Why an empty object is not empty​

A normal object in JavaScript has its own fields and prototype. When the code reads the property, the engine first searches for it in the object itself, then rises along the chain of prototypes. Therefore, an empty {} is not really empty - it inherits behavior from Object.prototype.

It's a normal mechanic of language. The problem begins when the application allows you to record data not in the object itself, but in its prototype. Then new objects begin to inherit not only the standard properties, but also what someone put there.

If Object.prototype is contaminated, the effect is diverged by a large piece of application immediately. If the prototype of a particular settings object changes, the scale is smaller, but this is often enough to operate. Vulnerabilities do not need to spoil the entire runtime. Sometimes it is enough to break one object, which then goes into the sensitive code.

proto, prototype and constructor.prototype​

On this topic, even those who have already faced Prototype Pollution are often stumbled on this topic.

__proto__ - historical access to the prototype of the object.
prototype - the property of the function-constructor, from which prototypes of instances are created.
constructor.prototype - a workaround to the same object.




Where Prototype Pollution Comes From​

Insecure recursive merger​

The most common source is the self-written deep fusion of objects. The code looks so ordinary that it is almost not noticed in the audit.

JavaScript:

function isPlainObject(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}

function deepMerge(target, source) {
for (const key of Object.keys(source)) {
const src = source[key];
const dst = target[key];

if (isPlainObject(src) && isPlainObject(dst)) {
deepMerge(dst, src);
} else {
target[key] = src;
}
}

return target;
}
On normal data, this is just a service function. The problem begins when the input object contains a special path to the prototype.

JavaScript:

const payload = JSON.parse('{"__proto__":{"visible":true}}');

const state = {};
deepMerge(state, payload);

console.log(state.visible); // true
console.log(({}).visible); // true
console.log({ test: 1 }.visible); // true
It is useful to dwell on the mechanics. JSON.parse() itself does not yet do anything “magical”. It only creates an object that has its own field proto. The danger appears later when this object picks up the code that recursively on the keys and thereby folds the usual line proto Moving to the prototype.

This is one of the reasons why the bug lives so long. The developer looks at JSON.parse() - everything is calm there. He looks at deepMerge() - also like no crime. The problem is born at the junction.

URL Settings and Way Analysis​

In front-end, the source of pollution often sits in a code that is not perceived as dangerous at all. For example, a page can turn query string into an embedded settings object.
JavaScript:

function setByPath(target, path, value) {
const parts = path.split(".");
let cursor = target;

for (let i = 0; i < parts.length - 1; i++) {
const key = parts;
if (!cursor[key] || typeof cursor[key] !== "object") {
cursor[key] = {};
}
cursor = cursor[key];
}

cursor[parts[parts.length - 1]] = value;
}

const params = {};
setByPath(params, "__proto__.theme", "dark");

This code is very fond of universality. It can lay a.b.c=value, works for filters, settings and shapes. The problem is that without filtration of special keys, he is able not only to build nested objects, but also to go into the prototype chain.

A separate class of such bugs lives in old query string and location.has, especially if the project is for many years and some of the client dependencies no one has been revising for a long time.

postMesage, JSON and deep copies​

Another unpleasant surface is the messages between windows, iframes and widgets. postMessage itself does not make Prototype Pollution, but as soon as the data obtained hit the universal function of the merger or layout on the tracks, history repeats itself.

The typical script I see:

  • the widget takes JSON from postMessage;
  • data is transformed into an object;
  • the object merges with the current configurations of the component;
  • One of the keys takes the record to the prototype.
In large front-end builds, this is especially inconvenient to investigate, because the source is in one place, and the effect manifests itself in another, sometimes through several library layers.

How Polulation Becomes Customer Exploitation​

Prototype Pollution is not XSS. For it to become XSS, you need a section of the code that will read the contaminated property and transfer it to a sensitive place. This site is usually called a gadget.

DOM XSS via HHTML​

The most understandable example is the configuration object in which the application reads html, if it is given.

JavaScript:

function renderCard(options = {}) {
const container = document.getElementById("card");

if (options.html) {
container.innerHTML = options.html;
return;
}

container.textContent = "Default content";
}
It looks like the usual code. The developer expects that the options will be either empty or with clearly defined fields. But if earlier in the application there was already a contamination of the prototype, an empty object ceases to be truly empty.

JavaScript:

const payload = JSON.parse('{"__proto__":{"html":"<img src=x onerror=alert(1)>"}}');
deepMerge({}, payload);

renderCard({});
The key problem here is not in HTML as such. It is that the code reads an optional property at the object of settings, without checking where this property came from - from the object itself or from the prototype.

Substitution script.src​

In frontends are dangerous not only HTML inserts. Often, pollution comes to the loading of scripts.
JavaScript:

function loadWidget(options = {}) {
const script = document.createElement("script");
script.src = options.src || "/static/widget.js";
document.head.appendChild(script);
}
Such a fragment often looks even neat: there is a default value, there is a separate download function. But if options.src gets caught from the prototype, the code will behave as if the script address is set normally.

JavaScript:

const payload = JSON.parse('{"__proto__":{"src":"https://example.invalid/widget.js"}}');
deepMerge({}, payload);

loadWidget({});
This is a useful example not only for hunting for XSS. Here it is well shown why Prototype Pollution is dangerous for a frontend with a large number of third-party libraries, analytics, advertising scripts and widgets. There, configuration objects are found everywhere, and many fields remain optional.

Event and call field processors​

There is another class of gadgets that are often overlooked. The application does not write to the DOM directly, but uses the value from the object of settings as a function, the name of the processor, the string for a timer or the initialization parameter of the third-party library.

You can do this:

JavaScript:

function setupTimer(options = {}) {
if (options.delayHandler) {
setTimeout(options.delayHandler, 100);
}
}
Or like that:

JavaScript:

function bindAction(button, options = {}) {
if (options.onClick) {
button.setAttribute("onclick", options.onClick);
}
}
The more self-written widgets in the application, strapping over third-party libraries and “flexible” configuration layers, the more chances to find exactly such gadgets, and not trainingHHTML.

What it looks like in the SPA code​

If you reduce the typical client scenario to a minimum, you get something like this:

JavaScript:

function parseHash() {
const result = {};
const raw = location.hash.slice(1);

for (const pair of raw.split("&")) {
const [key, value] = pair.split("=");
if (key) {
setByPath(result, decodeURIComponent(key), decodeURIComponent(value || ""));
}
}

return result;
}

function initWidget() {
const userOptions = parseHash();
const defaults = { theme: "light" };

deepMerge(defaults, userOptions);
renderCard({});
}
We are interested not in the paraseHash function itself, but the general drawing:

  • data came from the URL;
  • decomposed along the paths;
  • merged into the object of settings;
  • Another piece of code read the “empty” object that inherited a polluted field.
This is how Prototype Pollution lives in real interfaces. Not in one spectacular fragment, but in several ordinary functions, each of which separately looks harmless.

Server-side: where the dangerous part begins​

On the Prototype Pollution server, it is more unpleasant for two reasons. First, pollution lives in the process before the restart. Secondly, even a careful check can accidentally turn into a denial of service if you touch the wrong path and not that gadget.

Therefore, for the server part, it is useful to start not with attempts to immediately get to the execution of commands, but from secure indicators - those that change the observed behavior of the service, but do not break it.

Safe indicator on Express​

Below is a minimal example where pollution first gets into the process, and then manifests itself in another processor.

JavaScript:

const express = require("express");
const app = express();

app.use(express.json());

function isPlainObject(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}

function deepMerge(target, source) {
for (const key of Object.keys(source)) {
const src = source[key];
const dst = target[key];

if (isPlainObject(src) && isPlainObject(dst)) {
deepMerge(dst, src);
} else {
target[key] = src;
}
}

return target;
}

app.post("/api/profile", (req, res) => {
const profile = {};
deepMerge(profile, req.body);
res.json({ saved: true });
});

app.get("/api/ping", (req, res) => {
const options = {};
res.status(options.status || 200).json({ ok: true });
});

app.listen(3000);
If POST /api/profile reaches the prototype and puts status there, the next request to GET /api/ping suddenly begins to respond with the code that was expected in the application.

This is a very useful practical piece for two reasons. First, it shows server-side pollution without a dangerous operating system. Secondly, you can immediately see the main difference from the client part: the pollution does not disappear after one render. It remains to live in the process and begins to influence subsequent requests.

Why child_process becomes a gadget​

Now you can move to server effects more seriously.

The danger of child_process is not that the developer necessarily runs an external command with a user string. For Prototype Pollution, this is not even necessary. The problem begins where the code transmits to the API the start of the process empty or incomplete object of the options and expects that the missing fields simply will remain undefined.

JavaScript:

const { execFile } = require("node:child_process");

function convertImage(inputPath, outputPath, options = {}) {
return execFile(
"convert",
[inputPath, outputPath],
options
);
}
Outside, it looks like a pure wrapping function. But if the options receives sensitive fields from the prototype, the behavior of the call may change. That is why in the server-side analysis Prototype Pollution is paid so much attention not to the lines of commands, but to configuration objects around them.

Templaters and Hidden Configuration​

With templaters, the story is similar. The problem is usually not that the application is directly slipped by an arbitrary pattern. The problem is that the render(), compile() or renderFile() transmits an object of settings, some of which should have been empty or secure by default.
JavaScript:

function renderPage(engine, template, data, options = {}) {
return engine.render(template, data, options);
}
If the prototype has already occurred in the process, options can suddenly get fields that affect the work of the templater - file search, rendering behavior, compilation modes, shielding or other parameters.

It is especially painful to find this in large Node.js-applications, where the template builder is wrapped in several inner layers, and the source of pollution sits in general in another part of the service.

Tools that really help​

For the customer part, tools work well that are able to quickly find the source of pollution and check whether the value reaches a dangerous place in DOM. DOM Invader and similar means for analyzing client chains are especially useful here. For primary screening, there are also narrower utilities like ppmap.

For the server part, automation is useful, but do not expect a miracle from it. An external scan can highlight safe indicators - a strange answer code, JSON behavior, unexpected headlines. Real value usually appears when dealing with dependencies, strapping over child_process, templaters and data fusion functions.

Tools here save time, but do not replace understanding of the shape of the bug. Prototype Pollution too often lives in the joints between the layers so that it can be completely given to one scanner.

Do not do “universal” deep fusion of user objects​

The first and most useful correction is to stop mindlessly pouring an arbitrary user object into the application structure. Instead of “smart” general deepMerge() is almost always better than the explicit extraction of permitted fields.

JavaScript:

function normalizeProfile(input) {
return {
displayName: typeof input.displayName === "string" ? input.displayName : "",
theme: input.theme === "dark" ? "dark" : "light",
pageSize: Number.isInteger(input.pageSize) ? input.pageSize : 20
};
}
Such a code is less “engraved” than a universal merger helper, but there is no magic. Allowed fields are visible immediately, the rest simply will not get further.

Do not use ordinary objects as dictionaries​

If you need a “key-value” structure with custom keys, it’s better to take an Map or an object without a prototype.
JavaScript:

const safeDict = Object.create(null);
safeDict.userTheme = "dark";
Or like that:
JavaScript:

const cache = new Map();
cache.set("user:42", { theme: "dark" });
The ordinary object is too easily transformed from a convenient container to the surface of the attack.

Check the ownership of the field to the object itself​

If the application reads the optional properties of the object of the settings, it is useful to check that the field really belongs to the object itself, and not the prototype.

JavaScript:

function renderCard(options = {}) {
const container = document.getElementById("card");

if (Object.hasOwn(options, "html")) {
container.innerHTML = options.html;
return;
}

container.textContent = "Default content";
}
This is not a universal protection against all problems around Prototype Pollution, but a very useful barrier in places where the code uses configuration objects.

Why freezing the prototype will not help​

The idea of object.freeze (Object.prototype) regularly pops up as a fast remedy. As an additional barrier, it is useful, but does not solve the architectural problem in itself. It has a price: compatibility, order of initialization, the behavior of polyphiles, unexpected side effects in the old code.

If the application is already massively based on universal merge helper, empty settings objects and dynamic track recording, freezing the prototype will be an emergency brake rather than a normal correction.

Instead of conclusion​

Once the application has a way of writing to the prototype and a section of code that reads a non-existent property as an acceptable default value, then not the “theoretical feature of JavaScript” begins, but quite a working surface of the attack. On the client, it goes to DOM XSS and the download change. On the server - to manage the behavior of the process, framework and system calls.

Good protection doesn’t start by finding one forbidden key. It starts where the team stops taking a universal object as a safe container for everything in a row.
 
Top Bottom