Skip to content

Challenge format reference

Every challenge is described by a single YAML file validated by ChallengeImportYaml (defined in ctfapp/schemas/lab_stack.py). The importer entry point is ctfapp/services/challenge_importer.py:import_challenge_yaml.


Two syntaxes

The entire document is a YAML mapping. Set description_md directly as a multi-line scalar.

ctf_version: 1
slug: ecbeast
title: ECBeast
category_slug: crypto
description_md: |
  A live AES-ECB oracle challenge. Connect to the service, recover the
  per-team flag from the oracle, and submit it on the challenge page.
points: 100

Separate the YAML header from a Markdown body with ---. Everything after the second --- delimiter becomes description_md. Use this syntax when the description is long or contains complex Markdown.

---
ctf_version: 1
slug: frontmatter-static
title: Frontmatter Static
category_slug: misc
points: 50
status: hidden
files:
  - chal.py
  - output.json
output_file: output.json
---
This description came from the manifest body.

Top-level fields

Field Type Required Default Notes
ctf_version integer no 1 Must be exactly 1.
slug string yes 1–120 chars. Unique identifier used as the URL path and workspace directory name.
title string yes 1–200 chars. Display name shown on the challenge list.
category_slug string yes 1–40 chars. Must match a category key declared in challenges/manifest.yml (e.g. crypto, web, misc, pwn, rev, forensics).
description_md string no "" Markdown rendered on the challenge detail page. Omit here if using frontmatter body syntax.
points integer yes Must be > 0. Awarded to the principal on a correct submission.
flag_prefix string no GRIZZ (or DEFAULT_FLAG_PREFIX env var) Max 40 chars. The literal prefix before {...} in the derived flag string.
is_dynamic boolean no false When true, enables the /dispatch spawn flow so players can start a per-team container instance.
status "hidden" | "visible" no "hidden" "visible" shows the challenge to players and triggers flag pre-generation for all existing principals.
container_image string no null Docker image reference. Shorthand for a single-service lab_stack. Requires container_port.
container_port integer no null 1–65535. Port the container listens on.
container_scheme string no "http" Protocol hint for the access URL display: "http", "https", "tcp", "udp".
container_transport string no "tcp" Transport layer: "tcp" or "udp".
output_file string no null Filename of an artifact written by chal.py after per-team flag injection. Only the basename is stored; directory separators are stripped.
files list no [] Handout files. Each entry is a string path or a dict (see below). flag.txt is reserved and will be rejected.
lab_stack object no null Multi-service lab definition (see below). Takes precedence over container_image/container_port when both are present.

files entries

Each item in files is either a string (shorthand) or a dict (full form). The two forms cannot be mixed for the same file.

String shorthand

files:
  - chal.py       # served as-is; display name = basename
  - output.txt

Dict form

files:
  - name: chal.py           # display name (optional when path is set)
    path: chal.py           # path relative to the manifest directory
    inject_flag: false      # do NOT inject the per-team flag into this file
Field Type Default Notes
name string basename of path 1–255 chars. Display name for the download.
path string 1–512 chars. Source path. Mutually exclusive with contents.
contents string Inline file contents. Mutually exclusive with path.
generated boolean false When true, the file is produced by chal.py at generation time rather than read from disk now. Must set name or path.
inject_flag boolean true When true, the per-team flag is available to chal.py before the file is served. When false, the file is distributed as-is.

Reserved filename

flag.txt is managed internally. Declaring it under files will cause validation to fail with an error.


lab_stack — multi-service labs

Use lab_stack when a challenge requires more than one container, or when you need precise resource control.

lab_stack:
  ttl_seconds: 3600        # default; range 60–604800
  services:
    - name: app
      image: rand-web:latest
      internal_port: 1337
      protocol: http
      transport: tcp
      cpu_units: 512       # Docker CPU-share units
      memory_mb: 256
      env:
        SOME_VAR: value
      inject_flag: true    # FLAG env var injected into this container
    - name: db
      image: postgres:16-alpine
      internal_port: 5432
      protocol: tcp
      transport: tcp
      cpu_units: 256
      memory_mb: 256
      inject_flag: false

lab_stack top-level

Field Type Required Default Notes
ttl_seconds integer no 3600 60–604800 (7 days). How long the instance lives before the reaper destroys it.
services list yes At least one service required.

Per-service fields

Field Type Required Default Notes
name string yes 1–64 chars. Internal identifier.
image string yes Docker image reference (up to 512 chars).
internal_port integer yes 1–65535. Port the container exposes.
protocol string no "http" "http", "https", "tcp", or "udp".
transport string no "tcp" "tcp" or "udp".
cpu_units integer no 256 64–100000. Docker CPU-share units.
memory_mb integer no 256 32–32768 MB RAM cap.
env mapping no {} Additional environment variables.
command list of strings no null Overrides the container's default CMD.
inject_flag boolean no true When true, the per-team flag is passed as the FLAG environment variable.

Shorthand equivalence

A top-level container_image + container_port pair is automatically promoted to a single-service lab_stack with name: app, cpu_units: 512, and memory_mb: 256. Prefer explicit lab_stack when you need custom resource limits or multiple services.


Worked examples

Static crypto challenge (files + output_file)

From challenges/circular/circular.yml:

ctf_version: 1
slug: circular
title: Circular
category_slug: crypto
description_md: |
  An experimental/educational crypto challenge that explores the concept of circular ciphers using circulant matrices.

  Files provided:
  - `chal.py`
  - `output.txt`
points: 100
flag_prefix: GRIZZ
is_dynamic: false
status: visible
output_file: output.txt
files:
  - chal.py
  - output.txt

What happens on import:

  1. chal.py and output.txt are registered as handout files.
  2. The platform calls chal.py at generation time with the per-team flag injected; chal.py writes output.txt.
  3. Both files appear as downloads on the player's challenge detail page.

Dynamic container challenge (shorthand)

From challenges/ECBeast/ecbeast-challenge.yml:

ctf_version: 1
slug: ecbeast
title: ECBeast
category_slug: crypto
description_md: |
  A live AES-ECB oracle challenge. Connect to the service, recover the
  per-team flag from the oracle, and submit it on the challenge page.
points: 100
flag_prefix: GRIZZ
is_dynamic: true
status: visible
container_image: ecbeast:latest
container_port: 5337
container_scheme: tcp
container_transport: tcp

What happens when a player spawns:

  1. The platform derives the per-team flag and starts a container from ecbeast:latest with FLAG=<derived-flag> in the environment.
  2. The player connects to the container on port 5337/TCP, exploits the ECB oracle, and retrieves their team's flag.
  3. The player submits the flag on the challenge page; a correct submission records a Solve and credits points to their principal.

Source-only handout (inject_flag: false)

From challenges/rand-web/rand-web.yml:

files:
  - name: chal.py
    path: chal.py
    inject_flag: false

Setting inject_flag: false distributes chal.py as-is. The flag lives only inside the running container; the source file is handed out so the player can read and exploit the vulnerable code.

Frontmatter + generated output

From challenges/frontmatter-static/frontmatter-static.yml:

---
ctf_version: 1
slug: frontmatter-static
title: Frontmatter Static
category_slug: misc
points: 50
status: hidden
files:
  - chal.py
  - output.json
output_file: output.json
---
This description came from the manifest body.

The body after the second --- becomes description_md.


Category constraints

category_slug must match a key defined in challenges/manifest.yml. The current event defines these categories:

Slug Display name
crypto Crypto
misc Misc
web Web
pwn Pwn
rev Reverse
forensics Forensics

Empty categories

pwn, rev, and forensics have no challenges imported yet. A valid import targeting those slugs will succeed, but the category will only appear on the player challenge list once at least one visible challenge exists in it.