Challenge Build Pipeline
GZ::CTF (this fork) can build a challenge's container image from source at import time — you ship a Dockerfile in the challenge package and the platform produces a tagged, ready-to-run image on the local Docker daemon. No registry round-trip, no manual docker build, no hand-copied image tags in challenge.yaml.
This is exclusive to this fork. Upstream GZ::CTF expects you to publish an image to a registry yourself and reference it by tag; here, omitting containerImage (or pointing it at a local path) is enough.
This page covers how images are auto-built, the build queue and job lifecycle, the review gate that defers untrusted code, and the admin build-audit surface.
See also: /guide/authoring/challenge-yaml for the package schema and /guide/authoring/repo-bindings for syncing challenges straight from a Git repo.
When a build fires
Every challenge import (tarball upload, GitHub import, or repo-binding scan) parses each challenge.yaml, then asks one question per container challenge: does this imply an image build? The decision is ResolveBuildIntent in ChallengeImportService, and it runs in this order:
- Not a container type → no build (
BuildStatus = None). containerImageis a local path (./src,./Dockerfile,./src/Dockerfile, a bareDockerfile, or anything ending in/Dockerfile) → resolve the build context and build it.containerImageis a gzcli template placeholder (it contains{{, e.g."{{.slug}}:latest") → look for a Dockerfile in the conventional spot and build it.containerImageis omitted/empty but a Dockerfile exists in the conventional spot → build it anyway (you clearly meant to ship a container; the platform fills in the tag).containerImageis a registry-style ref (nginx:alpine,ghcr.io/foo/bar:tag) → no build, the runner pulls it as-is (BuildStatus = NotApplicable).
The convention search
For cases 3 and 4 (template placeholder or empty image), the platform searches for a Dockerfile in this exact order, relative to the directory that holds challenge.yaml:
./src/Dockerfile wins when both exist. If neither is found and the image was a template placeholder, the challenge lands in MissingDockerfile with a diagnostic; if the image was simply empty, it stays None (manual, no auto-build).
When containerImage is an explicit local path, ResolveBuildContext maps it to a (context, Dockerfile) pair:
The build context is always confined to the challenge package directory. A declared path that resolves outside the package (e.g. ../../etc) is rejected with build context path '…' escapes the challenge package. Symlinks in the context are skipped when the snapshot is copied, so a link to the host kubeconfig, A&D flags, or WireGuard keys can't be baked into an attacker's image.
A minimal buildable package
You can also be explicit, or use the gzcli template style (both build the same src/Dockerfile):
The deterministic gzctf-auto/ image tag
The Docker builder (DockerChallengeImageBuilder) tar+gzips the build context and computes a SHA-256 over the compressed bytes. The image tag is derived from that hash:
slugis the challenge title normalized to lowercase, non-alphanumerics collapsed to-(e.g.Tower of Babel→tower-of-babel).- The 12-hex-char suffix is the content digest.
Because the tag is content-addressed, re-importing identical source reuses the existing image instead of rebuilding. Before invoking docker build, the builder inspects the local tag; if it already exists, the build is skipped and the result is reported as (cached).
Edit any file in the build context and the digest changes, so the next import produces a new tag and a fresh build. No source change means no rebuild — this is why a repo-binding scan that finds no diffs is cheap.
Built images are labelled org.gzctf.keep=true so a host-side docker image prune -af --filter label!=org.gzctf.keep=true won't delete them — important for A&D checker images that have no long-running container holding a reference.
After a successful build, the builder does best-effort cleanup: it untags older gzctf-auto/{gameId}/{slug}:* tags (previous content SHAs from earlier edits), then prunes dangling images. A cleanup failure never flips a successful build to failed.
The review gate: who gets auto-built
This is the safety boundary. Attacker-controlled Dockerfiles do not run on the shared build host until an admin has reviewed them.
The import API carries an AutoApprove flag (ChallengeImportOptions). Auto-build only fires when AutoApprove == true:
For a public submission (AutoApprove == false), the challenge is imported with ReviewStatus = Pending; the build intent is computed and the row reflects it, but no job is enqueued. The system logs Challenge build deferred (pending review) and the unreviewed Dockerfile is never executed. An admin builds it later via the per-challenge rebuild flow once they approve it.
Public submissions also pass a shape check (ValidateSubmissionShape) before reaching the review queue — they must include a non-empty solver/ file, declare a flag source (flags: or flagTemplate:, unless the type uses the A&D engine), and (for container types) have a buildable Dockerfile in the conventional spot (./src/Dockerfile or ./Dockerfile) when a build is implied by a local path or a {{…}} template image. The check only fails when a build is intended but no Dockerfile is found; an explicit registry image — or an empty image with no Dockerfile (which resolves to None, a non-build challenge) — passes through. Admin and binding imports skip this check (trusted).
The gate is purely about building. The path-traversal guards, the 1 GB extracted-size cap (decompression-bomb defense), and the symlink-skip in snapshot copy apply to every import path regardless of AutoApprove. The gate adds the rule that running a user-supplied Dockerfile waits for human review.
Build queue and job lifecycle
When an auto-build is warranted, the importer doesn't build inline. It snapshots the context to a fresh temp dir (/tmp/gzctf-build-<guid>, owned by the worker for cleanup) and enqueues a ChallengeBuildJob onto a bounded in-memory channel. Enqueue returns one of:
The status flip to Queued happens only after the queue accepts the job, so a row never sits in Queued without an actual job behind it. The dedup (AlreadyPending) is what stops a double-click on Rebuild — or a re-import while a build runs — from producing two Docker builds and two audit rows.
ChallengeBuildQueueService is a background service with a fixed pool of 2 workers draining the channel. Per job:
- Mark the challenge
Buildingand create theChallengeBuildAuditrow up front (so both the live strip and the history table have something to render). - Call the builder, streaming output. Each output line is secret-scrubbed and appended to a buffer; roughly every 2 seconds the current tail is flushed to
Challenge.LastBuildLogso the admin modal can watch the log live. - On completion, write the terminal status, duration, log tail, and digest to both the audit row and the challenge.
Status transitions
The full set of BuildStatus values surfaced on a challenge:
On success the challenge's ContainerImage is overwritten with the built tag (the gzctf-auto/... local tag, or the registry tag if a push target is configured) and BuildImageDigest is set.
Retries and timeouts
- The Docker build has a 5-minute timeout; a registry push has a 10-minute timeout.
- Transient failures — daemon-connection refused, connection reset, I/O timeout, EOF mid-build, 502/503/504, "temporarily unavailable" — retry up to 3 attempts with 10s / 30s / 90s backoff. During the backoff the challenge stays
Building(no red flicker) and stays in the dedup set so a concurrent manual build can't slip in. - Non-transient failures (Dockerfile syntax, missing base image, unauthorized) fail immediately on attempt 1 — no retry storm.
Crash recovery
On app startup the service flips any row left in Building or Queued from a previous process lifetime to Failed with the message Build interrupted by app restart., and writes a matching audit row. It also sweeps /tmp/gzctf-build-* snapshot dirs older than 1 hour. Nothing stays stuck on a yellow "Building" badge after a crash.
Secret scrubbing
Build output is visible to admins via Challenge.LastBuildLog and ChallengeBuildAudit.LogTail. Before any line is buffered or flushed, ScrubSecrets redacts token-shaped strings to ***SCRUBBED***:
False positives are acceptable — a scrubbed log beats a leaked token. Log tails are also capped at 32 KiB.
Optional registry push
By default, built images stay on the local daemon — fine for a single-host setup where the runner shares that daemon. If your runner pulls from a registry (e.g. a Kubernetes cluster), configure BuildRegistryConfig so the builder retags and pushes after each successful build:
The pushed tag is {Server}/{Namespace?}/gzctf-auto/{gameId}/{slug}:{sha}. When a push is configured, the challenge's ContainerImage is set to the registry tag (that's what the runner pulls). A push failure (DNS, auth denied, timeout) flips the build to Failed with the reason in the log. See /config/appsettings for where this config lives.
Docker vs Kubernetes builders
Under the Kubernetes container provider, an auto-build fails immediately with:
On Kubernetes, don't rely on auto-build. Publish your image to a registry the cluster can pull from and set containerImage to that registry reference (case 5 above), which is NotApplicable for the build pipeline and pulled as-is.
Admin build observability
The admin Builds surface (under /admin/builds, served by AdminController) is the audit trail for every build. All endpoints require admin.
History and live state
GET /api/Admin/Builds returns rows with: challenge id/title, game id, enqueued/started/finished timestamps, Trigger, Attempt, Status, content Digest, the scrubbed LogTail, ErrorMessage, and DurationMs.
GET /api/Admin/Builds/InProgress returns the live strip: audit id, challenge id, game id, slug, attempt, Trigger, and StartedAtUtc for each running build (used to render elapsed times).
The Trigger distinguishes how each build started:
Triggering and managing builds
History cleanup and disk hygiene:
PruneImages builds its keep-set from current ContainerImage values (keeping both the registry-prefixed form and the bare local gzctf-auto/ form, so the deterministic cache path survives), and only removes images whose tags are all unreferenced gzctf-auto/* tags.
The per-challenge Rebuild button (POST /api/edit/games/{id}/challenges/{cId}/rebuild) re-extracts the challenge's stored archive (or re-fetches from its parent repo binding), snapshots, and enqueues with Trigger = Manual. This is the same flow Reenqueue and approval-after-review use, so all the dedup and fallback logic lives in one place. This is also how an admin builds a previously-deferred public submission after approving it.
Quick reference
- Build target? Omit
containerImage, point it at a local path, or use a{{…}}placeholder. A registry ref means "pull, don't build." - Where the Dockerfile goes:
./src/Dockerfile(preferred) or./Dockerfile. - Tag:
gzctf-auto/{gameId}/{slug}:{sha12}, content-addressed and cached. - Trusted (admin/GitHub/binding) imports build immediately; public submissions defer until approval.
- Kubernetes runtime: auto-build is unsupported — pre-publish to a registry.
Next: /guide/authoring/challenge-yaml · /guide/authoring/repo-bindings