Skip to content

Dynamic challenges & labs

A dynamic challenge is one where each team (or solo player) gets their own isolated container instance. The player exploits the live service to retrieve a flag that is unique to their team.


How it works

Setting is_dynamic: true in a challenge YAML does three things:

  1. The Dispatch spawn flow is enabled for that challenge.
  2. When a player clicks Spawn, the platform derives their per-team flag and starts a container with FLAG=<derived-flag> in its environment.
  3. The instance is recorded in the database with a TTL; the reaper destroys it when it expires (or when the player clicks Destroy).

The platform routes spawn requests through two paths depending on configuration:

  • VPN lab path (LAB_ENABLED=true + a valid lab_stack or container spec): containers receive stable IP addresses reachable over WireGuard; each team's network is isolated at the IP level. See the WireGuard setup guide for player instructions.
  • Host-port path (development / no VPN): the container is started on the local Docker daemon with a random published host port. This path is blocked in production (APP_ENV=production) by a hard gate in the dispatch code.

Challenge YAML fields

Shorthand (single service)

is_dynamic: true
container_image: ecbeast:latest
container_port: 5337
container_scheme: tcp      # default: http
container_transport: tcp   # default: tcp

The platform promotes this to a single-service lab_stack automatically with cpu_units: 512 and memory_mb: 256.

Explicit lab_stack (multi-service)

is_dynamic: true
lab_stack:
  ttl_seconds: 3600
  services:
    - name: web
      image: rand-web:latest
      internal_port: 1337
      protocol: http
      transport: tcp
      cpu_units: 512
      memory_mb: 256
      inject_flag: true
    - name: db
      image: postgres:16-alpine
      internal_port: 5432
      protocol: tcp
      transport: tcp
      cpu_units: 256
      memory_mb: 256
      inject_flag: false

See challenge-format.md for the full field reference.


TTL and instance reaping

Each instance has an expires_at timestamp set at spawn time:

  • The default TTL is controlled by INSTANCE_TTL_SECONDS (default: 7200, i.e. 2 hours).
  • The lab_stack.ttl_seconds field in the YAML overrides the default for that specific challenge (range: 60–604800 seconds).
  • Instances that reach expires_at are not destroyed automatically during normal web traffic — you must run the reaper.

Running the reaper

uv run python cli.py instance reap

The reaper calls reconcile_expired_lab_instances, which destroys all instances whose expires_at has passed and updates their status in the database. Run this on a cron schedule in production.


Configuration requirements

Labs require explicit enablement

Dynamic challenges can be imported at any time, but spawning instances requires all of the following to be true:

Requirement Config key / condition
Lab orchestration enabled LAB_ENABLED=true
Docker daemon reachable LAB_DOCKER_HOST (or default socket)
Container image built and available Image present in daemon or registry
VPN mode (production) LAB_ISSUE_WG_CONFIG=true + WireGuard configured

In development (APP_ENV=development or APP_ENV=testing), the platform falls back to the local Docker daemon with random published ports instead of VPN. This fallback is blocked in production.


Spawn / reset / destroy flow

Players interact with instances through /dispatch:

Action URL What happens
Spawn POST /dispatch/spawn/<challenge_id> Derives per-team flag; starts container; records Instance row.
Destroy POST /dispatch/destroy/<challenge_id> Stops and removes container; deletes Instance row.
Reset POST /dispatch/reset/<challenge_id> Destroy + immediate respawn. Subject to a cooldown enforced by the Dispatch service.

The player also sees a list of active instances at /dispatch/instances.


Per-team isolation

Every spawned instance receives the per-team flag as the FLAG environment variable:

FLAG = <flag_prefix>{hmac-sha3-256(ADMIN_KEY, team_secret || challenge_id.to_bytes(4,"big"))[:32]}
  • team_secret is 32 bytes of random entropy generated once per principal and never changed.
  • ADMIN_KEY is the server-side secret (ADMIN_KEY env var).
  • The flag is unique per principal per challenge. A flag leaked from one team is cryptographically invalid for any other team.

On the VPN path, network access to each container is further restricted to the WireGuard peer whose team_id matches the instance owner. A team cannot reach another team's container even if they discover its IP address.


CLI instance management

Administrators can manage instances without going through the UI:

Spawn an instance

# By team ID
uv run python cli.py instance spawn ecbeast --team-id 3

# By user ID (solo player)
uv run python cli.py instance spawn ecbeast --user-id 7

# By principal ID
uv run python cli.py instance spawn ecbeast --principal-id 12

Destroy an instance

uv run python cli.py instance destroy ecbeast --team-id 3

Reset (destroy + respawn)

uv run python cli.py instance reset ecbeast --team-id 3

Reap all expired instances

uv run python cli.py instance reap

All instance commands accept --app-env <env> and --json for machine-readable output.


Admin panel instance management

From the admin dashboard, individual instances can have their TTL extended via Admin → Instances → Extend. This calls extend_instance_window which adds additional time to expires_at without restarting the container.


Extending the dispatch remote (production)

In production, the web process delegates spawning to a separate Dispatch service over an authenticated internal HTTP API:

DISPATCH_INTERNAL_URL=http://localhost:5001
DISPATCH_ADMIN_TOKEN=<secret>
DISPATCH_USE_REMOTE=true

When DISPATCH_USE_REMOTE=false or the URL is empty, the platform uses the local Docker daemon directly (development only; blocked in production by Invariant 2 of the platform security model).