Flag Derivation¶
CTFHive uses a deterministic, per-principal flag derivation scheme based on HMAC-SHA3-256. Every player or team receives a unique flag for each challenge, derived from server-side secrets that no participant can access. This is the cryptographic foundation of CTFHive's anti-cheat system.
Correction: CLAUDE.md documents the wrong algorithm
CLAUDE.md describes the flag as
FORGE{hmac_sha256(server_secret, f'{principal_id}|{chal_id}|{base}')[:24]}.
That description is wrong in three ways: it uses SHA-256 instead of
SHA3-256, it keys on principal_id (an integer) instead of team_secret
(32 bytes of entropy), and it truncates to 24 hex characters instead of 32.
The authoritative implementation is ctfapp/services/flag_engine.py;
ctfapp/flag_engine.py is a re-export shim that forwards to the service
module.
Algorithm¶
Canonical source: ctfapp/services/flag_engine.py — derive_flag()
import hmac, hashlib
def derive_flag(admin_key: bytes, team_secret: bytes, challenge_id: int,
prefix: str | None = None) -> str:
admin_key = _admin_key_bytes(admin_key) # str → bytes if needed
msg = team_secret + challenge_id.to_bytes(4, "big")
digest = hmac.new(admin_key, msg, hashlib.sha3_256).hexdigest()
resolved_prefix = prefix or _default_flag_prefix()
return f"{resolved_prefix}{{{digest[:32]}}}"
Step-by-step¶
| Step | Detail |
|---|---|
| 1. Build the message | Concatenate the principal's team_secret (raw bytes, 32 B) with challenge_id encoded as a 4-byte big-endian unsigned integer. |
| 2. HMAC-SHA3-256 | Compute HMAC-SHA3-256(key=ADMIN_KEY, msg=team_secret ‖ challenge_id_be4). |
| 3. Hex-encode and truncate | Call .hexdigest() → 64 hex chars. Take the first 32 characters. |
| 4. Wrap with prefix | Produce {PREFIX}{first_32_hex_chars}, e.g. GRIZZ{a3f7...}. |
Inputs¶
| Input | Type | Source | Notes |
|---|---|---|---|
admin_key |
bytes |
app.config["ADMIN_KEY"] (ADMIN_KEY env var) |
Server secret; never exposed. Changing it invalidates all existing derived flags. |
team_secret |
bytes |
Principal.team_secret (32 random bytes) |
Generated with os.urandom(32) at principal creation. Stored encrypted (EncryptedBinary) in the principals table. Never returned by any API. |
challenge_id |
int |
Challenge.id (database primary key) |
Encoded as 4 bytes, big-endian. |
prefix |
str \| None |
Challenge.flag_prefix or DEFAULT_FLAG_PREFIX env var |
Defaults to GRIZZ. Per-challenge overrides are respected. |
Output example (shape only)¶
GRIZZ{a3f7b2c1d4e5f6a7b8c9d0e1f2a3b4c5}
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
first 32 hex chars of HMAC-SHA3-256
The full digest is 64 hex chars (256 bits); only the first 32 (128 bits) are
exposed in the flag, providing 2^128 preimage resistance for a brute-force
attacker who does not know ADMIN_KEY or team_secret.
Why per-principal isolation matters¶
Each principal (team or solo player) has its own team_secret. Because the
HMAC key is ADMIN_KEY and the message includes team_secret, a flag for
principal A is cryptographically independent of the flag for principal B,
even for the same challenge. A player who leaks their flag cannot help a
teammate submit the same string — it will be rejected outright.
The submit-time cross-team detection layer in anti-cheat exploits this: when a submission is rejected, the engine re-derives the expected flag for every other principal and checks for an exact match, providing hard evidence of flag sharing.
Storage and caching¶
Pre-generated flags are stored in two places to avoid re-deriving on every submission:
Redis HASH → team_flags:{principal_id} → {challenge_id: flag_value}
(fast path, optional)
PostgreSQL → team_flags table → flag_value column (EncryptedString)
(source of truth, always present)
Lookup order (get_flag_for_principal):
- Redis
HGET team_flags:{principal_id} {challenge_id}— returned if present. TeamFlagdatabase row — returned if present.- Re-derive on the fly via
derive_flag_for_principal().
Redis is treated as a performance cache. If Redis is unavailable, the application falls back to the database and re-derives without error.
Flags are pre-generated in two batch scenarios:
- New principal (
generate_flags_for_principal): derives flags for all currently visible challenges. - New challenge (
generate_flags_for_challenge): derives flags for all active principals.
Constant-time verification¶
def verify_flag(submitted: str, admin_key: bytes, team_secret: bytes,
challenge_id: int, prefix: str | None = None) -> bool:
expected = derive_flag(admin_key, team_secret, challenge_id, prefix=prefix)
return hmac.compare_digest(
submitted.strip().encode("utf-8"),
expected.encode("utf-8"),
)
hmac.compare_digest performs a constant-time string comparison, preventing
timing-based oracle attacks that could otherwise be used to partially reconstruct
the expected flag character by character.
Dynamic challenge instances¶
For lab/container challenges, derive_instance_flag(challenge, principal)
wraps derive_flag() and returns the same value that is injected as the FLAG
environment variable into the container. The derivation is identical — no
separate mechanism is used for dynamic flags.
Security considerations¶
| Threat | Mitigation |
|---|---|
| Flag sharing between principals | Per-principal team_secret makes flags cryptographically distinct. |
| Brute-force flag generation | Attacker needs both ADMIN_KEY and the target's team_secret; neither is reachable without server compromise. |
| Timing oracle on submission | hmac.compare_digest eliminates timing side-channel. |
| Static flag in challenge YAML | Importer wraps it through derivation at deploy time; static strings are not stored verbatim. |
| ADMIN_KEY rotation | Rotating ADMIN_KEY invalidates all previously derived and cached flags. Plan a maintenance window. |
Key rotation impact
Changing ADMIN_KEY invalidates every flag every principal has ever been
shown. Redis cache entries will be stale; TeamFlag rows will produce wrong
answers. Flush Redis (FLUSHDB on the flags DB) and delete all TeamFlag
rows, then trigger generate_flags_for_principal for every active principal
before reopening the event.