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:
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__ - 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.
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.
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.
The typical script I see:
You can do this:
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.
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.
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.
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.
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.
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.
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.
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.On normal data, this is just a service function. The problem begins when the input object contains a special path to the prototype.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;
}
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.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
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.
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.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:
function renderCard(options = {}) {
const container = document.getElementById("card");
if (options.html) {
container.innerHTML = options.html;
return;
}
container.textContent = "Default content";
}
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.JavaScript:
const payload = JSON.parse('{"__proto__":{"html":"<img src=x onerror=alert(1)>"}}');
deepMerge({}, payload);
renderCard({});
Substitution script.src
In frontends are dangerous not only HTML inserts. Often, pollution comes to the loading of scripts.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:
function loadWidget(options = {}) {
const script = document.createElement("script");
script.src = options.src || "/static/widget.js";
document.head.appendChild(script);
}
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.JavaScript:
const payload = JSON.parse('{"__proto__":{"src":"https://example.invalid/widget.js"}}');
deepMerge({}, payload);
loadWidget({});
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:
Or like that:JavaScript:
function setupTimer(options = {}) {
if (options.delayHandler) {
setTimeout(options.delayHandler, 100);
}
}
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.JavaScript:
function bindAction(button, options = {}) {
if (options.onClick) {
button.setAttribute("onclick", options.onClick);
}
}
What it looks like in the SPA code
If you reduce the typical client scenario to a minimum, you get something like this:We are interested not in the paraseHash function itself, but the general drawing: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({});
}
- 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.
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.
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.JavaScript:
const { execFile } = require("node:child_process");
function convertImage(inputPath, outputPath, options = {}) {
return execFile(
"convert",
[inputPath, outputPath],
options
);
}
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.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.JavaScript:
function renderPage(engine, template, data, options = {}) {
return engine.render(template, data, options);
}
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.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.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
};
}
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.Or like that:JavaScript:
const safeDict = Object.create(null);
safeDict.userTheme = "dark";
The ordinary object is too easily transformed from a convenient container to the surface of the attack.JavaScript:
const cache = new Map();
cache.set("user:42", { theme: "dark" });
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.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.JavaScript:
function renderCard(options = {}) {
const container = document.getElementById("card");
if (Object.hasOwn(options, "html")) {
container.innerHTML = options.html;
return;
}
container.textContent = "Default content";
}
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.