The art of prototype pollution gadgets in NodeJS
in this article, I will deep dive in some of prototype pollution gadgets in Node.js, sharing my personal journey and a deep dive into one gadget with insights from Node.js internals
Overview
Node.js uses the ESM loader to dynamically import modules at runtime.
The loader relies on plain objects (context
) to track module metadata such as source
, format
, and importAttributes
.
Because these objects inherit from Object.prototype
, prototype pollution can inject unexpected properties, which may lead to arbitrary code execution during a dynamic import()
.
This article analyzes the relevant internals (defaultLoad
and getSourceSync
), explains why prototype pollution affects them, and demonstrates the potential exploit.
Quick check, is this code safe?
//server.mjs
import express from 'express';
const app = express();
app.use(express.json());
function unsafeMerge(target, source) {
for (const key in source) {
if (typeof source[key] === 'object' && source[key] !== null) {
target[key] = target[key] || {};
unsafeMerge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
app.post('/try', async (req, res) => {
const userInput = req.body || {};
unsafeMerge({}, userInput);
await import('./someLIB.mjs');
res.send('ok');
});
app.listen(3000, () => console.log('Server running on http://localhost:3000'));
// someLIB.mjs
export function hello() {
return 'Hello world';
}
the answer is.. no this code is not safe and it has simple prototype pollution (basic question)
but how we can get highly impact for this code, if you see this code doesnt has a useful gadget and simple code you are wrong
if this code run by node js and express js thats means we can get gadget from node js code or express js code (its need deep search im ngl)
in this article i would take gadget from internal node js source code specifically esm module code
defaultLoad (load.js)
the source code for what i will talk about:
defaultLoad
is the core function in Node.js' ESM loader responsible for resolving and loading modules. It processes a module specifier (url
) and context (importAttributes
, format
, source
, etc.), then determines how to fetch and interpret the module.

Key behavior:
If
source
is not provided in thecontext
, it callsgetSourceSync()
to read the module from disk or data URLs.

https://github.com/nodejs/node/blob/main/lib/internal/modules/esm/load.js#L32
After retrieving the source, it invokes
defaultGetFormat()
to detect the module type (e.g.,esm
,commonjs
,builtin
).If format is detected as
commonjs
, the loader discardssource
to trigger a CJS re-fetch.It validates
importAttributes
and returns an object withformat
,responseURL
, andsource
.
Key Observation
defaultLoad
dynamically spreadscontext
into a new object with__proto__
:

https://github.com/nodejs/node/blob/main/lib/internal/modules/esm/load.js#L89
If Object.prototype.source
is polluted, this value is implicitly trusted as the module’s code.
When
format
isn’tcommonjs
, Node’s loader skips re-reading the file and may execute this injectedsource
directly.This turns a simple prototype pollution bug into a code execution primitive through the ESM loader.
Observation
The critical detail in defaultLoad
is how it constructs a new context
object with a __proto__
reference:

https://github.com/nodejs/node/blob/main/lib/internal/modules/esm/load.js#L89
This line means:
If
Object.prototype.source
is polluted, the loader now inherits that attacker-controlledsource
.The loader trusts this
source
value to contain module content.Instead of reading a file via
getSourceSync
, Node may directly return the pollutedsource
, effectively running injected JavaScript.
This creates a dangerous scenario:
A single prototype pollution (
__proto__.source = <payload>
) is enough.Any
import()
call for a non-commonjs
module may execute arbitrary attacker-supplied code.
In essence, defaultLoad
’s prototype inheritance pattern gives attackers a direct path from prototype pollution to remote code execution (RCE) in Node.js.
Exploit Concept
An attacker can abuse the unsafe deep merge logic to pollute Object.prototype
with a source
property containing malicious JavaScript code. When Node’s ESM loader (defaultLoad
) is invoked to import a module, it checks context.source
first. Since this property is inherited through prototype pollution, the loader treats the attacker-supplied string as the module source code and executes it.
Exploitation Flow:
Send a crafted request with
__proto__
keys to poisonObject.prototype
:curl -X POST http://localhost:3000/try -H "Content-Type: application/json" -d '{"__proto__": {"source": "console.log(\"PWNED\")"}}'
The server merges the payload, setting
Object.prototype.source
.import('./someLIB.mjs')
triggersdefaultLoad
, which reads the pollutedcontext.source
.Node executes attacker-controlled code instead of loading the real module.
Impact: Remote Code Execution (RCE) with full control over the Node.js process.
Conclusion
This vulnerability demonstrates how unsafe object merging can escalate into full Remote Code Execution (RCE) when combined with Node.js internals. By polluting Object.prototype
, an attacker can hijack the ESM loader’s module resolution flow and execute arbitrary code. The flaw highlights why prototype pollution is not just a data integrity issue but a critical security risk in any application or framework that dynamically loads code.
Reference
Many popular libraries ship with known server-side prototype pollution gadgets, see
Last updated