Skip to content

rootHytx/NervCTF

Repository files navigation

NervCTF

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 probe after any CTFd upgrade to verify compatibility.


How it works

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)

Installation

Pre-compiled binaries (recommended)

Download from GitHub Releases:

  • nervctf-linux-x86_64-static — CLI for Linux (static, no deps)
  • nervctf-linux-aarch64 — CLI for ARM64
  • nervctf-windows-x86_64.exe — CLI for Windows
  • remote-monitor-linux-x86_64-static — server binary (deploy to CTFd host)

Rename to nervctf and remote-monitor, place in your PATH.

Build from source

# 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-musl

Cross-compile targets: make release-musl (static), make release-arm64, make release-windows. Run make help for all targets.


Quick start

# 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 probe

Configuration

.nervctf.yml

Created 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_ip

Configuration precedence (highest wins)

CLI 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.


Commands

All commands accept global flags that come before the subcommand name:

nervctf [GLOBAL FLAGS] <subcommand> [SUBCOMMAND FLAGS]

Global 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

nervctf setup

Provisions the complete server environment on the remote host. Run once per competition.

nervctf setup
nervctf setup --upgrade

What it does (first run):

  1. Prompts interactively: host IP, SSH user, CTFd path, monitor port, token, SSH key
  2. Saves everything to .nervctf.yml
  3. 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-monitor container
    • Rsyncs the nervctf_instance plugin into CTFd's plugin directory
    • Writes docker-compose.override.yml with the monitor's env vars
    • Restarts CTFd so the plugin is loaded
    • Polls health endpoints until everything is up
  4. Prints the admin dashboard URL and token

What --upgrade does:

  • Pushes a new remote-monitor binary 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

nervctf deploy

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 --prune

Deploy 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. standarddynamic) 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)

nervctf validate

Validates challenge YAML files without connecting to the server. Exits 1 if any errors are found.

nervctf validate
nervctf validate --debug

Validation 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

nervctf probe

Queries the remote monitor for the current CTFd compatibility status and displays a capability matrix.

nervctf probe
nervctf probe --refresh
nervctf probe --json

The 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

nervctf list

Lists all challenges found under --challenges-dir.

nervctf list
nervctf list --detailed
Flag Description
-d, --detailed Show full field values for each challenge

nervctf scan

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

nervctf fix

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

Challenge specification

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

Standard challenge

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 UI

Dynamic scoring challenge

Points 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}

Instance challenge

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/entrypoint

Static-flag instance challenge

Use 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}

Validator rules for instance challenges

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

Validation rules (all types)

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

Remote Monitor

The monitor runs as a Docker container on the CTFd host and is managed by the docker-compose.override.yml that nervctf setup writes.

Admin dashboard

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

Environment variables

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

Background tasks

The monitor runs two background loops independently of HTTP traffic:

Sync loop (every CTFD_DB_SYNC_INTERVAL seconds):

  • Reads CTFd submissions and caches correct solves in SQLite
  • Reads CTFd teams and users for 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_at has 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

Flag sharing detection

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.


Split-machine mode

Set runner_ip and runner_user in .nervctf.yml to run containers on a separate node.

How it works:

  1. nervctf deploy rsyncs each challenge's build context directly from your machine to runner_user@runner_ip:~/challenges/<name>/ using ssh_key_path
  2. The monitor SSHes to the runner to build Docker images and start containers
  3. 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

Troubleshooting

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

Development

# 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-static

See 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.


License

MIT License. See LICENSE for details.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Packages

 
 
 

Contributors