Solution For The Google CTF 2018 Hacking Contest

news vulnerability

Google CTF is a hacking competition in the style of Capture-the-Flag, which has been going on for many years.Google CTF is a hacking competition in the style of Capture-the-Flag, which has been going on for many years.Here are a few inputs on how to master it.

This is a summary of an article originally written by Pichaya Morimoto (Security Consultant, SEC Consult Thailand) to give you an insight to this year’s Google Capture the Flag contest taking place in June 2018.

What is Google CTF?

Many of the questions have to do with Google’s technologies such as Android, Firebase, Angular, GCP and others. There are 5 categories of questions to solve: Crypto, Misc, Pwn, Re and Web. This article will focus about Web, which is a lot of fun.

 

The Problem: Cat Chat

Here’s what we need to solve: “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.

It is a chat web application and we can to go to a chatroom and get a random room ID, then send the URL to another person so they can join on a different computer. The rule is, typing the word “dog” is forbidden. If the administrator gets aware of it, we’ll get be banned. We are able to type /report to get the administrator to come into the chatroom for 10 seconds (obviously, he is a bot and not an actual human, probably a Selenium bot).

Cat chat

There are two commands in this room and those are /name <new name> and /report which will tell the administrator to come in and look at people talking about dogs. Remember: This room only allows chats regarding cats! Other than that the problem will give you the following server side code.

const http = require('http'); const express = require('express'); const cookieParser = require('cookie-parser') const uuidv4 = require('uuid/v4'); const SSEClient = require('sse').Client; const admin = require('./admin'); const pubsub = require('@google-cloud/pubsub')(); const app = express(); app.set('etag', false); app.use(cookieParser()); // Check if user is admin based on the 'flag' cookie, and set the 'admin' flag on the request object app.use(admin.middleware); // Check if banned app.use(function(req, res, next) { if (req.cookies.banned) { res.sendStatus(403); res.end(); } else { next(); } }); // 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\' www.google.com/recaptcha/www.gstatic.com/recaptcha/', 'frame-src \'self\' www.google.com/recaptcha/', ].join('; ') }, }); }); // 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(); }); // Process room broadcast messages const rooms = new Map(); app.get(roomPath + '/receive', function(req, res) { res.setHeader('X-Accel-Buffering', 'no'); let channel = new SSEClient(req, res); channel.initialize(); let roomName = req.params.room; let room = rooms.get(roomName) || new Set(); rooms.set(roomName, room.add(channel)) req.once('close', () => { room.size > 1 ? room.delete(channel) : rooms.delete(roomName) }); }); // Broadcast to all instances using Cloud Pub/Sub. For local testing, it's easy // to skip by commenting it out and patching the broadcast fn below. var publisher; pubsub.createTopic('catchat', function() { var topic = pubsub.topic('catchat'); publisher = topic.publisher(); topic.createSubscription('catchat-' + uuidv4(), {ackDeadlineSeconds: 10}).then(function(data) { data[0].on('message', function(msg) { msg.ack(); var room = msg.attributes.room; if (!rooms.has(room)) return; var msg = msg.data.toString('utf-8'); console.log(`${room} ^^^`, msg) for (let channel of rooms.get(room)) channel.send(msg); }); }); }); function broadcast(room, msg) { // for (let channel of (rooms.get(room) || [])) channel.send(JSON.stringify(msg)); // Local broadcast only publisher.publish(Buffer.from(JSON.stringify(msg)), {room: room}); // Pub/Sub broadcast } // Static files app.get('/server.js', (req, res) => res.sendFile(__filename)); app.use(express.static(__dirname + '/static/', {fallthrough: false})); app.listen(8080);

 

The client-side code in HTML/JS looks like this:

// Set name let color = ['brown', 'black', 'yellow', 'white', 'grey', 'red'][Math.floor(Math.random()*6)]; let breed = ['ragamuffin', 'persian', 'siamese', 'siberian', 'birman', 'bombay', 'ragdoll'][Math.floor(Math.random()*7)]; if (!localStorage.name) localStorage.name = color + '_' + breed; // Utility functions let cookie = (name) => (document.cookie.match(new RegExp(`(?:^|; )${name}=(.*?)(?:$|;)`)) || [])[1]; let esc = (str) => str.replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&apos;'); // 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'); }); // 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); } let sse = new EventSource("receive"); sse.onmessage = (msg) => handle(JSON.parse(msg.data)); // Say goodbye window.addEventListener('unload', () => navigator.sendBeacon(`send?name=${encodeURIComponent(localStorage.name)}&msg=Bye`)); // Admin helper function. Invoke this to automate banning people in a misbehaving room. // Note: the admin will already have their secret set in the cookie (it's a cookie with long expiration), // so no need to deal with /secret and such when joining a room. function cleanupRoomFullOfBadPeople() { send(`I've been notified that someone has brought up a forbidden topic. I will ruthlessly ban anyone who mentions d*gs going forward. Please just stop and start talking about cats for d*g's sake.`); last = conversation.lastElementChild; setInterval(function() { var p; while (p = last.nextElementSibling) { last = p; if (p.tagName != 'P' || p.children.length < 2) continue; var name = p.children[0].innerText; var msg = p.children[1].innerText; if (msg.match(/dog/i)) { send(`/ban ${name}`); send(`As I said, d*g talk will not be tolerated.`); } } }, 1000); }

 

<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>Cat Chat</title> <script src="/catchat.js"></script> <script src="https://www.google.com/recaptcha/api.js?render=6LeB410UAAAAAGkmQanWeqOdR6TACZTVypEEXHcu"></script> <link rel="stylesheet" type="text/css" href="/style.css"> </head> <body> <div id="panel"> <div id="conversation"> <p>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.</p> <p>Rules:</p> <p>- You may invite anyone to this chat room. Just share the URL.</p> <p>- 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.</p> <p>Commands you can use: (just type a message starting with slash to invoke commands)</p> <p>- `/name YourNewName` - Change your nick name to YourNewName.</p> <p>- `/report` - Report dog talk to the admin.</p> <!-- 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). --> <p>Btw, the core of the chat engine is open source! You can download the source code <a href="/server.js">here</a>.</p> <p style="margin-bottom: 5em">Alright, have fun!</p> </div> <input id="messagebox" autofocus> </div> </body> </html>

 

Screenshot of catchat.js - SEC Consult

If you are an advanced hacker, one look will tell you that it is rather simple.  It is just a loophole on the client side. By analyzing the file catchat.js you will see that the “<” in the DOM is sanitized using the esc function to prevent cross-site scripting loopholes

Screenshot of Security Considerations when inserting HTML to a page by using insertAdjacentHTML.
Code snippet showing use of insertAdjacentHTML - SEC Consult

You will find a display() function which is there to display values in chat by using the function insertAdjacentHTML().  It is a JavaScript function for extending HTML like InnerHTML(). It is one of the root cause of DOM-based XSS problems if it accepts user input in the form of HTML tags, so there is a chance of it being a loophole here.

Code snippet showing esc() function during output by display()

Now we will have to take a look where the esc() function was used during output by display() and start from there.

Code snippet highlighting a line with ${esc(data.name)=}] - SEC Consult

You can see that the only place that hasen’t been sanitized is at ${you}. However, a hacker can’t control that value there which means that the code was written securely and sanitizing the value from user output prevents DOM-based XSS. Is it over?

Not really.  If you have good eyes and take a deeper look you will find a line that is more interesting than the rest:

Code snippet showing </style><script>alert("1")</script>

Give it about 20 seconds, think about how you can hack it.

..

..

..

..

Ready?

Here the data.name value was pulled from local storage in the browser.  If you read closely you will know that it was given at random when you enter the chat room, and we can change that on our own through /name, which means it is a value that a hacker can control, indeed. However, this value is sanitized so we can’t use  “<” or “>”, if we type something such as:

Code snippet shows result using the escape function on </style><script>alert("1")</script>

The value is converted by esc() and it will turn into:

Code snippet shows use of selector #name in the attack

But what is interesting in this context, is that even if we can’t enter JavaScript commands directly, we are still able to enter CSS commands.

The question is how. How can a hacker put CSS into a hack?

There is a technique in CSS that can be used to steal information on DOM in the form of a side-channel attack. Lets say the password on the webpage is 1234 and if we attack it by using XSS we would be able to see the password and grab it. But CSS isn’t able to read the value and pull it directly. It may pull the information by using other factors that we got from other indirect ways. This is called side-channel attack (for example using a high frequency microphone to listen to the sound of the CPU to steal the password instead of directly take the password out of memory).

In CSS, we can use a selector to jump to the HTML tag with various attributes. Plus, if the selector we use points to any tag, we can do something with it.

For example:

Screenshot of CSS code in the victim's browser - SEC Consult

If a selector finds a tag with an id name, then the CSS code that we specify in { } will work. In this case, change the background image to catbg.jpg from localhost:1234.

Oh, wait! How is changing a background picture to a cat dangerous?

It is dangerous if the hacker can control the CSS that is put into the victim’s web browser. If the CSS looks like this:

Code snippet showing relevant part of the code that needs analyzing - SEC Consult

For example: you want to use the selector to find the input tag with the attribute name passwd, then check that the values are starting with a, b, c, d …. The value of the selector is the value you want to steal. A simple example is the credit card number input field on online shopping websites.

Once we figure out the beginning letter of the value, go ahead and put in the background picture that we pulled from the URL so that it will send the value that was found. In this instance the password is s3cr3t so the first letter is the letter s.

The CSS selector that checked whether or not the first letter is a, b, c.. will not show a match until it gets down to the letter s. But when it does, it will pull the URL containing the letter s. In the picture above, it will set the background picture from the URL path  /s of the hacker’s web server. As a result, a hacker will know that the first letter in the victim’s passwd field is s. By utilizing this concept, a hacker can write a script that will pull the sensitive data on the victim’s web browser and will be able to obtain the credit card number or password piece by piece. In our case, using we will match the next letters, sa,sb,sc,… and so on. In the end you will be able to pull all the digits from the form within just a few seconds.

If we understand the technique of stealing information using CSS then we can go back to analyzing the code once again.

Code snippet shows example usage of data.name - SEC Consult

We can inject the CSS code through the name with the /name command. The CSS name will not use “<” or “>” at all. For example, data.name is like this:

Code snippet shows resulting code after DOM evaluation - SEC Consult

Once the DOM evaluated it, you will get the value

Code snippet shows simplified CSS - SEC Consult

A simplified CSS looks like this:

Code snippet shows server.js code - SEC Consult

On to the next problem. Look carefully at the server.js code.  The values are not just protected with esc()but there is also hardening with a Content-Security-Policy (CSP).

Screenshot shows JavaScript error when trying to connect to a source that is not the origin - SEC Consult

In this case, the problem is that we are not able to use the CSS technique to steal informations with a side-channel attack and send it directly to the hacker’s server. The URL in the background picture uses a CSP directive value which is:
default-src 'self'

This means that the value of the src attribute, including the URL in the CSS, must be from the same origin (self). ).  So, sending it directly to the hacker’s server is impossible.

Code snippet shows protection agains CSRF by checking whether the referer came from the same host - SEC Consult

Even if we can’t steal from the website directly via an attacker’s website, we are still able to do it in another way. It is merely a side-channel attack that uses another side-channel attack on top of it. The chat application has part of a Web API that is used when sending and receiving messages, those are /send and /receive and will allow a simple CSRF hack

Simply put, CSRF will make the targeted site user do something. In this scenario, we are going to design the attack in the following way:

We use a CSS selector to point the bait to the value we want. They will be checked individually, if it is a letter such as “a”, we will do CSRF to send the letter back to us into our chatbox!

Actually, the term CSRF isn’t completely correct in our case. CSRF is shortened from Cross-Site Request Forgery. The targeted request would be sent to the hacker’s website upon entering. So, the word cross-site means different website but in our case it is within the same website. We would need to call it Same-site request forgery or maybe just think about it being like XSS.

The server-side code itself has a protection against CSRF by checking whether the referer came from the same host or not. If the check is done correctly it is a good way of protecting against cross-site requests (in this case the check was done incorrectly, but you will have to guess how). Because of auto-GET/POST/AJAX, the browser would certainly send the referrer unless a loophole is found in the browser. But not in this case as it is same-site.

Code snippet shows existance of an administror secretcommand - SEC Consult

With this CSS method we can construct a CSRF payload as a HTTP GET request:
background-image:url(/send?name=admin&msg=a);

Once the injected CSS is executed in the context of the browser of the target (administrator) the request will be sent automatically. It would make the administrator send the letter “a”, if the CSS Selector matches. But wait, wouldn’t it run into CSP? The answer is: no, it won’t, as the origin is within the same website which makes default-src ‘self’ not able to prevent the sending of the request.

Now, we can use /report to have the administrator come into our chatroom for 10 seconds. Once we got the administrator in the room, we can do a CSS injection by changing our name to malicious CSS code and then entering the word dog to our chatroom so we will get banned. Then the application uses the display() function to display the CSS from our name in the DOM and this CSS will appear on the administrator’s screen and let the hacker control CSS on the opened screen of the administrator. The next question is, what is it that we want to steal from the administrator? Once we read the code, we will find that the administrator has a /secretcommand.

Code snippet shows regex that is used to pull the value after /secret - SEC Consult

This contest is called capture the flag, our goal is to hack and steal the flag. You can see in above client-side JS that we have a chance to pull the flag from a cookie and it will be appearing in the chat room as HTML, if we can get the value one by one with CSS selector.

The person that has the flag in the cookie is the administrator and this code will make the flag appear only in the chat window that only the administrator can see.

<span data-secret = "Suppose the flag is located here"> ***** </ span>
But the problem is, that in the beginning the website won’t show this span and it would only show once there is an update with the flag value by using /secret <input new value> , which means even if we can steal from that span tag, we would get a value that changed. (Precisely, we need the original “flag” value in span but the span only shows when the value is changed, it is a deadlock.) It won’t be the original value that was in the cookie!

But if we look at the code of the server-side JS once more, we will find another loophole:

Code snippet shows how to pull the value of the cookie to display on the webpage - SEC Consult

See? The regex is used to pull the value after /secret, which is the new value of the flag, into arg[1] and then used to replace the cookie’s value of the original flag that we wanted and then the client-side JS will show the new value.

At my first glance, I thought there might be a vulnerability called HTTP response splitting, but I already tried and it didn’t work because the modules in Node.JS provided good protection. However, with a little bit of creativity we can make the administrator set the value of this cookie flag by not overwriting the original value and also use client-side JS to pull the original value back into the chatroom by not having to do a CRLF injection. We will do a CSRF by CSS injection as above to make the administrator send the /secret message to give the flag a new secret value. Here’s how it’s done…
1; domain = xxx.web.ctfcompetition.com
To call this cookie injection wouldn’t be wrong.  Once it is used and return into HTTP response we would get:
Set-Cookie: flag=1; domain=xxx.web.ctfcompetition.com; Path = /; Max-Age = 3153600

As a result, the cookie flag on the chat page is set to cookie flag = 1 by the administrator. But it will be in an unknown sub-domain xxx instead of doing it in the actual website which results in the value of the flag of the cookie to remain the original for cat-chat.web.ctfcompetition.com and doesn’t get overwritten.

After that, with the help from the client-side JS we pull the value of the cookie to display on the webpage. It pulls the original value because when you pull the cookie(‘flag’) by document.cookie you would get the value from the same origin and not the one in the sub-domain that we faked with CSRF.

Screenshot shows chat room in two different browser windows - SEC Consult

Recap of the outlined hacking techniques:

  1. Bypass XSS sanitization by using CSS injection
  2. Bypass CSP by using src (of CSS’s background image) from the same website
  3. Bypass Anti-CSRF by using same-site request instead
  4. Bypass Cookie Overwritten by cookie cross-domain injection (I made up the name, don’t know if it exists or not)

Done with the theory. Let’s look at the actual tasks before we get lost.

The first step is to open up the chat room, then copy the URL to another browser. (or use incognito mode, it will be fine as long as it has a different local storage context)

Then we generate the payload of the CSS injection from this python code:

 

import string kn = "" kn = kn.replace('}', "\\}").replace('{', "\\{") lt = string.ascii_letters + string.digits + "_}{" payload = "span[data-secret^=%s%s]{background: url(send?name=admin&msg=%s);}" res = "/name xx]{color:red;}span[data-secret]{background:url(send?name=admin&msg=/secret 1;Path=/; domain=xx.web.ctfcompetition.com);}" for x in lt: if x == '{' or x == '}': x = "\\" + x res += payload % (kn, x, x) print res + "span[data-name^=xx"

Copy/Paste of output of payload of the CSS injection - SEC Consult

Use it with the browser on the right. It’s easy just copy the output and then paste.

Typing “/report” in left browser for admin to enter the room - SEC Consult

Then switch to the browser on the left and type “/report” so the administrator will enter the room.

Typing "dog" in right browser so admin can be user - SEC Consult

Then switch to the browser on the right and type anything that has the word “dog”.  That will make the administrator ban the user in the browser to the right.

Cat chat web competition screenshot - SEC Consult

Once the user is banned, the display() function will pull the name that has that malicious CSS injected and broadcast it to everyone in the chatroom (to tell that the user got banned). The administrator will see it as well. To summarize, what the CSS payload will do is this:

  1. Start with cookie cross-domain injection so that span[data-secret] appears in the chat room of administrator’s web browser, plus the flag value retrieved from the original cookie.
  2. Do a side-channel attack with CSS by checking one selector at a time, starting from the first one.
  3. Once you know what the first letter is, do a CSRF via CSS injection and have the administrator /send the one that was found back into the chatroom with background:url(). (The people that will see the CSS injection includes everyone in the chatroom but the only one that will give out the value is the administrator as nobody else has the cookie flag, and even if a span pops up it will be a blank value that won’t match.)
Banned message from admin - SEC Consult

Once you get the first letter, repeat everything for the next letter by using a simple regex. Add prefix to the flag, let say the flag is “abc” then we will find the first letter with ^= . Once you find it then enter ^=a, if you find b then put ^=ab etc., until the last letter and at the end you get abc.

Keep doing it until you get all the letters, then we can chain everything together. All these techniques combined will let us steal information (the flag) on the web browser that the administrator has opened on the screen.

Additional information that wasn’t mentioned earlier:

  1. The web application is using a long-pulling request /receive. If someone uses an intercepted proxy, such as, BurpSuite or OWASP ZAP to analyze the web application, it will not work because the proxy must close the connection to retrieve the HTTP response. Long-pulling requests to update messages in a real-time chat room adds some difficulties to attackers.
  2. Actually in the /report in the API uses Google reCAPTCHA with autofill token in the backend so the user doesn’t need to fill in anything. Unfortunately it makes it impossible to automate all the loops throughout the whole process by an automated script.

If you are interested to read about advanced web hacking in Thai, the original article is available here