OSCTF

module
v0.3.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 24, 2026 License: Apache-2.0

README

OSCTF

An open, self-hostable platform for running cybersecurity competitions, labs, and training.

What makes it different: most CTF platforms hand every team the same shared challenge instance. OSCTF gives each team its own isolated container — its own port, its own network, an optional per-team unique flag — created on demand and lifecycle-managed by a built-in scheduler (TTL, extend, per-team quota, automatic teardown at event end). That per-team-instance model, not the scoreboard, is the reason to use it over a CTFd-class tool.

Per-team network isolation is enforced on Linux only — and container challenges now fail closed without it. On Docker Desktop (macOS/Windows) per-team containers are reachable across networks once a port is published, so isolation is not enforced there. OSCTF now refuses to start container instances on any daemon whose per-team isolation it cannot verify (the isolation gate, #2); the only override is OSCTF_ALLOW_UNISOLATED_INSTANCES=true, for a local trial only and logged loudly. Run real events on a Linux host. Detail: docs/v0.2/03-runtime.md.

CTFs are the entry point; the durable goal is to be the open infrastructure layer universities, communities, and companies build their security education on. Vision and roadmap: docs/project-desc.md. One direction on that roadmap is AI-security challenges — challenges whose target is a live LLM agent rather than a binary or web app, attacked over multiple turns (roadmap, not shipped: docs/ai-challenges.md).

How it works

One Go binary — a modular monolith — serves the JSON API and the embedded React dashboard, with Postgres as the source of truth, Redis for ephemeral state (sessions, rate limits, the board cache), MinIO for attachments, the host Docker daemon for per-team containers, and 0..N out-of-process plugins over gRPC. Read each top-to-bottom.

The editable docs/architecture/ canvases are the source of truth. The PNGs below are periodic exports that lag their source between renders — each caption stamps the commit it was exported from, so drift is visible rather than silent. If a PNG and its canvas disagree, the canvas wins. Sources are verified at 92c5755.

Source: 00-overview.excalidraw · PNG exported at 92c5755 · source verified at 92c5755

Request flows — flag submission, scoreboard read, per-team instances, plugin lifecycle (click to expand)

Flag submission. A plugin challenge-type's verdict is computed before the transaction — a plugin call never happens inside the row lock. Inside SELECT … FOR UPDATE the deleted/swapped checks run before the solved/attempt checks, so a challenge changed mid-submit costs no attempt. The point value is recorded post-commit; the event-bus and notification tails are async and can never fail the solve.

Flag submission flow

Source: 01-flow-submission.excalidraw · PNG exported at 92c5755 · source verified at 92c5755

Scoreboard read. Read-repair makes served == the solve log by construction rather than by timing. Plugin scores are locked at solve and read from a per-solve record, so the served board equals a from-scratch recompute over the log even with every plugin down; a background worker backfills missing/pending records off the read path.

Scoreboard read flow

Source: 02-flow-scoreboard.excalidraw · PNG exported at 92c5755 · source verified at 92c5755

Per-team instance lifecycle — the reason to use OSCTF over a CTFd-class tool. Per-team locking, quota, DB-arbitrated port allocation, container hardening, and background sweeps (expiry, reap, reconcile) that all take the same per-team lock.

Per-team instance lifecycle flow

Source: 03-flow-instance.excalidraw · PNG exported at 92c5755 · source verified at 92c5755

Plugin lifecycle. Boot runs in a goroutine and never gates HTTP serving; an 8-state supervisor with guarded transitions; register-on-ready / revert-before-death; a two-level in-flight budget; and an ordered shutdown (HTTP drain → plugin drain → background workers) under one shared budget.

Plugin lifecycle flow

Source: 04-flow-plugin.excalidraw · PNG exported at 92c5755 · source verified at 92c5755

Quick start

git clone https://github.com/swayyaam/OSCTF && cd platform
cp .env.example .env

# Change OSCTF_ADMIN_PASSWORD (default: change-me-now) before exposing this to anyone.

# On Linux, give the platform access to the host Docker socket group, or every
# container challenge fails at instance-start time. (Docker Desktop doesn't need this
# GID — but it can't isolate per-team networks, so container challenges are refused
# there by default regardless; see the note above.)
echo "OSCTF_DOCKER_GID=$(stat -c '%g' /var/run/docker.sock)" >> .env

docker compose up -d --build --wait

Then open http://localhost:8080: authentication, teams, a challenge board with seeded examples, flag submission with scoring, a live scoreboard, and an admin panel. No cloud account, no license key, no external services.

The platform mounts the host Docker socket to launch challenge containers, which is root-equivalent on the host. Run events on a dedicated host/VM. See docs/v0.1/08-challenge-runtime.md.

The security posture — the adversaries, what each can reach, and what is defended vs. explicitly accepted — is in THREAT_MODEL.md; report vulnerabilities via SECURITY.md.

Running a real event

The quickstart is fine for a local trial; before an actual event, read docs/v0.1/10-deployment.md. The notes people most often miss:

  • Linux host — for the per-team isolation above, and set OSCTF_DOCKER_GID to the socket's group.
  • Large scoreboards — each live scoreboard WebSocket is a file descriptor; OSCTF_WS_MAX_CONNS is clamped to RLIMIT_NOFILE, so raise the host ulimit (e.g. LimitNOFILE=65536) and size the cap accordingly for a few thousand viewers.
  • Shared-NAT venues — the per-IP register/login limits default generous so a campus or venue behind one NAT can all sign in at event start; tighten OSCTF_REGISTER_IP_* / OSCTF_LOGIN_IP_* for a public-internet deployment.
  • Instance tuningOSCTF_INSTANCE_TTL / _EXTEND / _MAX_TTL / _REAP_AFTER, OSCTF_TEAM_INSTANCE_QUOTA, OSCTF_PORT_RANGE_START / _END (default 3000032767, which must be open on the host). All settings are documented in .env.example.

Local development

make setup      # install pinned tools + dashboard deps
make dev        # start Postgres, Redis, MinIO (compose)
make dev-api    # run the Go API on :8080
make dev-web    # run the Vite dev server on :5173 (proxies /api -> :8080)

Tests

make test              # unit (Go -short + web)
make test-integration  # integration (testcontainers spin up Postgres/Redis/MinIO)
make smoke             # build the stack, run the end-to-end smoke test, tear down

The testing tiers, build tags, and the invariants they pin are described in AGENTS.md; CI runs all of it (plus a compose smoke and Playwright e2e) on every push.

Layout

Path What
api/ Go backend (modular monolith): HTTP, services, stores, challenge runtime, scheduler
dashboard/ React + TypeScript SPA (Vite)
examples/ Seeded example challenges (challenge.yaml format)
deploy/ Prometheus / Grafana / Caddy configs (optional compose profiles)
docs/ Versioned build specs (v0.1v1.0) + guides — start at docs/README.md
scripts/ Smoke test and dev helpers

Status

Latest release: see Releases and the CHANGELOG. Shipped: v0.1 (MVP) → v0.2 (per-team instances + scheduler), hardened across v0.2.1 (security), v0.2.2 (concurrency), and v0.2.3 (scoreboard consistency by construction).

Shipped — v0.3 (plugin-first): the canonical /api/v1 surface (with /api/v0 kept as a deprecated alias), scoped API tokens, and the out-of-process plugin system — a loader with an eight-state supervisor and ordered drain, a versioned gRPC ABI, and four extensible types (auth, scoring, notification, challenge type) each proven by a reference plugin in plugins/. An auth plugin asserts an identity; the core decides what it may mean, and enforces that field by field. Specs: docs/v0.3/.

Next — v0.3.1: the osctf CLI and an MCP server over API v1. There are no API stability promises before v1.0.

On the roadmap — AI-security challenges (design, not built). A challenge family where the target is a live LLM agent — a system prompt, optional tools, an optional retrieval corpus — that a competitor attacks over multiple turns (prompt injection, indirect injection, tool abuse, guardrail bypass, system-prompt extraction) instead of submitting a static flag. It is a ChallengeType plugin, not a core change. The design, the scoring model (deterministic vs graded, and why deterministic is preferred), the cost and isolation limits, and the ABI extensions it requires are in docs/ai-challenges.md. It is a design, not a feature.

License

Apache License 2.0. Contributions are accepted under the same license (see NOTICE).

Directories

Path Synopsis
cmd
platform command
Command platform is the OSCTF server binary.
Command platform is the OSCTF server binary.
internal
apigen
Package apigen provides primitives to interact with the openapi HTTP API.
Package apigen provides primitives to interact with the openapi HTTP API.
apperr
Package apperr defines the domain error vocabulary shared by every service.
Package apperr defines the domain error vocabulary shared by every service.
audit
Package audit records admin-relevant actions to the audit_log table.
Package audit records admin-relevant actions to the audit_log table.
auth
Package auth owns credentials and sessions: argon2id password hashing (PHC strings), the AuthProvider interface with the v0.1 email+password implementation, Redis-backed sessions, and the request identity context.
Package auth owns credentials and sessions: argon2id password hashing (PHC strings), the AuthProvider interface with the v0.1 email+password implementation, Redis-backed sessions, and the request identity context.
challenges
Package challenges is the challenge domain service: admin CRUD (with the admin/participant split enforced at the handler layer), attachment storage, and the participant-facing board with visibility and phase gating.
Package challenges is the challenge domain service: admin CRUD (with the admin/participant split enforced at the handler layer), attachment storage, and the participant-facing board with visibility and phase gating.
clock
Package clock provides an injectable time source so time-based decisions (event window, freeze) are testable without sleeping (docs/v0.1/03-tech-stack.md).
Package clock provides an injectable time source so time-based decisions (event window, freeze) are testable without sleeping (docs/v0.1/03-tech-stack.md).
config
Package config parses the process environment into one typed Config struct.
Package config parses the process environment into one typed Config struct.
db
Package db owns the pgx connection pool and the goose migration runner.
Package db owns the pgx connection pool and the goose migration runner.
db/migrations
Package migrations embeds the goose SQL migrations so they ship inside the binary and run on boot without a migrations directory on disk.
Package migrations embeds the goose SQL migrations so they ship inside the binary and run on boot without a migrations directory on disk.
events
Package events is the single-event domain service: reads, admin updates with window validation, phase/freeze computation against an injected clock, and first-boot default-event seeding.
Package events is the single-event domain service: reads, admin updates with window validation, phase/freeze computation against an injected clock, and first-boot default-event seeding.
fdbudget
Package fdbudget apportions a process's file-descriptor limit among the consumers that hold an fd per unit of concurrent work: live WebSocket connections and in-flight plugin calls (each pins its inbound request fd for the call's duration).
Package fdbudget apportions a process's file-descriptor limit among the consumers that hold an fd per unit of concurrent work: live WebSocket connections and in-flight plugin calls (each pins its inbound request fd for the call's duration).
flags
Package flags generates per-instance dynamic flags.
Package flags generates per-instance dynamic flags.
handlers
Package handlers implements the generated strict-server interface.
Package handlers implements the generated strict-server interface.
httpserver
Package httpserver assembles the chi router: middleware stack, operational endpoints (/healthz, /readyz, /metrics), the mounted /api/v0 handler, and the embedded SPA fallback.
Package httpserver assembles the chi router: middleware stack, operational endpoints (/healthz, /readyz, /metrics), the mounted /api/v0 handler, and the embedded SPA fallback.
httpx
Package httpx holds transport helpers shared across the HTTP layer: request-ID context plumbing, JSON writing, and the single problem+json error translator.
Package httpx holds transport helpers shared across the HTTP layer: request-ID context plumbing, JSON writing, and the single problem+json error translator.
metrics
Package metrics owns the Prometheus registry and the platform's custom metrics.
Package metrics owns the Prometheus registry and the platform's custom metrics.
pagination
Package pagination normalizes page/per_page query params into limit/offset.
Package pagination normalizes page/per_page query params into limit/offset.
plugin
Package plugin is the host side of the OSCTF plugin ABI: discovery, the manifest, launching and supervising plugin processes, the in-flight budget, and the registry wiring.
Package plugin is the host side of the OSCTF plugin ABI: discovery, the manifest, launching and supervising plugin processes, the in-flight budget, and the registry wiring.
plugin/plugintest
Package plugintest provides the hostile plugin doubles and the build/dial harness they are exercised through.
Package plugintest provides the hostile plugin doubles and the build/dial harness they are exercised through.
plugin/plugintest/doubles/configecho command
Double: reads its config through the PUBLIC sdk.Config() and reflects it in Value — so the host→plugin config path (OSCTF_PLUGIN_CONFIG env → sdk.Config) can be asserted end to end.
Double: reads its config through the PUBLIC sdk.Config() and reflects it in Value — so the host→plugin config path (OSCTF_PLUGIN_CONFIG env → sdk.Config) can be asserted end to end.
plugin/plugintest/doubles/crashafter command
Double: CRASHES AFTER SERVING — handshakes and answers Info, then exits non-zero on the first Value call.
Double: CRASHES AFTER SERVING — handshakes and answers Info, then exits non-zero on the first Value call.
plugin/plugintest/doubles/crashlaunch command
Double: CRASH ON LAUNCH — exits non-zero before serving, every time.
Double: CRASH ON LAUNCH — exits non-zero before serving, every time.
plugin/plugintest/doubles/goodscore command
Double: well-behaved baseline.
Double: well-behaved baseline.
plugin/plugintest/doubles/hang command
Double: HANG — Value never returns (and ignores the context).
Double: HANG — Value never returns (and ignores the context).
plugin/plugintest/doubles/ignoreshutdown command
Double: IGNORES SHUTDOWN — serves correctly but traps and ignores SIGINT/SIGTERM, so a graceful stop does not make it exit.
Double: IGNORES SHUTDOWN — serves correctly but traps and ignores SIGINT/SIGTERM, so a graceful stop does not make it exit.
plugin/plugintest/doubles/logecho command
Double: logs via the PUBLIC sdk.Log() on each call, so the plugin→host log path (over go-plugin's stderr channel into the host Logger) can be asserted end to end.
Double: logs via the PUBLIC sdk.Log() on each call, so the plugin→host log path (over go-plugin's stderr channel into the host Logger) can be asserted end to end.
plugin/plugintest/doubles/malformed command
Double: MALFORMED — serves Info fine but returns a gRPC error status on Value (and an out-of-contract Info name mismatch is available via NAME).
Double: MALFORMED — serves Info fine but returns a gRPC error status on Value (and an out-of-contract Info name mismatch is available via NAME).
plugin/plugintest/doubles/nohandshake command
Double: NEVER HANDSHAKES — a valid executable that starts and then blocks forever without calling plugin.Serve, so the go-plugin handshake never completes and the loader's launch is stuck until its StartTimeout.
Double: NEVER HANDSHAKES — a valid executable that starts and then blocks forever without calling plugin.Serve, so the go-plugin handshake never completes and the loader's launch is stuck until its StartTimeout.
plugin/plugintest/doubles/slow command
Double: SLOW — responds correctly, every time, in 4 seconds, and deliberately IGNORES the request context.
Double: SLOW — responds correctly, every time, in 4 seconds, and deliberately IGNORES the request context.
plugin/plugintest/doubles/slowcoop command
Double: SLOW BUT COOPERATIVE — a long call like `slow`, except it HONORS ctx cancellation: on cancel it returns promptly with codes.Canceled instead of running to completion.
Double: SLOW BUT COOPERATIVE — a long call like `slow`, except it HONORS ctx cancellation: on cancel it returns promptly with codes.Canceled instead of running to completion.
plugin/plugintest/doubles/slowshutdown command
Double: SLOW ONLY ON SHUTDOWN — responds normally, but on a stop signal takes far longer than the 30s drain window to exit.
Double: SLOW ONLY ON SHUTDOWN — responds normally, but on a stop signal takes far longer than the 30s drain window to exit.
plugin/plugintest/doubles/wrongabi command
Double: WRONG ABI MAJOR — serves correctly but with a go-plugin ProtocolVersion the host does not speak.
Double: WRONG ABI MAJOR — serves correctly but with a go-plugin ProtocolVersion the host does not speak.
redisx
Package redisx sets up the shared go-redis client.
Package redisx sets up the shared go-redis client.
runtime
Package runtime manages challenge workload containers behind the ChallengeRuntime interface.
Package runtime manages challenge workload containers behind the ChallengeRuntime interface.
scheduler
Package scheduler owns the lifecycle of per-team challenge instances: spawn on demand (with quota + flag + TTL), extend, stop, expire on a TTL, and clean up at event end.
Package scheduler owns the lifecycle of per-team challenge instances: spawn on demand (with quota + flag + TTL), extend, stop, expire on a TTL, and clean up at event end.
scoreboard
Package scoreboard computes standings from the ground truth (recompute-from- scratch, not incremental), caches the snapshot in Redis, and manages the freeze snapshot.
Package scoreboard computes standings from the ground truth (recompute-from- scratch, not incremental), caches the snapshot in Redis, and manages the freeze snapshot.
scoring
Package scoring computes challenge point values.
Package scoring computes challenge point values.
seed
Package seed performs idempotent first-boot seeding: the admin account, the default event (M4), and the example challenges (M10).
Package seed performs idempotent first-boot seeding: the admin account, the default event (M4), and the example challenges (M10).
storage
Package storage persists challenge attachments and future blobs behind the ObjectStore interface.
Package storage persists challenge attachments and future blobs behind the ObjectStore interface.
submissions
Package submissions owns the flag-submission hot path: the single-transaction flow that locks the challenge, enforces solve/attempt rules, compares the flag in constant time, and always logs the attempt (docs/v0.1/01-architecture.md).
Package submissions owns the flag-submission hot path: the single-transaction flow that locks the challenge, enforces solve/attempt rules, compares the flag in constant time, and always logs the attempt (docs/v0.1/01-architecture.md).
teams
Package teams is the team domain service: creation, membership, captain transfer, and the public team pages.
Package teams is the team domain service: creation, membership, captain transfer, and the public team pages.
testsupport
Package testsupport spins up ephemeral Postgres and Redis via testcontainers for integration tests.
Package testsupport spins up ephemeral Postgres and Redis via testcontainers for integration tests.
users
Package users is the user-account domain service: registration validation, profile reads, and password changes.
Package users is the user-account domain service: registration validation, profile reads, and password changes.
version
Package version exposes the build version, set via -ldflags at build time.
Package version exposes the build version, set via -ldflags at build time.
webdist
Package webdist serves the React SPA.
Package webdist serves the React SPA.
ws
Package ws is the WebSocket hub for live scoreboard updates: a connection registry, throttled latest-wins broadcast, ping/pong keepalive, and graceful drain on shutdown.
Package ws is the WebSocket hub for live scoreboard updates: a connection registry, throttled latest-wins broadcast, ping/pong keepalive, and graceful drain on shutdown.
plugin
abi
Package abi is the OSCTF plugin ABI surface SHARED by the host and by plugin authors: the go-plugin handshake, the ABI version, the dispense keys, and the gRPC transport bridge to the generated stubs (pluginpb).
Package abi is the OSCTF plugin ABI surface SHARED by the host and by plugin authors: the go-plugin handshake, the ABI version, the dispense keys, and the gRPC transport bridge to the generated stubs (pluginpb).
eventkeys
Package eventkeys is the SHARED definition of the event names core publishes and the Data keys each one carries — the contract a notification plugin depends on.
Package eventkeys is the SHARED definition of the event names core publishes and the Data keys each one carries — the contract a notification plugin depends on.
sdk
Package sdk is the public, importable surface for writing an OSCTF plugin.
Package sdk is the public, importable surface for writing an OSCTF plugin.
sdk/contract
Package contract lets a plugin author verify a built plugin satisfies the OSCTF contract WITHOUT the monorepo — it dials the plugin exactly as the host does, wrapped so no wire type is exposed.
Package contract lets a plugin author verify a built plugin satisfies the OSCTF contract WITHOUT the monorepo — it dials the plugin exactly as the host does, wrapped so no wire type is exposed.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL