A CLI + server toolchain for running CTF competitions on CTFd. Deploy challenges from YAML, provision ephemeral per-team containers, and detect flag sharing — all from one command, with no manual CTFd API interaction.
Tested against CTFd 3.7.3. Run
nervctf probeafter any CTFd upgrade to verify compatibility.
NervCTF has two components that you deploy once and then leave running:
Your machine CTFd host (single-machine mode)
───────────── ─────────────────────────────────────────────────
┌─ Docker Compose stack ──────────────────────┐
nervctf CLI ─── Token ──▶ │ remote-monitor:33133 ─── SQL ──▶ MariaDB │
│ │ └──▶ uploads dir │
│ instance manager │
│ (docker daemon, local) │
│ CTFd (nginx+gunicorn)│
│ nervctf plugin │
└─────────────────────────────────────────────┘
nervctf (CLI) — runs on your machine. Reads challenge.yml files, validates them, and syncs them to the remote monitor. Also runs nervctf setup to provision the server.
remote-monitor (server) — runs inside Docker on the CTFd host. It writes directly to CTFd's MariaDB (no CTFd API key needed), manages container instances for per-team challenges, and serves an admin dashboard.
nervctf_instance (CTFd plugin) — deployed by nervctf setup. Hooks into CTFd's challenge system so players can request, renew, and stop containers from the CTFd UI.
In split-machine mode, containers run on a separate worker node. The CLI rsyncs challenge files directly to the runner; the monitor controls containers over SSH.
Your machine CTFd host Runner node
──────────── ────────────────────────────── ──────────────────
nervctf CLI ──▶ remote-monitor ─── SSH ──────▶ docker daemon
│ │
└── rsync ──────┘ (challenge files)
Download from GitHub Releases:
nervctf-linux-x86_64-static— CLI for Linux (static, no deps)nervctf-linux-aarch64— CLI for ARM64nervctf-windows-x86_64.exe— CLI for Windowsremote-monitor-linux-x86_64-static— server binary (deploy to CTFd host)
Rename to nervctf and remote-monitor, place in your PATH.
# With Nix (provides all dependencies)
nix develop .# --command cargo build --release
# Without Nix (Debian/Ubuntu)
sudo apt install build-essential pkg-config libssl-dev
cargo build --release
# Static musl builds (what the releases use)
nix develop .# --command cargo build --release --target x86_64-unknown-linux-muslCross-compile targets: make release-musl (static), make release-arm64, make release-windows. Run make help for all targets.
# 1. Run setup wizard — provisions Docker, CTFd, plugin, monitor on the remote host
nervctf setup
# 2. Write your challenges
mkdir -p challenges/web/sqli challenges/pwn/overflow
cat > challenges/web/sqli/challenge.yml <<'EOF'
name: SQL Injection 101
category: web
value: 100
type: standard
description: Find the flag in the database.
flags:
- flag{sql_is_fun}
EOF
# 3. Validate locally (no network required)
nervctf validate
# 4. Deploy to CTFd
nervctf deploy
# 5. Check compatibility with the live CTFd instance
nervctf probeCreated by nervctf setup. Searched upward from the working directory (walks up to filesystem root). You can place it at the repo root and run nervctf from any subdirectory.
# ── Monitor connection ─────────────────────────────────────────────────────────
monitor_ip: 1.2.3.4 # IP of the CTFd/monitor host
monitor_port: 33133 # default: 33133
# ── Authentication ────────────────────────────────────────────────────────────
monitor_token: <hex> # 64-char hex token; auto-generated by nervctf setup
# This is the token for the remote-monitor, NOT CTFd.
# ── Deployment (used only by nervctf setup / setup --upgrade) ─────────────────
monitor_user: root # SSH user on the CTFd host (must have sudo/docker)
monitor_ctfd_path: /home/root/CTFd # CTFd install path on the remote host
# default: /home/<monitor_user>/CTFd
ssh_key_path: ~/.ssh/id_rsa # private key for SSH access to monitor + runner hosts
# ── Local challenges ──────────────────────────────────────────────────────────
challenges_path: ./challenges # where nervctf looks for challenge.yml files
# ── Tuning (baked in at setup time; requires setup --upgrade to change) ────────
max_concurrent_provisions: 4 # max parallel container provisions
max_instances_per_team: 3 # max active instances per team across all challenges
# 0 = unlimited
# ── Display ───────────────────────────────────────────────────────────────────
ctfd_domain: ctfd.example.com # CTFd URL shown in admin dashboard links
# defaults to http://<monitor_ip>
# ── Split-machine mode (optional) ─────────────────────────────────────────────
# Set runner_ip to run containers on a separate node.
# Leave unset to run containers on the same machine as CTFd.
runner_ip: 192.168.1.50
runner_user: docker
runner_domain: challenges.example.com # hostname shown to players in connection strings
# defaults to runner_ipCLI flags > environment variables > .nervctf.yml
| CLI flag | Environment variable | Description |
|---|---|---|
--monitor-url |
MONITOR_URL |
Full URL of the remote monitor (http://host:port) |
--monitor-token |
MONITOR_TOKEN |
Monitor auth token |
The --monitor-url flag overrides both monitor_ip and monitor_port from the config file.
All commands accept global flags that come before the subcommand name:
nervctf [GLOBAL FLAGS] <subcommand> [SUBCOMMAND FLAGS]| Flag | Default | Description |
|---|---|---|
-c, --challenges-dir <path> |
. (current directory) |
Root directory to search for challenge.yml files |
-v, --verbose |
false | Enable verbose output |
--monitor-url <url> |
from config/env | Override monitor URL for this run |
--monitor-token <token> |
from config/env | Override monitor token for this run |
Provisions the complete server environment on the remote host. Run once per competition.
nervctf setup
nervctf setup --upgradeWhat it does (first run):
- Prompts interactively: host IP, SSH user, CTFd path, monitor port, token, SSH key
- Saves everything to
.nervctf.yml - Runs an embedded Ansible playbook that:
- Installs Docker + Docker Compose (if not present)
- Clones CTFd 3.7.3 and starts it
- Builds and starts the
remote-monitorcontainer - Rsyncs the
nervctf_instanceplugin into CTFd's plugin directory - Writes
docker-compose.override.ymlwith the monitor's env vars - Restarts CTFd so the plugin is loaded
- Polls health endpoints until everything is up
- Prints the admin dashboard URL and token
What --upgrade does:
- Pushes a new
remote-monitorbinary and plugin files to the server - Rebuilds the monitor Docker image
- Restarts CTFd and the monitor
- Checks whether the installed CTFd version matches the tested version (3.7.3)
- Does not re-provision CTFd or regenerate the token
| Flag | Description |
|---|---|
--upgrade |
Upgrade plugin + monitor binary on an existing deployment |
Validates all challenges locally, diffs them against what's on CTFd, and applies changes.
nervctf deploy
nervctf deploy --dry-run
nervctf deploy --recreate
nervctf deploy --recreate --pruneDeploy runs in four ordered phases:
| Phase | What happens |
|---|---|
| 1 — Cores | Creates new challenges and updates changed ones. Uploads flags, tags, topics, hints. Detects changed files (marks for phase 2). |
| 2 — Files | Uploads attachment files for new/changed challenges. |
| 3 — Requirements | Resolves prerequisite challenge names to CTFd IDs and patches requirements. Run after phase 1 so all IDs exist. |
| 4 — Next pointers | Patches next_id links. Same reason as phase 3. |
Before phase 1, the CLI fetches the stored compatibility probe and:
- Aborts if dynamic challenge scoring is in a broken state (partial CTFd migration)
- Warns if CTFd is in user-mode (player instance auth will fail)
- Notes any degraded capabilities (e.g. Redis cache not invalidated)
- Warns if the live CTFd version differs from the tested version (3.7.3)
A challenge is considered changed if any of these differ from the remote: category, description, state, connection_info, attempts, extra fields, flags (content, type, data), hints (content, cost), tags, topics, or files.
Type changes (e.g. standard → dynamic) always trigger a delete + recreate because CTFd's PATCH endpoint does not create the required join table rows.
| Flag | Description |
|---|---|
-d, --dry-run |
Print what would change without applying anything |
--recreate |
Force re-deploy all challenges even if up to date; re-syncs files to runner and rebuilds images |
--prune |
Delete remote challenges that no longer exist locally (run after phase 4) |
Validates challenge YAML files without connecting to the server. Exits 1 if any errors are found.
nervctf validate
nervctf validate --debugValidation checks all fields for each challenge type and cross-challenge rules (e.g. duplicate names, self-referencing requirements). See Challenge specification for the full rule table.
| Flag | Description |
|---|---|
--debug |
Print the full parsed field dictionary for every challenge |
Queries the remote monitor for the current CTFd compatibility status and displays a capability matrix.
nervctf probe
nervctf probe --refresh
nervctf probe --jsonThe monitor stores a probe result at startup and refreshes it on demand. The probe queries CTFd's MariaDB to detect the installed version, team vs user mode, which optional schema columns exist, and whether the plugin table is installed.
Example output:
NervCTF <-> CTFd Compatibility Report
══════════════════════════════════════
CTFd version : 3.7.3 (source: configs_table)
CTFd mode : team
Probed at : 2026-05-26 14:32:11 UTC
Capability Status
───────────────────────── ──────────
Challenge CRUD [ok]
Dynamic scoring [ok]
Player authentication [ok]
Instance flag lifecycle [ok]
Redis cache invalidation [DEGRADED]
Schema fingerprint:
challenges : attribution, category, connection_info, decay, description,
function, id, initial, logic, max_attempts, minimum, name,
next_id, position, requirements, state, type, value
dynamic_challenge: id (stub-only — scoring is inline in challenges)
Warnings:
• Direct MariaDB writes bypass CTFd Redis cache — stale data may be served
until CTFd restart or TTL expiry
Capability statuses:
| Status | Meaning |
|---|---|
[ok] |
Fully supported, no caveats |
[DEGRADED] |
Works with known limitations — operator is warned |
[BROKEN] |
Will not work; deploy is blocked or severely impaired |
Exit code: 0 if all capabilities are ok or degraded; 1 if any capability is broken. Suitable for CI gates.
Note: Redis cache invalidation is always DEGRADED — NervCTF writes directly to MariaDB and cannot invalidate CTFd's Redis cache. Restart CTFd or tune CACHE_DEFAULT_TIMEOUT after a bulk deploy.
| Flag | Description |
|---|---|
--refresh |
Force a fresh probe from MariaDB (ignores cached result on monitor) |
--json |
Output raw JSON instead of the formatted table |
Lists all challenges found under --challenges-dir.
nervctf list
nervctf list --detailed| Flag | Description |
|---|---|
-d, --detailed |
Show full field values for each challenge |
Scans the challenges directory and prints statistics (counts by type, category, total points, etc.).
nervctf scan
nervctf scan --detailed| Flag | Description |
|---|---|
-d, --detailed |
Show per-challenge breakdown |
Interactively scans challenge YAML files and offers to patch common missing fields (state, version, description, etc.).
nervctf fix
nervctf fix --dry-run| Flag | Description |
|---|---|
-d, --dry-run |
Show what would be patched without modifying files |
Challenges live in files named challenge.yml (or challenge.yaml). NervCTF searches recursively under --challenges-dir up to depth 5. The directory structure is arbitrary — use whatever layout suits your competition.
challenges/
├── web/
│ └── sqli/
│ ├── challenge.yml
│ └── dist/source.py ← referenced in files:
└── pwn/
└── overflow/
└── challenge.yml
Fixed-point challenge. Players submit a flag and it either matches or it doesn't.
# ── Identity ──────────────────────────────────────────────────────────────────
name: SQL Injection 101 # required; must be unique across all challenges
category: web # required
version: "0.3" # optional; local metadata only, not sent to CTFd
author: alice # optional; local metadata only
# ── Scoring ───────────────────────────────────────────────────────────────────
type: standard # standard | dynamic | instance
value: 100 # required for standard; must be > 0
state: visible # visible (default) | hidden
# ── Content ───────────────────────────────────────────────────────────────────
description: |
Find the vulnerability and retrieve the flag.
The server is at http://challenge.example.com
connection_info: "http://challenge.example.com" # optional; shown on the challenge page
attempts: 5 # max wrong guesses; 0 or omitted = unlimited
# ── Flags (at least one required for non-instance challenges) ─────────────────
flags:
- flag{simple_string} # shorthand: static type, case-sensitive
- type: static
content: "flag{alternate}"
data: case_insensitive # optional modifier; default: case-sensitive
- type: regex
content: "flag\\{[a-z]+\\}" # regex pattern
# ── Organisation ──────────────────────────────────────────────────────────────
tags: [web, sql-injection, beginner]
topics: [owasp-top-10, database] # freeform topic labels (separate from tags in CTFd)
# ── Hints ─────────────────────────────────────────────────────────────────────
hints:
- "Try single-quote injection" # free hint
- content: "The login form is vulnerable"
cost: 50 # costs 50 points to unlock
# ── Files ─────────────────────────────────────────────────────────────────────
files:
- dist/source.py # path relative to challenge.yml
- dist/Dockerfile
# ── Prerequisites ─────────────────────────────────────────────────────────────
requirements:
- "Warmup" # other challenge name; must exist locally or on CTFd
- "Web Intro"
# ── Navigation ────────────────────────────────────────────────────────────────
next: "Advanced SQLi" # name of the challenge to show as "next" in CTFd UIPoints decrease as more teams solve the challenge.
name: Crypto Hard
category: crypto
type: dynamic
value: 0 # ignored for dynamic; set initial/minimum below
extra:
initial: 500 # starting point value (required, must be > 0)
decay: 50 # number of solves at which value reaches minimum (required, must be > 0)
minimum: 100 # floor value (optional; defaults to 0)
decay_function: linear # linear (default) | logarithmic
flags:
- flag{crypto_hard}Provisions an ephemeral container per team. Players click "Request Instance" in CTFd, receive a host/port, and interact directly with their isolated environment.
name: Pwn Me
category: pwn
type: instance
value: 0
extra: # optional dynamic scoring on top of instance
initial: 500
decay: 50
minimum: 100
decay_function: linear
description: |
Connect to your instance and get root.
instance:
# ── Backend ─────────────────────────────────────────────────────────────────
backend: docker # docker | compose | lxc | vagrant
# ── Docker backend ───────────────────────────────────────────────────────────
image: . # "." = build from Dockerfile in challenge dir
# or a registry image: "ubuntu:22.04"
# ── Compose backend (mutually exclusive with docker image) ───────────────────
# compose_file: docker-compose.yml
# compose_service: app # which service exposes the port
# ── LXC backend ──────────────────────────────────────────────────────────────
# lxc_image: ubuntu:22.04
# ── Ports ────────────────────────────────────────────────────────────────────
# Single port (most common):
internal_ports: [1337]
# Multi-port: each gets a separately allocated random host port.
# The first port is the "primary" shown in the connection string.
# internal_ports: [80, 443]
# Compose multi-service (mutually exclusive with internal_ports):
# service_ports:
# app: [80]
# admin: [8080]
connection: nc # nc | http | ssh
# Controls the connection string shown to players.
# ── Lifecycle ────────────────────────────────────────────────────────────────
timeout_minutes: 45 # instance auto-expires after this many minutes
max_renewals: 3 # how many times players can extend the timer
# ── Flag ─────────────────────────────────────────────────────────────────────
flag_mode: random # random | static
# random: a unique flag is generated per instance and
# registered in CTFd's flags table at provision time.
# static: uses the flags: list above; one flag shared by all teams.
flag_prefix: "CTF{" # optional; wraps the random part
flag_suffix: "}"
random_flag_length: 16 # characters in the random part (default: 16)
# How the flag is delivered into the container:
flag_delivery: env # env (default) | file
# env: injected as $FLAG environment variable
# file: written to a bind-mounted file at flag_file_path
# flag_file_path: /challenge/flag # required when flag_delivery: file
# flag_service: app # compose only: which service gets the file mount
# ── Optional ─────────────────────────────────────────────────────────────────
command: null # override the container's CMD/entrypointUse flag_mode: static (or omit it) and define flags in the top-level flags: list. All teams share the same flag.
type: instance
instance:
backend: docker
image: .
internal_ports: [1337]
connection: nc
flag_mode: static
flags:
- flag{shared_static_flag}| Check | Severity |
|---|---|
instance block required |
Error |
instance.internal_ports must have at least one entry (unless service_ports set) |
Error |
| Port values must be 1–65535 | Error |
service_ports and internal_ports are mutually exclusive |
Warning |
instance.connection must not be empty |
Error |
instance.timeout_minutes == 0 |
Warning |
Docker backend: image required |
Error |
| Docker backend: local path missing Dockerfile | Warning |
Compose backend: compose_file not found on disk |
Warning |
LXC backend: lxc_image required |
Error |
flag_mode: random without flag_prefix/flag_suffix |
— (defaults used) |
flag_delivery: file without flag_file_path |
Error |
flag_file_path not an absolute path |
Warning |
flag_mode: static without any flags defined |
Error |
| Field | Check | Severity |
|---|---|---|
name |
Empty or whitespace | Error |
name |
Duplicate across challenge files | Error |
category |
Empty or whitespace | Error |
value |
== 0 for standard type |
Error |
extra |
Missing for dynamic type |
Error |
extra.initial |
Missing or == 0 for dynamic |
Error |
extra.decay |
Missing or == 0 for dynamic |
Error |
extra.minimum |
Not set for dynamic |
Warning |
extra.decay_function |
Not "linear" or "logarithmic" when set |
Error |
flags |
At least one required (non-instance, non-random-flag) | Error |
flags[].content |
Empty or whitespace | Error |
flags |
Duplicate content values | Warning |
hints[].content |
Empty or whitespace | Error |
files |
Referenced file not found on disk | Error |
requirements |
Challenge lists itself | Error |
requirements |
Named challenge not found locally | Warning |
next |
Points to itself | Error |
next |
Named challenge not found locally | Warning |
attempts |
Set to 0 (same as unlimited — likely a typo) |
Warning |
| Unknown YAML keys | Not in the spec | Warning |
The monitor runs as a Docker container on the CTFd host and is managed by the docker-compose.override.yml that nervctf setup writes.
http://<monitor_ip>:<monitor_port>/admin?token=<MONITOR_TOKEN>
Or log in once at http://<monitor_ip>:<monitor_port>/ and the session cookie is set.
The dashboard shows:
- All active container instances per team
- Flag submission attempts (including flag-sharing alerts)
- Correct solve log
- Runtime config (public host, runner mode, base dirs, etc.)
- CTFd compatibility probe result
These are set by nervctf setup in docker-compose.override.yml and don't normally need manual editing.
| Variable | Required | Default | Description |
|---|---|---|---|
CTFD_DB_URL |
yes | — | MariaDB URL: mysql://user:pass@host/db |
MONITOR_TOKEN |
yes | — | Admin token (SHA-256 hashed on startup; plaintext never stored) |
PUBLIC_HOST |
yes | — | Hostname or IP returned to players in connection strings |
MONITOR_PORT |
no | 33133 |
TCP port to listen on |
MONITOR_BIND |
no | 0.0.0.0 |
Bind address |
DB_PATH |
no | ./monitor.db |
Path to the SQLite database |
CHALLENGES_BASE_DIR |
no | /opt/nervctf/challenges |
Where challenge files are extracted on the server |
CTFD_UPLOADS_DIR |
no | "" |
Absolute path to CTFd's uploads directory (for file attachment writes) |
RUNNER_SSH_TARGET |
no | "" |
Split-machine mode SSH target, e.g. docker@192.168.1.50 |
MAX_CONCURRENT_PROVISIONS |
no | 4 |
Parallel container provisions (semaphore) |
MAX_INSTANCES_PER_TEAM |
no | 0 |
Max active instances per team across all challenges (0 = unlimited) |
CTFD_DB_SYNC_INTERVAL |
no | 30 |
Seconds between CTFd solve/user sync cycles |
CTFD_DOMAIN |
no | http://<PUBLIC_HOST> |
CTFd URL shown in admin dashboard links |
The monitor runs two background loops independently of HTTP traffic:
Sync loop (every CTFD_DB_SYNC_INTERVAL seconds):
- Reads CTFd
submissionsand caches correct solves in SQLite - Reads CTFd
teamsandusersfor name display - Reverts instances back to running if a CTFd admin deleted a solve record
- Purges stale flag attempt records
Expiry + health loop (every 30 seconds):
- Tears down instances whose
expires_athas passed - Kills stuck provisioning stubs older than 30 minutes
- Detects and tears down orphaned Docker Compose projects not tracked in SQLite
- Health-checks tracked instances and cleans up externally killed containers
When a player submits a flag, the CTFd plugin calls the monitor's /api/v1/plugin/attempt endpoint. The monitor checks whether the submitted flag was originally issued to a different team. If so, it records a flag-sharing alert visible in the admin dashboard under "Attempts (alerts only)".
Flag ownership is stored permanently in the team_flags SQLite table — even after an instance expires, the monitor remembers which team owned which flag.
Set runner_ip and runner_user in .nervctf.yml to run containers on a separate node.
How it works:
nervctf deployrsyncs each challenge's build context directly from your machine torunner_user@runner_ip:~/challenges/<name>/usingssh_key_path- The monitor SSHes to the runner to build Docker images and start containers
- The monitor generates a dedicated SSH keypair at setup time (
monitor_ssh_key) bind-mounted into the monitor container
Requirements on the runner node:
- Docker + Docker Compose installed
- SSH access for the monitor (public key added to
~/.ssh/authorized_keys) - No CTFd required — it is isolated to the CTFd host
| Symptom | Cause | Fix |
|---|---|---|
| No challenges found | Files not named challenge.yml or deeper than 5 directories |
Check path; max depth is 5 |
state: Field may not be null |
CTFd requires state field |
Run nervctf fix |
| File upload 500 | Wrong ownership on uploads directory | chown -R 1001:1001 <ctfd_path>/.data/CTFd/uploads |
| Monitor 401 | Token mismatch between CLI and server | Check monitor_token in .nervctf.yml matches MONITOR_TOKEN on server |
ansible-playbook: not found |
Tool not in PATH | Run inside nix develop .# or install ansible |
| Players get 403 on instance request | CTFd is in user-mode | Switch CTFd to team mode; nervctf probe confirms |
| Dynamic challenges fail 500 in CTFd admin | Partial CTFd schema migration | Run nervctf probe --refresh to diagnose |
| rsync permission denied | SSH key not passed to rsync | Set ssh_key_path in .nervctf.yml |
| Instance shows wrong host to players | runner_domain or PUBLIC_HOST wrong |
Set runner_domain in .nervctf.yml and re-run nervctf setup --upgrade |
| Flag submission says "Incorrect" but flag matches | CTFd attempt() return type mismatch |
Upgrade NervCTF; fixed in v2.4.0 |
# All dev commands use the Nix devshell
nix develop .# --command cargo check
nix develop .# --command cargo test
nix develop .# --command cargo fmt
# Build static release binaries
nix develop .# --command cargo build --release --target x86_64-unknown-linux-musl
# Copy to dist/ after build (required by CLAUDE.md)
cp target/x86_64-unknown-linux-musl/release/remote-monitor dist/remote-monitor-linux-x86_64-static
cp target/x86_64-unknown-linux-musl/release/nervctf dist/nervctf-linux-x86_64-staticSee docs/ for per-module documentation:
| Document | Contents |
|---|---|
docs/instance-challenges.md |
Full instance challenge YAML reference, backend details, multi-port setup |
docs/remote-monitor.md |
All API routes, env vars, schema detection, compatibility probe |
docs/challenge_manager.md |
Sync logic, needs_update fields, sub-resource strategies |
docs/validator.md |
All validation rules with severities |
docs/ctfd_api.md |
HTTP API surface, version compatibility table |
docs/ctfd-dependency-audit.md |
Full CTFd dependency map, risk register |
docs/dev-notes.md |
Build env, cross-compilation, architectural notes |
See ARCHITECTURE.md for the full system reference.
MIT License. See LICENSE for details.