Skip to content

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.pyderive_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):

  1. Redis HGET team_flags:{principal_id} {challenge_id} — returned if present.
  2. TeamFlag database row — returned if present.
  3. 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.