CSAW’18 RTC Quals — Clicker 2.0 Write-ups
Write-ups for the challenges Adrift, Negativity and OpenObjectivity from the CSAW’18 RTC Qualifiers.
Whilst not being eligible for the finals, me and my CTF team decided to do the CSAW’18 RTC Qualifiers anyway, for the practice, learning, and fun!
This set of challenges got really interesting toward the end as it had me and my team members playing with the key logic behind common Python functions and libraries in order to exploit the services.
Flag 0: Recon
No, this isn’t a flag, but it’s important to know what we’re dealing with before trying to exploit it.
If we visit the website, we’re greeted with what seems to be some documentation on a REST API. If we look at the name ‘Clicker 2.0’ and the endpoints under the clicker
namespace it all hints at being a clicker game, (such as the famous Cookie Clicker) but with the twist that it’s all done through a REST API. There’s also a zip file supplied which contains the source code for the site; You’ll notice it says that it runs on Python3, this information is important later on.
If we click on the individual endpoints it details how it should be used, as well as a handy ‘Try it out’ button. Let’s try making ourselves a user.
Woah, that’s a lot of information; Let’s focus on what’s important here.
Here I edited their example to ‘sammy’ and the unquestionably secure password of ‘samsamsam’, and hit the big ‘Execute’ button.
And then here we’ve got our response, stating that our user was successfully created, and it gives us our token. Something to note with this token is that it begins with ey
, which tells us it’s encoded JSON.
If we decode it, we get some JSON and some seemingly-random data; That data is not random, this is in fact a JWT (JSON Web Token). CyberChef has support for decoding JWT to JSON, and vice-versa.
Now we some nice JSON. Of course we can’t modify this token unless we get the secret key as that seemingly-random data is actually a signature, so we would need the secret key to re-sign it.
Anyways, back to Clicker 2.0. Let’s try using our new token to play this game. The web interface isn’t going to cut it for us, let’s program our own wrapper for the Clicker 2.0 REST API.
At first we can begin with a simple little wrapper to let us query the endpoints easily.
It’s working so far, let’s add some more specific functions to make this a bit easier.
Now we can play the game!
So we have enough money to buy the cheapest clicker called ‘base’, it costed all 5 of our hard-earned money!
Now we’ve clicked it, we’ve managed to get +1 money!
If we spam it though, we’re told we’ll get banned? There must be a rate-limit of some sort.
Of course we could keep this going on for hours and get lots of money… But, time is flags!
Flag 1: Adrift
Now that we’re in flag territory, let’s have a look at the supplied source code.
Extracting the zipped file gives us the complete source code for the clicker site; Of course not fully up to date when it comes to the database file there, but good enough for us. The challenge description doesn’t tell us too much about how to solve the challenge, so let’s just search for the word ‘flag’.
If we do a quick search for the word flag through all the files, we see that there is reference to three different flags in clicker/__init__.py
, so let’s take a look at the first one.
The first flag appears want a request with a header called bring_back_random_click
which has to equal the return of the function random_string
and if it’s not, it will return a 404 error. Before we take a look at random_string
we must have a look at the route that this is located in, as it’s not @app.route
but rather @default.route
which implies that this may be a Flask Blueprint.
It appears that this isn’t a Blueprint, but rather a Namespace, which is offered by the python module flask_restplus
but we don’t need to worry about that part, as if we scroll further down in the code…
We can see that default is added to the namespace /default
so the path we must GET to get the flag is /default/
.
If we take a look at the random_string
function we see that defines random_str
with nothing, picks a index from the lowercase alphabet ( string.ascii_lowercase
) and then appends the letter at that index to random_str
10 times, then it returns random_str
.
As there are only 26 characters in the lowercase alphabet, we can just keep trying the same string over and over until we eventually get it right.
Here we write a script which will keep trying the trying the string “aaaaaaaaaa” until the the request doesn’t return 404, which would mean we got the guess correct, and then print the response text, which should be the flag.
After a second or two when running it, we are sent the flag and can claim our 100 points
flag{wh@t_Ar3_lAt3_b1nDiNg_cl0sUreS}
Flag 2: Negativity
Negativity gives us the same URL and file as Adrift, as they are all part of the same series, with only 18 solves, it must be pretty challenging.
Going back to our grep
command, we can see that clicker/__init__.py
also contains some reference to the second flag.
We find that at the route /default/money
we have a function that requires a user token; then appears to grab some user object and checks if that user’s money is greater than or equal to 0, if so, return error 404, if not, return the flag.
So the clear goal here is to create a user account and get it to have negative money, but where do we start?
In the end, the only thing that can change our money is the service itself, so we can use grep
to search for all references to user.money
. Let’s check them in order. Line 54 is part of the second flag function, so we can ignore that.
Lines 76, 77 and 79 all appear to be part of some admin command to give a user money.
Lines 36 and 53 of clicker/service/user_click.py
immediately stick out to me, as they both make use of the round
function. Why would they need to round these values? Let’s inspect the code further.
These lines are part of the function purchase_clicker
, although this function is pretty big so let’s focus on the part of the function with these lines using user.money
.
So the first thing to take note of is that round()
is used here, in Python3 (I said it would be important) this function has a seemingly strange behaviour.
if two multiples are equally close, rounding is done toward the even choice (so, for example, both
round(0.5)
andround(-0.5)
are0
, andround(1.5)
is2
). (https://docs.python.org/3.5/library/functions.html#round)
Essentially, if we’re rounding a number which ends in .5, it will round to the nearest even integer.
Using the knowledge of this behaviour, there must be a way to get the if
statement to pass, but with round(price)
larger than user.money
so that we can get negative money, and then get the flag.
Let’s think of a number for price
, and an integer for user.money
that causes this. We would want price
to be bigger than user.money
when rounded, But when subtracted before rounded from user.money
to be 0 or greater. The lowest value user.money-price
can be and still round to 0 is -0.5, so let’s have that as a constraint for our numbers: Whatever user.money
is, price
is 0.5 more than it. Now we want a pair of number following this constraint such that price
is rounded to a larger number than user.money, as round
rounds to the nearest even number, user.money
to be an odd value and price
to be that same odd value plus 0.5 then price
should theoretically round up to be 1 higher than user.money
. Let’s test this.
I picked the odd number 3 arbitrarily, let’s see what the round(money-price)
is equal to:
This is 0, meaning the first if would pass, allowing all the stuff that handles buying the clicker to be executed. Lets see what money-round(price)
comes out to:
It results in a negative! We have found our exploit!
The next step is to figure out how to get these numbers into those variables, as a recap: Money needs to be odd, and price needs to be money plus 0.5.
As money need only be an odd integer, and the base clicker can give us 1 money per click, this can be easily set.
Price is calculated from the clicker price multiplied by this scale, which is raised to the power of the quantity of clickers, essentially scaling the price of the clicker depending on how many you already own.
During recon we discovered this scale value to be constant of 1.15 for all clickers.
If we look through all the clickers, there’s a clicker called momo where the price is 10, and 10 * 1.15 is 11.5, which is an odd number plus 0.5!
So we need to click base until we get 10, buy momo, then click until we get 11, and buy momo to get negative money. As our old account already has a lot of money on it, let’s create a new one:
Let’s call it ‘B1g F3ll4’, after my team name ‘B1g F3ll4s’ with the ever-so-secure password ‘samsamsam’
Let’s copy our new key into our wrapper that we wrote from the recon and run it.
Let’s buy the base clicker, and click 10 times (we sleep to avoid the rate-limit ban).
Now we’ve bought momo, we need to click 11 times and buy momo again!
Success! Negative money! Now we just need to HTTP GET /default/money
And that is our flag!
flag{d1d_u_eXp3r1ence_gr0wTh}
Flag 3: OpenObjectivity
OpenObjectivity gives us the same URL and file as Adrift and Negativity, as they are all part of the same series, with only 11 solves and 500 points up to grabs it must be quite a tough challenge; We have two challenges in the clicker series under our belt, how hard could another one be?
Looking at the part for the third flag, we see it’s located at /default/record
and requires the logged in user to have a record of over 5000000000 (which is 5×10⁹ or more commonly known as 5 billion) otherwise it will just report 404.
First question, what is user.record
? Let’s grep it.
Other than the occurrence we already know of, it takes us back to the user_click.py file we used in the second flag.
This time under the function use_clicker
which is implied to run when we click a clicker.
This may look a little confusing to begin with, but if we dissect it it will become more understandable. First it sets the variables curr_time
to the current time, and works out the difference between this time, and whatever user.record_time
is and stores it in time_diff
, it then checks if this difference is bigger than 5 seconds and if so it will set user.record
to 0 and the user.record_time
to the current time, if not it will add the amount of money from that click to the user.record
.
This is essentially recording how much money you make for 5 seconds, and then resets it. So we need to get 5 billion money in 5 seconds.
First let’s check if this is possible.
The most valuable clicker is captiosus, which is 10000000 (which is 10⁷ or 10 million). As the maximum quantity is 10, that means the highest amount we can get in 1 click is 10 * 1000000 = 100000000 (which is 10⁸ or 100 million). Which means we need to click in 5000000000÷100000000 = 50 times (with the best clicker) in 5 seconds. Back in the recon we discovered a rate-limit where if you spam too quickly it will threaten you with a ban.
So let’s find out what this rate-limit actually is.
We can try a couple search terms until we find one that works, and here we see two uses of the string rate_limit
.
The first imports rate_limit
and the second in the same file uses that import on the click route, or what manages when we click.
Decorators in python are a bit confusing, lets focus on the part that matters:
So this seems to check if the time since you’ve last clicked is smaller than the interval
which is the reciprocal of reqs_per_sec
. This means that you can click reqs_per_sec
times per second, as implied by the name which could be read as ‘requests per second’.
This means that we can only click twice per second, so we can only click 10 times in 5 seconds, meaning we the maximum record we can get is 100000000×10=1000000000 (10⁹ or 1 billion) which is only a fifth of our goal, 5 billion. This likely means that playing the game normally is not our ticket to victory.
One thing to take note of is that the user.record
has to be stored somewhere between requests, which is where the database file comes into play.
Yup, there’s a database file in there. Let’s have a poke around.
Seems to be a SQLite3 database, which is common for small-scale python projects like this. Luckily there is a program which lets us see what is inside:
Let’s see what tables and columns in those tables are available to us:
We can see that the table user
has a column called record
, lets have a peek at the contents of that table:
Well, that’s a bit of a mess, let’s only pick out username and record:
We can see that this one outlier has 7400000000 (7.4×10⁹ or 7.4 billion!) as their record! Meaning that our route is probably to find a way to log in as a user that already has a record over 5 billion.
Having a look at the auth
table, we can see that password hashes are stored, so let’s find the password hash of our record-breaking user!
Ah, that’s quite a good hashing algorithm which slightly discourages my hash-breaking side. Let’s try something else.
Do you remember earlier, when we found out that the key used for authorisation was in fact a JSON Web Token? Let’s have a play with this information.
The only thing stopping us from creating our own JSON Web Tokens is the lack of a SECRET_KEY to sign the token, maybe there is a vulnerability that can get us this secret?
Whilst skimming over the code, we might notice the user of getattr
here in clicker/service/clicker.py
. getattr
is short for Get Attribute, which essentially does what is says on the tin, it takes in an object, and takes in a name, and will try to get the value from an attribute named as specified in the object. The vulnerability is that the person passes in sys.modules[__name__]
which essentially refers to the current script.
The programmer intended for the only accessible things to be these clicker classes, which would return the information about the clicker (as they all inherit base).
However, we can access all variables within this script, which includes the imported Config here at the top of the script; Let’s try this.
We’ll have to find where this function is called, and if it passes unsanitised input into it.
The first occurrence seems to be what we want.
If we remember from the original website, this is the endpoint to get a clicker by name.
Sadly, it didn’t quite work. Let’s go back to the script to see what went wrong.
The focus is on the last line, it tries to call the output of getattr
and get the __dict__
of it.
You may have noticed get_clicker_field
here, and you wouldn’t be alone, so let’s take a look at that one, which just so happens to be right below the definition of get_clicker
.
This one also uses getattr
but it uses it twice, so we could potentially use it to extract a specific variable from config, so let’s take a look at what is in config.
It’s imported from a directory up, from config.py
.
We need not look far, the secret key is right there! Let’s try specifying a field of SECRET_KEY
when querying /clicker/Config
this time:
Looks like we found ourselves the SECRET_KEY!
dId_you_r3aLly_think_I_w0u1dnt_s3t_a_key
Let’s decode one of our older tokens using CyberChef:
This is the decoded token. As specified in the standard the exp is Expiration Time, the iat is the Issued At Time, and the sub is the Subject. Judging that the Subject is a number, I’m assuming that this is the user ID.
Let’s write a script to keep trying different IDs until we find one which has a record higher than 5 billion.
Luckily there’s a library just for our case, I set the expiry to be really big so the token won’t expire. I then iterate over the numbers 0 to 4999, and try them all as ids.
And with that, the 500 points can be claimed!