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 the context, it calls getSourceSync() 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 discards source to trigger a CJS re-fetch.

  • It validates importAttributes and returns an object with format, responseURL, and source.

Key Observation

  • defaultLoad dynamically spreads context 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’t commonjs, Node’s loader skips re-reading the file and may execute this injected source 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-controlled source.

  • The loader trusts this source value to contain module content.

  • Instead of reading a file via getSourceSync, Node may directly return the polluted source, effectively running injected JavaScript.

This creates a dangerous scenario:

  1. A single prototype pollution (__proto__.source = <payload>) is enough.

  2. 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:

  1. Send a crafted request with __proto__ keys to poison Object.prototype:

  2. curl -X POST http://localhost:3000/try -H "Content-Type: application/json" -d '{"__proto__": {"source": "console.log(\"PWNED\")"}}'
  3. The server merges the payload, setting Object.prototype.source.

  4. import('./someLIB.mjs') triggers defaultLoad, which reads the polluted context.source.

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