White Hats - Nepal

Security research, bug bounty writeups, pentest notes

Prototype Pollution Exploitation: A Pentester's Practical Guide

TL;DR

Prototype pollution exploitation is a sophisticated attack vector targeting the fundamental inheritance model of JavaScript, allowing an attacker to inject properties into the global Object prototype. By successfully polluting a prototype, an attacker can modify the behavior of every object within the application environment, potentially leading to privilege escalation, bypass of security controls, or full system compromise. This vulnerability typically surfaces in functions that perform recursive merges or deep clones of user-controlled JSON data without proper key sanitization.

Understanding the JavaScript Prototype Chain and Inheritance

To master prototype pollution exploitation, you must first understand how JavaScript handles inheritance. Unlike class-based languages like Java or C++, JavaScript uses a prototype-based model. Every object has an internal link to another object called its prototype. When you attempt to access a property that doesn't exist on an object, JavaScript looks for it in that object's prototype, then the prototype's prototype, and so on, until it reaches Object.prototype, which is the top of the chain.

The __proto__ property is a getter/setter that exposes the internal prototype of an object. In modern Node.js and browser environments, if an attacker can control the keys of an object being merged into another, they can target __proto__ to add properties directly to the global Object.prototype. Since almost all objects inherit from this root, the injected property becomes globally available.


// Simple pollution example
let user = {};
let attacker_input = JSON.parse('{"__proto__": {"isAdmin": true}}');

// Vulnerable merge logic
function merge(target, source) {
    for (let key in source) {
        if (typeof target[key] === 'object' && typeof source[key] === 'object') {
            merge(target[key], source[key]);
        } else {
            target[key] = source[key];
        }
    }
}

merge(user, attacker_input);

let guest = {};
console.log(guest.isAdmin); // Output: true

In the example above, the guest object never had an isAdmin property defined. However, because the merge function traversed into __proto__, it added isAdmin: true to the global object prototype. This is the foundation of prototype pollution exploitation.

Key Takeaway: Prototype pollution is not just about changing a single object; it is about altering the blueprint of all objects in the runtime, which can break logic across the entire application.

Identifying Vulnerable Patterns in Modern Web Applications

During a web application security testing guide assessment, I look for specific patterns that indicate potential pollution. The most common culprits are utility libraries like Lodash, Underscore, or custom implementation of deep-merge functions. Historically, CVE-2018-3721 and CVE-2019-10744 highlighted how even widely used libraries like Lodash were susceptible to these attacks.

When performing reconnaissance, use an online port scanner to identify exposed management interfaces or development ports that might reveal the underlying tech stack. Once you identify a Node.js or heavy-client-side JavaScript environment, look for these three conditions:

Common input vectors include search query parameters, body parameters in POST requests, and configuration files. During api pentesting methodology, pay close attention to PUT or PATCH requests that accept complex JSON objects, as these often use vulnerable merge logic to update resource states.

Client-Side Exploitation: From Prototype Pollution to XSS

In the browser, prototype pollution exploitation is frequently used to achieve Cross-Site Scripting (XSS). This happens when a library or the application's own code uses an object to store configuration or state, and some of those properties are later rendered into the DOM or used in dangerous functions like eval() or setTimeout().

Consider a scenario where a site uses a tracking library that reads configuration from an object. If the library checks for a property like transport_url and uses it to create a script tag, we can pollute that property. If the site doesn't explicitly define transport_url, it will inherit our polluted value from the prototype chain.


// Attacker-controlled URL parameter: 
// ?__proto__[transport_url]=javascript:alert(1)

// Application code:
let config = {}; // Empty object
let script = document.createElement('script');
script.src = config.transport_url || '/default-tracker.js'; 
document.body.appendChild(script);

By polluting Object.prototype.transport_url, we've forced the application to load our malicious payload. This is a classic example of a "gadget." For more details on crafting these payloads, see our XSS attack example guide. The beauty of this attack is that it bypasses many traditional WAF rules because the "malicious" data is often hidden in standard JSON structures or URL parameters that don't look like typical XSS payloads.

Attack Type Primary Mechanism Common Impact
Client-Side Pollution DOM Gadgets / Config Overrides DOM XSS, Logic Bypass
Server-Side Pollution Process Environment / Child Processes RCE, Denial of Service (DoS)
Logic Pollution Property Overwriting (e.g., isAdmin) Privilege Escalation

Server-Side Exploitation: Escalating to Remote Code Execution (RCE)

Server-side prototype pollution exploitation in Node.js is significantly more dangerous. The goal here is usually to find a gadget within the Node.js core modules or a common dependency that can be used to execute system commands. A famous gadget involves the child_process.spawn function.

When spawn() is called, it can take an options object. If this object is not provided or is missing certain properties, Node.js will look into the prototype chain. By polluting properties like shell, env, or NODE_OPTIONS, we can manipulate how new processes are created. For example, polluting shell to point to /bin/sh and env to include malicious variables can lead to command injection.

Another powerful vector is polluting the NODE_OPTIONS environment variable. If we can pollute Object.prototype.NODE_OPTIONS, and the application later spawns a new Node.js process, that process will inherit the polluted options. We could use --require to force the new process to load a malicious script from a path we control or even from a remote share if the environment allows it.


// Target payload for a vulnerable JSON endpoint
{
  "__proto__": {
    "shell": "node",
    "NODE_OPTIONS": "--inspect-brk=0.0.0.0:9229"
  }
}

This payload might open a debugging port on the server, allowing us to connect remotely and execute arbitrary code. Finding these gadgets requires deep knowledge of the application's dependencies. Tools like ScanSearch can help map out the attack surface by identifying all public-facing API endpoints and assets that might be running vulnerable Node.js versions.

Automated Discovery and Manual Testing Tools

Detecting prototype pollution exploitation manually can be tedious. You have to test various payloads like __proto__, constructor[prototype], and variants with different encodings. However, modern tooling has made this much easier for pentesters. The first place to start is the Burp Suite tutorial where we discuss using the DOM Invader extension.

DOM Invader is specifically designed to find prototype pollution. It automatically tests for pollution sinks by injecting unique strings into URL parameters and monitoring the global object. If it detects that Object.prototype has been modified, it flags the vulnerability and even helps identify potential gadgets.

For server-side detection, I use a "canary" approach. I attempt to pollute a non-destructive property, such as Object.prototype.polluted_check = "vulnerable". Then, I observe if other parts of the application start returning this value. For example, if an API response includes an object that now has a polluted_check field, I know the pollution was successful. Be careful with this in production environments, as polluting the global prototype can cause application-wide crashes (DoS).

If you are testing a large organization, it is also useful to use a real-time network scanner to find shadow IT or forgotten dev servers that might be running old, vulnerable versions of Express or Lodash. These are often the easiest targets for prototype pollution exploitation because they lack the rigorous patching cycles of main production apps.

Mitigation Strategies and Secure Coding Practices

Fixing prototype pollution requires more than just a simple patch; it requires changing how developers handle data structures in JavaScript. The most effective defense is to avoid using plain objects for storing user-controlled data. Instead, use Map objects, which do not have a prototype chain in the same way and are not susceptible to __proto__ manipulation.

If you must use objects, create them without a prototype using Object.create(null). This creates a "pure" object that does not inherit from Object.prototype, effectively breaking the pollution chain at its source.


// Secure object creation
const safeObject = Object.create(null);
merge(safeObject, userInput); // Even if userInput has __proto__, safeObject won't be affected

Other strong defensive measures include:

  1. Schema Validation: Use libraries like Joi or Ajv to strictly validate incoming JSON. These libraries can be configured to strip out forbidden keys like __proto__ or constructor.
  2. Freezing the Prototype: You can call Object.freeze(Object.prototype) at the start of your application. This prevents any further modifications to the global prototype. While effective, this can sometimes break older legacy libraries that rely on modifying prototypes.
  3. Using Built-in Methods: Modern Node.js versions have added flags and internal checks to mitigate these attacks. Ensure you are running the latest LTS version (currently 18.x or 20.x).
Key Takeaway: Defense in depth is critical. Combining schema validation with Object.freeze() provides a strong layer of protection even if a new vulnerability is discovered in a dependency.

FAQ Section

How do I detect prototype pollution manually in the browser console?

The simplest way is to try and inject a property into the prototype and then check a new object. Run let a = {}; let b = JSON.parse('{"__proto__":{"test":1}}'); Object.assign(a, b); then check if ({}).test returns 1. If it does, the environment is vulnerable to prototype pollution. You can then look for sinks in the source code where user input is processed via merge or extend functions.

Can prototype pollution lead to RCE in Node.js?

Yes, absolutely. By polluting properties that are later used in sensitive functions like child_process.spawn(), exec(), or eval(), an attacker can achieve Remote Code Execution. Common gadgets include polluting the env or shell properties which Node.js looks for when creating new system processes. This makes it one of the most critical vulnerabilities in the Node.js ecosystem.

Is prototype pollution only a JavaScript issue?

While the term "Prototype Pollution" is specific to JavaScript's prototype-based inheritance, similar concepts exist in other languages. For example, "Class Poisoning" or certain types of "Object Injection" in Python or PHP share the same goal: modifying the underlying structure of all objects to change application behavior. However, JavaScript's ubiquitous use of `__proto__` makes it uniquely susceptible to this specific attack pattern.

Summary of Defensive Layers: To stay ahead of prototype pollution exploitation, you must integrate security into the development lifecycle. This involves regular dependency audits, using secure-by-default data structures, and performing rigorous pentesting. By understanding both the mechanism of the pollution and the nature of the gadgets used for exploitation, you can build more resilient applications and more effectively identify vulnerabilities during your security assessments. Always remember that a single vulnerable merge function in a obscure dependency can compromise your entire server environment.