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.
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.
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¶
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:
chal.pyandoutput.txtare registered as handout files.- The platform calls
chal.pyat generation time with the per-team flag injected;chal.pywritesoutput.txt. - 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:
- The platform derives the per-team flag and starts a container from
ecbeast:latestwithFLAG=<derived-flag>in the environment. - The player connects to the container on port 5337/TCP, exploits the ECB oracle, and retrieves their team's flag.
- The player submits the flag on the challenge page; a correct submission
records a
Solveand creditspointsto their principal.
Source-only handout (inject_flag: false)¶
From challenges/rand-web/rand-web.yml:
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.