Google CTF 2018: /bin/cat chat
Google CTF has come to a close, and with a very narrow victory on our part. As we decompress and mull over a set of excellent problems, we will post a few writeups of the ones we solved. Here is a writeup for the web problem “Cat Chat.”
Cataloguing the Challenge
Who doesn’t love chatting about dogs! Apparently, the folks at Google CTF. In fact, they dislike it so much that they made a chat application where you can talk about literally anything else. They called it Cat Chat and it has a bit more depth than we might think.
Our first introduction to the problem is pretty simple. We’re offered a full-window chat interface that introduces itself as Cat Chat
. In addition, it lays out a few basic “rules” for using it. They are:
- We can invite other members by sharing the URL
- We cannot talk about dogs
- We can change our name using the
/name
command - We can report others for talking about dogs
Finally, and most critically, the introduction also provides a link to the server code. This, plus the client’s catchat.js
will give us all that we need to break free of this dog-lovers’ dystopia.
Categorisations
Before we start looking for bugs and exploits, let’s break down what both the server and the client do. At 77 lines, the client code is incredibly easy to read through. Essentially, it opens a long running Server-Side Events (SSE) connection and uses that to get information about the chat. When a message is sent, it gets pushed along the event pipe (unless the message is a report, in which case a captcha is first invoked). In addition, the pipe can spit out a number of events that update the client in different ways. These are:
undefined
: Does nothingerror
: Logs an error and complains in the chatname
: Informs the chat of another member’s name changerename
: Updates your name in thelocalStorage
.secret
: Overwrites your current secert, then displays the new one hidden with CSS.msg
: Under normal circumstances, displays to the chat a message that has been received. However this will also autosend “Hi” when you first connect.ban
: The most amusing event. When a client seesban
it compares its name inlocalStorage
against the name of the person who was banned. If they are the same, then it gives itself a “banned” cookie and disconnects from the chat.
In addition to the events, there is also a function called cleanupRoomFullOfBadPeople
. This has no entrypoint, so it can reasonably be considered an admin-only function. Indeed, its primary purpose is to periodically check whether anyone has used the word “dog”, banning them if they have by fetching their username out of the post’s HTML. Like the rest of the client code, conveniently, it is nice and simple. Now let us look at the server.
Surprisingly enough, the server is not substantially more complicated than the client. A large amount of its code is dedicated to managing the multiple connections and setting up the various SSE pipes. Although we spent a while looking at these parts of the code, there was nothing very interesting about them so we will ignore them from now on. The interesting pieces are in the CSP — which gets set for every room — and the message processing logic.
The CSP is suitably locked down, with the following permissions
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/
Nothing exceptionally interesting, although the locked-down script-src
, in combination with no ability to upload files, suggests that our eventual exploit will likely not be JS based. Furthermore, the freedom of unsafe-inline
for style-src
implies that we will be using some variant of CSS injection.
Looking now at the message handling logic, we see that it first checks that the request is originating from the correct site. Otherwise, it will return a CSRF error. Then, if no command is being executed, it will send a broadcast message from the user to all other members of the chat. Finally, if the message starts with a slash followed by text, then we case on the text. The cases we support are:
/name
: In which case we send that person arename
response, and broadcast aname
event to everyone else./ban
: In which case we first check for an admin, and then send aban
event to all members of the chat./secret
: In which case we set the user’ssecret
cookie and then send them asecret
response./report
: In which case we forward the information to a hiddenadmin.report
command.
While each of these is interesting, nothing is obviously wrong with the server.
Catching Bugs
Now that we know approximately how the problem works, let’s make a few observations. First, the admin is just a normal user that happens to have a special cookie set. Secondly, we almost certainly need to find a way to leak the admin’s flag using CSS. Finally, we can probably ban the admin.
This last observation is not strictly relevant to the problem, but how could we resist the opportunity to ban them? Fortunately, it should be easy enough. Since all users see the /ban
response, if we set our own name to admin
they will ban us with themselves alongside. Let’s try!
Wait, what went wrong? Not only was the admin not banned, but neither were we! Well, remember how we described the ban check earlier? It looks at the html for the post and fetches the user’s name out of it. Then, it bans that user. Unfortunately, when two users have the same name all posts from either user are labeled as <shared_name> (you)
. Thus, when the admin went to ban me for talking about dogs, they instead tried to ban the user admin (you)
.
So now we’re stuck. Forget about the CTF, we have to ban the admin! It’s an affront to our very being that they remain unbannable. We should look more at the code.
Interestingly enough, when the server is parsing a command, it initially fetches the name of the command by using the regular expression /^\/[^ ]*/
. This regex will match a slash followed by any number of characters that are not space. However, once it parses the command, it finds the argument for it by using a different regular expression (as in the case of ban
): /\/ban (.+)/
. This RegExp will only match a command followed by a non-zero number of characters, but it can be anywhere in the string.
This discrepancy is notable because the admin is banning us using the simple templated string: /ban ${name}
. Since .
does not match newlines, this means that if we start our name with a newline, we can embed another /ban
command later in the expression. Our mean admin thought they could avoid us, but now we have a new trick. If we set our name to be "\n/ban admin"
, when they tries to ban us, they’ll ban themselves instead!
No! We thought we had them. What happened this time? It turns out that when the admin queries our username, they do so with the function element.innerText
. Notice how our username rendered itself all on one line? This means that when the admin tries to get our name, the newline will be stripped and they will see our name as /ban admin
. There is still hope, however. Since innerText
returns the rendered form of the underlying HTML, perhaps if we can get the HTML to render the newline, then the ban will go through.
There may be multiple ways of going about this, but the first one that I can think of is to set the element to have the CSS property white-space: pre
. Effectively, what this does is force the HTML to render all of the white space in the text. We can try this out locally and see that doing so indeed causes innerText
to return our username with the newline included. Now all we need is a way to render arbitrary CSS on the admin’s page.
Looking at the client code again, we find that after a ban, the client renders that user’s name in red. It does so by making a CSS query using the attribute selector with our escaped name embedded
`<style>span[data-name^=${esc(data.name)}] { color: red; }</style>`
Fortunately for us, that esc
function is incredibly weak. The only characters it replaces are < > "
and '
. As it turns out, none of those are needed to write valid CSS!
Finally, we have a real path to victory. If we set our name to be \n]{}\n #conversation span:first-child { white-space: pre }\n/ban admin
, then the first time we get “banned”, the admin will inject the following CSS into their active styles (reformatted):
span[data-name^=
] { }
#conversation span:first-child {
white-space: pre
}
/ban admin
Despite the terrible syntax errors, CSS is incredibly forgiving and will still render all relevant span
s as pre
(note that we could use a simpler selector here, but then the result would be impossible to read). Now, if we get “banned” again, the admin will ban our username with the newlines embedded and end up banning themselves instead. Let’s go get them!
Bask in that glorious ban! It feels so wonderful for the admin to finally get a taste of their own medicine. I hope you enjoyed this writeup, and I’d like to thank my team, my friends, and all of the…
Wait, what do you mean we still need to get the flag? Oh, huh. I suppose we do. Fortunately, our boondoggle has led us most of the way there.
Cater to the Masses
In order to get the flag, all we need to do is convince the admin to place their secret in the DOM, and then we can use our CSS injection to exfiltrate it.
Diving in, the first thing we need to do is get the admin to dump their secret onto the page. Realistically, a readthrough of the server code indicates that the only way to do this is to have them run the secret
command. However, the only command we can force them to run is the /ban
command. Fortunately for us, their server makes a classical blunder. Let’s look at the command logic:
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}/`);
}
Look at that! They use a switch statement with no breaks after the cases. This means, that we can use the same technique from the previous exercise to have them ban someone and then fall through and call /secret
.
While we can be reasonably assured that this works, we’re left with an issue. As soon as we call secret, we overwrite their flag cookie with one of our control. This seems problematic at first, but a quick glance at the Set-Cookie
grammar of RFC6265 reminds us that cookies can be defined for arbitrary domains. Thus, we can set our flag cookie to be bogus; Domain=google.com
and so we won’t overwrite the admin’s cookie. Instead, they will put their original cookie in the dom.
Finally, all that we need to do is exfiltrate it. There was something odd in the server code that I neglected to mention earlier, namely that the endpoint used for sending messages is method agnostic. You can POST
to it, or GET
it, or maybe even DELETE
it. Regardless, the ability to send messages via a GET
request gives us everything else that we need.
For those not versed in classic CSS exfil, the traditional technique is to use a background image url that only triggers on certain queries. By checking whether the query landed, we can get a single bit of data from the target. Exempla gratia, we can embed the following CSS query into our payload to know whether the admin’s flag has an 'A'
in it.
[data-secret*=A] {
background: url(https://cat-chat.web.ctfcompetition.com/room/<OUR_UID>/send?name=exfil&msg=A)
}
This uses the attribute selector in CSS to test whether the current page has any element whose attribute data-secret
contains an A
. If it does, it makes a request that will print the message A
from the user exfil
.
To test our whole exploit thus far, we can check to see if the admin’s flag contains the letter C
. Since we expect it to start with CTF{ }
, this should succeed. In order for this to work, we will set our name to
fake_banned_user
/secret hi_google; Domain=google.com
end_injection_tag] { }
[data-secret*=C] {
background: url(https://cat-chat.web.ctfcompetition.com/room/fb587ec9-6bff-4c4a-913b-852cfdb8effb/send?name=exfil&msg=C)
}
[begin_injection_tag
Trying this out, we get
Just as we expected, we get 'C'
back from our exfiltrator.
Catastrophe
Now that we have a proof of concept working, this is the part where I would show you my exploit script for you to look at and admire. Unfortunately, I cannot as I don’t have one. Instead what I have is a short name generator that we used while doing all of the exfiltration by hand.
There are a number of reasons why we ended up doing this, but the biggest one is that since we can’t embed quotes into our CSS, we are severely limited by the characters we can use. Furthermore, even if it matched multiple rules, you may only have a single background image and so it would only exfiltrate one character per attempt. As such, our approach was to write a small shim that we could update as we went and then leak one character at a time going either forward or back from what we had. This was further complicated by the fact that we were not able to use {
and we could not start any of our guesses with numerals. As such, this took about 15 queries before we were able to piece together the whole flag.
For reference, here is the shim
prefix = "L0LC47S_43V3"
suffix = ""
alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
numerals = "0123456789"
strings = ""
strings += alphabet + numerals
chrs = (strings)
.split("")
.map((letter) =>
` [data-secret*=${prefix}${letter}${suffix}]{ background: url(https://cat-chat.web.ctfcompetition.com/room/fb587ec9-6bff-4c4a-913b-852cfdb8effb/send?name=exfil&msg=${prefix}${letter}${suffix}) } `).join("\n")
localStorage.name = ` fake_banned_user
/secret hi_google; Domain=google.com
end_injection_tag] { }
${chrs}
[begin_injection_tag`
Trying it out on the final letter of the flag, we can see how it works:
Finally, we have our flag and no longer have to listen to the admin’s hateful messages. Now go forth and always remember our good dog
when you pwn.