Introduction
Prototype pollution is a critical security vulnerability unique to JavaScript that can lead to denial of service, remote code execution, and privilege escalation in Node.js applications. This vulnerability exploits JavaScript’s prototype-based inheritance system, allowing attackers to modify the prototype of base objects and affect the behavior of all objects in the application.
Unlike traditional vulnerabilities that target specific application logic, prototype pollution attacks the fundamental mechanics of the JavaScript language itself, making it particularly dangerous and often overlooked by developers.
This article provides a comprehensive exploration of prototype pollution attacks, their mechanisms, real-world exploitation scenarios, and effective mitigation strategies for Node.js applications.
Understanding JavaScript Prototypes
Before diving into prototype pollution, it’s essential to understand JavaScript’s prototype system:
// Every object in JavaScript has a prototype
const obj = {};
console.log(obj.__proto__); // Points to Object.prototype
// Prototype chain inheritance
function User(name) {
this.name = name;
}
User.prototype.greet = function() {
return `Hello, ${this.name}`;
};
const user = new User("Alice");
console.log(user.greet()); // "Hello, Alice"
// Object.prototype is at the top of the prototype chain
console.log(user.__proto__.__proto__ === Object.prototype); // true
The prototype chain is traversed when accessing properties, making it possible to modify behavior globally by polluting base prototypes.
The Prototype Pollution Vulnerability
Basic Mechanism
Prototype pollution occurs when an attacker can modify Object.prototype
or other base prototypes, affecting all objects in the application:
// Vulnerable code - unsafe object merging
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;
}
// Attack payload
const maliciousPayload = {
"__proto__": {
"isAdmin": true,
"polluted": "This affects all objects!"
}
};
const userConfig = {};
merge(userConfig, maliciousPayload);
// Now ALL objects are polluted
const newUser = {};
console.log(newUser.isAdmin); // true
console.log(newUser.polluted); // "This affects all objects!"
Alternative Pollution Vectors
// Via constructor.prototype
const payload1 = {
"constructor": {
"prototype": {
"isAdmin": true
}
}
};
// Via __proto__ (when available)
const payload2 = {
"__proto__": {
"isAdmin": true
}
};
// Via prototype pollution in arrays
const payload3 = {
"constructor": {
"prototype": {
"0": "polluted",
"length": 1
}
}
};
Real-World Exploitation Scenarios
1. Authentication Bypass
// Vulnerable authentication system
class AuthService {
constructor() {
this.users = new Map();
}
login(username, password) {
const user = this.users.get(username);
if (!user) return false;
return user.password === password;
}
hasPermission(user, action) {
// Vulnerable: Checking for admin property
return user.isAdmin || false;
}
}
// Attack: Pollute Object.prototype
const pollutionPayload = {
"__proto__": {
"isAdmin": true
}
};
// Any vulnerable merge operation
vulnerableMerge({}, pollutionPayload);
// Now all users appear to be admin
const authService = new AuthService();
const regularUser = { username: "john", password: "secret" };
console.log(authService.hasPermission(regularUser, "delete")); // true (bypassed!)
2. Configuration Override
// Vulnerable configuration system
class ConfigManager {
constructor() {
this.config = {};
}
loadConfig(userConfig) {
this.deepMerge(this.config, userConfig);
}
get(key) {
return this.config[key];
}
// Vulnerable merge function
deepMerge(target, source) {
for (let key in source) {
if (typeof source[key] === 'object' && source[key] !== null) {
if (!target[key]) target[key] = {};
this.deepMerge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
}
}
// Attack: Override security settings
const maliciousConfig = {
"__proto__": {
"allowUnsafeOperations": true,
"debugMode": true,
"skipValidation": true
}
};
const configManager = new ConfigManager();
configManager.loadConfig(maliciousConfig);
// All objects now have malicious properties
const securityCheck = {};
console.log(securityCheck.allowUnsafeOperations); // true
3. Denial of Service (DoS)
// DoS via prototype pollution
const dosPayload = {
"__proto__": {
"toString": function() {
// Infinite loop or expensive operation
while(true) {}
}
}
};
// After pollution, any string conversion causes DoS
vulnerableMerge({}, dosPayload);
// This will hang the application
const obj = {};
console.log(obj + ""); // Triggers toString()
4. Remote Code Execution
// RCE via command injection in child_process
const { spawn } = require('child_process');
// Vulnerable function using polluted properties
function executeCommand(options = {}) {
const command = options.command || 'ls';
const args = options.args || [];
// Vulnerable: Using potentially polluted properties
const child = spawn(command, args, {
shell: options.shell || false,
env: options.env || process.env
});
return child;
}
// Attack payload
const rcePayload = {
"__proto__": {
"shell": true,
"command": "rm -rf /",
"args": []
}
};
// After pollution
vulnerableMerge({}, rcePayload);
// This becomes dangerous
executeCommand(); // Could execute malicious commands
Common Vulnerable Patterns
1. Object Merging Libraries
// Vulnerable implementations in popular libraries
function vulnerableAssign(target, ...sources) {
sources.forEach(source => {
for (let key in source) {
if (key === '__proto__') continue; // Insufficient protection
if (typeof source[key] === 'object' && source[key] !== null) {
target[key] = target[key] || {};
vulnerableAssign(target[key], source[key]);
} else {
target[key] = source[key];
}
}
});
return target;
}
// Still vulnerable via constructor.prototype
const payload = {
"constructor": {
"prototype": {
"polluted": true
}
}
};
vulnerableAssign({}, payload);
console.log(({}).polluted); // true
2. JSON Parsing with Reviver
// Dangerous JSON parsing
function parseUserInput(jsonString) {
return JSON.parse(jsonString, (key, value) => {
// Vulnerable reviver function
if (key === '__proto__') {
return value; // Allows prototype pollution
}
return value;
});
}
// Attack via JSON
const maliciousJSON = '{"__proto__": {"isAdmin": true}}';
parseUserInput(maliciousJSON);
console.log(({}).isAdmin); // true
3. Query Parameter Parsing
// Vulnerable query string parsing
function parseQuery(queryString) {
const params = {};
const pairs = queryString.split('&');
pairs.forEach(pair => {
const [key, value] = pair.split('=');
const decodedKey = decodeURIComponent(key);
const decodedValue = decodeURIComponent(value);
// Vulnerable: Direct assignment without validation
setNestedProperty(params, decodedKey, decodedValue);
});
return params;
}
function setNestedProperty(obj, path, value) {
const keys = path.split('.');
let current = obj;
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i];
if (!current[key]) current[key] = {};
current = current[key];
}
current[keys[keys.length - 1]] = value;
}
// Attack via URL parameters
// URL: /?__proto__.isAdmin=true
const queryParams = parseQuery('__proto__.isAdmin=true');
console.log(({}).isAdmin); // true
Detection and Prevention
1. Secure Object Merging
// Secure merge implementation
function secureDeepMerge(target, source) {
// Blacklist dangerous keys
const dangerousKeys = ['__proto__', 'constructor', 'prototype'];
for (let key in source) {
if (dangerousKeys.includes(key)) {
continue; // Skip dangerous keys
}
if (typeof source[key] === 'object' && source[key] !== null) {
if (!target[key]) target[key] = {};
secureDeepMerge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
// Alternative: Using Object.create(null)
function createSafeObject() {
return Object.create(null); // No prototype chain
}
// Alternative: Using Map instead of objects
function secureConfigWithMap() {
return new Map();
}
2. Input Validation and Sanitization
// Comprehensive input validation
function validateAndSanitize(obj, maxDepth = 10) {
const dangerousKeys = ['__proto__', 'constructor', 'prototype'];
function sanitizeRecursive(current, depth = 0) {
if (depth > maxDepth) {
throw new Error('Object too deep');
}
if (typeof current !== 'object' || current === null) {
return current;
}
if (Array.isArray(current)) {
return current.map(item => sanitizeRecursive(item, depth + 1));
}
const sanitized = {};
for (let key in current) {
if (dangerousKeys.includes(key)) {
continue; // Remove dangerous keys
}
if (current.hasOwnProperty(key)) {
sanitized[key] = sanitizeRecursive(current[key], depth + 1);
}
}
return sanitized;
}
return sanitizeRecursive(obj);
}
3. Using Safe Alternatives
// Use Object.create(null) for dictionaries
function createSafeDictionary() {
return Object.create(null);
}
// Use Map for key-value storage
function createSafeMap() {
return new Map();
}
// Use Set for unique values
function createSafeSet() {
return new Set();
}
// Secure property assignment
function secureAssign(target, key, value) {
if (typeof key !== 'string' || key.includes('__proto__') || key.includes('constructor')) {
throw new Error('Invalid property key');
}
Object.defineProperty(target, key, {
value: value,
writable: true,
enumerable: true,
configurable: true
});
}
4. Object Freezing and Sealing
// Prevent prototype modification
Object.freeze(Object.prototype);
Object.freeze(Array.prototype);
Object.freeze(Function.prototype);
// Alternative: Seal prototypes
Object.seal(Object.prototype);
// Create immutable objects
function createImmutableConfig(config) {
return Object.freeze(Object.assign(Object.create(null), config));
}
Static Analysis and Runtime Detection
1. ESLint Rules
// .eslintrc.js
module.exports = {
rules: {
'security/detect-object-injection': 'error',
'no-prototype-builtins': 'error',
'no-extend-native': 'error'
},
plugins: ['security']
};
2. Runtime Monitoring
// Monitor prototype pollution at runtime
function setupPrototypeMonitoring() {
const originalObjectKeys = Object.keys(Object.prototype);
setInterval(() => {
const currentKeys = Object.keys(Object.prototype);
const newKeys = currentKeys.filter(key => !originalObjectKeys.includes(key));
if (newKeys.length > 0) {
console.warn('Prototype pollution detected:', newKeys);
// Alert security team
alertSecurityTeam('Prototype pollution detected', newKeys);
}
}, 1000);
}
// Detect pollution in specific objects
function detectPollution(obj) {
const pollutionIndicators = ['isAdmin', 'isLoggedIn', 'hasPermission'];
for (let indicator of pollutionIndicators) {
if (obj[indicator] !== undefined && !obj.hasOwnProperty(indicator)) {
return true; // Likely polluted
}
}
return false;
}
3. Testing for Prototype Pollution
// Test suite for prototype pollution
describe('Prototype Pollution Tests', () => {
beforeEach(() => {
// Clean up prototypes before each test
cleanupPrototypes();
});
it('should not allow prototype pollution via merge', () => {
const target = {};
const maliciousSource = {
"__proto__": {
"isAdmin": true
}
};
safeMerge(target, maliciousSource);
const testObj = {};
expect(testObj.isAdmin).toBeUndefined();
});
it('should detect existing pollution', () => {
// Intentionally pollute for testing
Object.prototype.testPollution = true;
const detector = new PollutionDetector();
expect(detector.isPolluted()).toBe(true);
// Cleanup
delete Object.prototype.testPollution;
});
});
function cleanupPrototypes() {
const prototypes = [Object.prototype, Array.prototype, Function.prototype];
prototypes.forEach(proto => {
Object.getOwnPropertyNames(proto).forEach(prop => {
if (!proto.constructor.prototype.hasOwnProperty(prop)) {
delete proto[prop];
}
});
});
}
Library-Specific Mitigations
1. Express.js Applications
const express = require('express');
const app = express();
// Secure middleware for prototype pollution prevention
function prototypePollutionMiddleware(req, res, next) {
// Sanitize query parameters
req.query = sanitizeObject(req.query);
// Sanitize request body
if (req.body) {
req.body = sanitizeObject(req.body);
}
next();
}
app.use(express.json());
app.use(prototypePollutionMiddleware);
function sanitizeObject(obj) {
const dangerousKeys = ['__proto__', 'constructor', 'prototype'];
if (typeof obj !== 'object' || obj === null) {
return obj;
}
const sanitized = {};
for (let key in obj) {
if (dangerousKeys.includes(key)) {
continue;
}
if (obj.hasOwnProperty(key)) {
sanitized[key] = typeof obj[key] === 'object'
? sanitizeObject(obj[key])
: obj[key];
}
}
return sanitized;
}
2. Lodash and Utility Libraries
// Secure alternatives to vulnerable lodash functions
const _ = require('lodash');
// Instead of _.merge (vulnerable)
function secureMerge(target, source) {
return _.mergeWith(target, source, (objValue, srcValue, key) => {
if (['__proto__', 'constructor', 'prototype'].includes(key)) {
return objValue; // Keep original value
}
return undefined; // Use default merge behavior
});
}
// Instead of _.set (vulnerable)
function secureSet(obj, path, value) {
const pathArray = _.toPath(path);
const dangerousKeys = ['__proto__', 'constructor', 'prototype'];
if (pathArray.some(key => dangerousKeys.includes(key))) {
throw new Error('Dangerous path detected');
}
return _.set(obj, path, value);
}
Best Practices and Recommendations
1. Defensive Programming
// Always validate object properties
function hasOwnProperty(obj, prop) {
return Object.prototype.hasOwnProperty.call(obj, prop);
}
// Use explicit property checking
function safePropertyAccess(obj, prop) {
if (hasOwnProperty(obj, prop)) {
return obj[prop];
}
return undefined;
}
// Create objects without prototypes for dictionaries
function createSafeDictionary(data = {}) {
const safeDict = Object.create(null);
for (let key in data) {
if (hasOwnProperty(data, key)) {
safeDict[key] = data[key];
}
}
return safeDict;
}
2. Security Headers and CSP
// Add security headers to prevent client-side pollution
app.use((req, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('Content-Security-Policy', "default-src 'self'");
next();
});
3. Regular Security Audits
# Use security-focused npm audit
npm audit --audit-level moderate
# Use specialized tools
npx audit-ci --moderate
# Check for known vulnerable patterns
npx eslint --ext .js --config .eslintrc.security.js .
Conclusion
Prototype pollution represents a unique and dangerous class of vulnerabilities in JavaScript applications. Unlike traditional injection attacks, prototype pollution exploits the fundamental mechanics of the JavaScript language itself, making it particularly insidious and potentially impactful.
The key to preventing prototype pollution lies in:
- Understanding the attack mechanism and how JavaScript prototypes work
- Implementing secure coding practices for object manipulation
- Using safe alternatives like
Object.create(null)
andMap
objects - Validating and sanitizing input at all application boundaries
- Regular security testing with both static and dynamic analysis tools
- Monitoring runtime behavior for signs of prototype pollution
By following these practices and maintaining awareness of this vulnerability class, developers can build more secure Node.js applications that resist prototype pollution attacks.
Remember: in JavaScript, every object inherits from Object.prototype
. Pollute the prototype, and you pollute the entire application.
References
- Prototype Pollution Attack in NodeJS
- OWASP Prototype Pollution
- Snyk’s Prototype Pollution Research
- JavaScript Prototype Pollution Attack
In JavaScript, everything is an object, and every object has a prototype. Guard your prototypes well.
Leave a Reply