To create a CTF challenge, pick one technique you want players to learn, design a single intended solve path with no guessing, and place it on your event's difficulty curve. Then write a clear description, package the service in a hardened Docker container, automate a solve script and have someone else playtest it before launch.
One challenge, followed through every stage. The examples below all build the same easy web challenge, web-notes: a notes API that returns any note by its numeric ID without checking who owns it.
- Idea: one bug class (broken object-level authorization, or IDOR) and one lesson.
- Tier: easy, aimed at 40 to 70 percent of active teams.
- Flag:
CTF7{idor_notes_…}, derived per team with an HMAC. - Container: a small Flask app on a pinned base image, running as a non-root user.
- Testing: a short
solve.pyin CI, then a blind playtest by someone who did not write it. - AI pass: a reviewer tries the player-facing package with public assistants and records the result before the tier is final.
CTF challenge design principles
Great challenges feel hard while you're stuck and obvious once you've solved them. Bad ones feel arbitrary. The difference usually comes down to a few principles.
1. One idea per challenge
Decide the single concept the challenge teaches: a JWT alg confusion (the JWT decoder shows what a player sees), a format-string leak, a padding oracle, a Wireshark stream reassembly. Build everything around it. Layering three unrelated tricks doesn't make a challenge harder in an interesting way. It makes it longer and more fragile. If you have three ideas, you have three challenges, or one clearly signposted multi-stage chain.
2. Design the intended path first
Write the solve as a numbered list before you write any code. Each step should give players a signal that they're on the right track: an error message, a leaked value, a changed response. Then build the vulnerable code so the intended path is the easiest path, and actively hunt for unintended shortcuts, such as a flag readable through path traversal on a web challenge meant to be about SSTI.
3. Avoid guessing
"Guessy" challenges are the top complaint in CTF feedback. Players should never have to guess:
- a hidden endpoint or file name not discoverable through normal enumeration or a hint in the description;
- a password, key or cipher parameter that isn't derivable from the data;
- the author's personal context (a pet's name, an in-joke) in an OSINT challenge;
- which of several stego tools and passwords you happened to use.
Fuzzing and brute force are fine when they're the lesson, such as a 4-digit PIN with no rate limit. Say so, and keep the search space small enough that it doesn't hammer your infrastructure.
4. Make it realistic where it matters
A web challenge that mirrors a real bug class (IDOR in an API, SSRF to a metadata endpoint, a deserialization gadget) teaches transferable skills. Realism doesn't mean complexity, though. A 40-line Flask app demonstrating a real flaw beats a 4,000-line app where the bug is hard to find because the code is noisy.
5. Keep it deterministic and robust
A challenge must behave the same for the first team and the 300th. Avoid shared mutable state that one team can break for everyone, time-sensitive tricks that depend on server clocks and exploits that only work 1 time in 50 without saying so.
Difficulty curve and target solve rates
Your challenge board is a product with a learning curve. Most events lose beginners because the first hour is too hard, and lose strong teams because the top end runs out by lunchtime. Plan the mix before anyone writes code.
| Tier | Share of board | Target solve rate* | Typical player time |
|---|---|---|---|
| Warmup / sanity | 10–15% | 70–95% | Under 15 minutes |
| Easy | 25–30% | 40–70% | 15–60 minutes |
| Medium | 30–35% | 15–40% | 1–3 hours |
| Hard | 15–20% | 3–15% | 3–8 hours |
| Insane | 0–5% | 1–3 solves | Most of the event |
*Share of active teams (those that scored at least once) that solve a given challenge in that tier. These are practical rules of thumb, not a standard. Shift the whole curve down for a college beginner event and up for an open international CTF.
- Calibrate with a multiplier. If the author solves their own challenge in 20 minutes, expect players to need several times that. Blind playtesting gives you a much better number.
- Every category needs an on-ramp. Each category should have at least one easy challenge, so a web-only team and a crypto-only team both score early.
- Use dynamic scoring so points decay as more teams solve. It corrects your difficulty estimates automatically. Try settings in the dynamic scoring calculator first. On CTF7 you can choose static or dynamic scoring per event.
- Aim for no unsolved challenges. A challenge nobody solves taught nobody anything. If an insane challenge has zero solves at the halfway mark, consider releasing a hint.
CTF challenge ideas by category
Pick ideas by category and level, not by cleverness: one easy on-ramp per category, then a few medium and hard challenges that reward reasoning. Each seed below gives the scenario players see and the skill it tests, and stops before the solution. The CTF categories guide explains what each category tests.
The levels: beginner is one concept in one or two steps with a browser, a terminal or a common free tool. Intermediate is two or three steps, some scripting and one moment where players connect the dots. Advanced needs multi-stage reasoning, custom tooling or specialist knowledge.
Web
- Library loan tiers (beginner): a book-lending site decides who is a premium member from a value the browser stores. Tests client-side versus server-side trust.
- Fest pass portal (intermediate): digital passes are numbered in order, and the download page never asks whose pass it is. Tests broken access control (IDOR).
- The last seat (advanced): a booking flow checks availability and books the seat in two separate steps. Tests race conditions.
Cryptography
- Postcards from Rome (beginner): a travel blogger hides messages to a friend under a classical shift cipher. Tests classical ciphers and key space.
- Same key, different day (intermediate): two intercepted messages were encrypted with the same stream key. Tests why key reuse breaks stream ciphers.
- Lucky draw (advanced): an online raffle seeds its random number generator from something players can observe. Tests predictable randomness.
Forensics
- Holiday snapshot (beginner): a staged photo whose embedded metadata records more than the sender meant to share. Tests image and document metadata.
- Chatty printer (intermediate): a capture of an office network where one device sends a document over a cleartext protocol. Tests protocol analysis and stream reassembly.
- Ghost process (advanced): a memory image from a workstation after a fictional malware infection. Tests memory forensics.
Reverse engineering and pwn
- Vending machine (beginner): a tiny program asks for a service code and leaves its secret readable inside the file. Tests extracting strings from a binary.
- Name tag printer (beginner): a visitor-badge program stores the name right next to a "staff" setting in memory. Tests memory layout and buffer bounds.
- Toy CPU (advanced): the real check runs inside a custom virtual machine with its own instruction set. Tests VM and bytecode analysis.
OSINT, steganography and misc
- Where was this taken (beginner): a staged photo with signage and landmarks that place a fictional persona in a city. Tests image clues and geolocation basics.
- Quiet pixels (intermediate): a message hides in the lowest bits of an image, and the description names the technique. Tests bit-level image analysis.
- Python jail (advanced): a restricted interpreter blocks most built-ins and keywords. Tests language internals.
Blue team, cloud and AI
- Phish in the inbox (beginner): three emails claim to come from the dean's office, and only one does. Tests email header analysis.
- Polite chatbot (beginner): a support bot is told never to reveal a staff discount code. Tests prompt-injection concepts.
- Patient zero (advanced): logs from several fictional hosts reveal where an intrusion started. Tests incident timeline reconstruction.
Before you build a seed, check that every step can be reached by reasoning, standard enumeration or a stated hint, and that one team cannot break it for everyone else.
OSINT ethics: never build OSINT challenges around real private individuals. Create fictional personas and host the "evidence" on accounts and sites you control.
Flag formats and dynamic flags
Pick one flag format for the whole event and document it in the rules, for example CTF7{This_is_an_example_flag}. A consistent prefix lets players recognize the flag when they see it, lets you validate submissions with a regex like ^CTF7\{[A-Za-z0-9_!@#\-]{4,64}\}$ and helps you spot flags leaked in public writeups or chat.
- Avoid ambiguous characters (
l/1/I,O/0) when the flag has to be read from an image or audio. - Decide on case sensitivity and state it. Case-insensitive matching avoids frustrated players when a flag is recovered from lossy output.
- Make the flag meaningful when it's static:
CTF7{0bj3ct_l3v3l_4uth_m4tt3rs}reinforces the lesson. - Never reuse flags between challenges or events.
Static vs dynamic flags
A static flag is the same for everyone, so it can be pasted into a group chat. A dynamic flag is unique per team, so a copied flag fails and names the team it was issued to. The concept, the trade-offs and when to use each are covered in what a dynamic flag is; this section is only about building one into a challenge.
For challenges that run in containers, a common pattern is deriving each team's flag with an HMAC and injecting it when the instance starts, as an environment variable or a mounted file. How injection works depends on your platform, so check its documentation.
import hmac, hashlib
def team_flag(secret: bytes, challenge: str, team_id: str) -> str:
"""Deterministic, unguessable per-team flag."""
msg = f"{challenge}:{team_id}".encode()
tag = hmac.new(secret, msg, hashlib.sha256).hexdigest()[:16]
return f"CTF7{{idor_notes_{tag}}}"
# team_flag(b"event-secret", "web-notes", "team-42")
# -> 'CTF7{idor_notes_<16 hex chars>}'
For downloadable challenges (reverse, crypto, forensics files), dynamic flags mean generating a file per team at build time, which is more work. Many events keep those static and rely on submission monitoring instead.
Writing challenge descriptions and hints
The description is part of the challenge. It should set the scene, point to the category's starting point and give every piece of information players can't discover themselves (connection details, file formats, rules like "no brute force needed"). It should not give away the solution.
name: notes-app
category: web
difficulty: easy
author: your-handle
description: |
Our intern built a "private" notes API over the weekend.
Guests can only read their own notes... right?
Start here: http://{host}:{port}/api/notes
Flag format: CTF7{...}
hints:
- cost: 0
text: "What identifies a note in the URL?"
- cost: 50
text: "The API checks that you're logged in. Does it check who owns the note?"
files: []
tags: [idor, api, beginner]
This is a generic metadata format. Adapt the fields to your platform's challenge schema.
- Tier your hints. The first nudges direction, the second names the technique, the last is nearly a walkthrough. On CTF7, hints can cost points so teams decide when to spend them.
- Pre-write hints before the event and agree when to release them (for example, zero solves after four hours). Improvising hints under pressure leads to accidental spoilers.
- Keep lore short. Two lines of story is flavor. Two paragraphs is a reading test.
- Credit the author and list attached files with checksums so players know they downloaded the right thing.
Dockerizing CTF challenges: templates
Every service-based challenge should ship as a Docker image. It makes the challenge reproducible, portable between your laptop and the event infrastructure, and easy to reset. Below are two templates: a Flask web challenge and a pwn challenge served over TCP.
Flask web challenge
A deliberately tiny warmup with an object-level authorization bug. The flag comes from an environment variable at runtime, so the image never contains the real flag.
web-notes/
├── Dockerfile
├── requirements.txt
├── app.py
└── solve.py
# app.py
import os
from flask import Flask, abort, jsonify
app = Flask(__name__)
FLAG = os.environ.get("FLAG", "CTF7{local_test_flag}")
NOTES = {
1: {"owner": "admin", "body": FLAG},
2: {"owner": "guest", "body": "Welcome, guest! Your notes live here."},
}
@app.get("/health")
def health():
return "ok"
@app.get("/api/notes/<int:note_id>")
def get_note(note_id):
note = NOTES.get(note_id) or abort(404)
# INTENDED BUG: no check that note["owner"] matches the caller
return jsonify(note)
# requirements.txt
flask==3.0.3
gunicorn==22.0.0
# Dockerfile
FROM python:3.12-slim
# Unprivileged runtime user
RUN useradd --create-home --uid 1000 ctf
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Code owned by root, read-only for the app user
COPY --chown=root:root app.py .
USER ctf
EXPOSE 5000
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "2", \
"--timeout", "10", "app:app"]
Pin the base image and dependencies so the challenge builds identically in three months. For extra reproducibility, pin the image by digest (python:3.12-slim@sha256:…).
Pwn challenge with socat
Pwn challenges usually expose a binary over TCP. Each connection gets a fresh process. socat is the simplest way to do that.
# Dockerfile
FROM ubuntu:24.04
RUN apt-get update \
&& apt-get install -y --no-install-recommends socat \
&& rm -rf /var/lib/apt/lists/*
RUN useradd --create-home --uid 1000 ctf
WORKDIR /home/ctf
# Compiled challenge binary + flag, both owned by root
COPY --chown=root:root chall flag.txt ./
RUN chmod 755 chall && chmod 444 flag.txt
USER ctf
EXPOSE 1337
# -T60: drop idle connections after 60s; fork: one process per player
CMD ["socat", "-T60", "TCP-LISTEN:1337,reuseaddr,fork", \
"EXEC:/home/ctf/chall,stderr"]
Ship the exact chall binary and, for libc-dependent exploits, the libc.so.6 and ld from the image as downloadable files. Players need the same libc to compute offsets. Building the binary inside the image (or a pinned builder stage) guarantees the match.
Pwn challenge with xinetd
xinetd is the older, widely used alternative. It adds per-source connection limits and CPU limits in its config.
# /etc/xinetd.d/ctf
service ctf
{
disable = no
socket_type = stream
protocol = tcp
wait = no
user = ctf
type = UNLISTED
port = 1337
bind = 0.0.0.0
server = /home/ctf/chall
per_source = 10 # max concurrent connections per IP
rlimit_cpu = 20 # CPU seconds per process
rlimit_as = 512M # address-space limit per process
}
# Dockerfile additions (ubuntu base, as above)
# RUN apt-get install -y --no-install-recommends xinetd
# COPY ctf.xinetd /etc/xinetd.d/ctf
# CMD ["xinetd", "-dontfork"]
xinetd starts as root and drops to the ctf user per connection, so the container needs the SETUID and SETGID capabilities. The socat version runs entirely as an unprivileged user, which is simpler to lock down. For hard pwn challenges, nsjail-based wrappers such as redpwn/jail add stronger per-connection sandboxing. Read their docs, because they typically need elevated container privileges to set up the sandbox.
docker-compose for local testing
# docker-compose.yml
services:
web-notes:
build: ./web-notes
ports: ["8080:5000"]
environment:
FLAG: "CTF7{dev_only_flag}" # real flag injected at deploy time
read_only: true
tmpfs: ["/tmp:size=16m"]
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
pids_limit: 128
mem_limit: 256m
cpus: 0.5
restart: unless-stopped
healthcheck:
test: ["CMD", "python", "-c",
"import urllib.request; urllib.request.urlopen('http://localhost:5000/health')"]
interval: 30s
timeout: 5s
retries: 3
pwn-babyrop:
build: ./pwn-babyrop
ports: ["1337:1337"]
read_only: true
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
pids_limit: 64 # also caps concurrent connections
mem_limit: 128m
cpus: 0.5
restart: unless-stopped
Run docker compose up -d --build, then point your solve script at localhost. If it passes locally with these limits applied, it's much more likely to survive the event.
Resource limits and isolation
Players are, by design, trying to break your challenge. Some will get code execution. That's often the point. Your job is to make sure a shell inside one challenge can't take down the event, read other flags or reach the internet.
- Run as non-root and make code and flags owned by root and read-only to the runtime user, so players can't overwrite the flag or backdoor the app for other teams.
- Read-only root filesystem with a small
tmpfsfor/tmpwhen the app needs scratch space. - Drop all capabilities and set
no-new-privileges. Add back only what's needed. - Limit memory, CPU and PIDs. The PID limit stops fork bombs. Memory limits stop a heap-spray from killing the host.
- Time out connections and requests (
socat -T, gunicorn--timeout) so idle or hung sessions don't pile up. - Block outbound traffic at the host or orchestrator level (for example, a Kubernetes NetworkPolicy). This stops reverse shells to the internet and use of your infrastructure for attacks. Docker's
internal: truenetworks also block published ports, so use them for backend services such as a challenge's database. - One flag per container. Never mount a shared directory of flags into challenge containers.
- Per-team instances for challenges where players change state (RCE, database writes, admin takeover). Shared instances are fine for stateless, read-only challenges. Per-team instances cost more, so reserve them for challenges that need them.
- Auto-restart and reset. Use restart policies and a scheduled reset for shared challenges that players can degrade.
Testing and playtesting
Automated tests
Every challenge ships with a solve.py that runs the intended exploit from start to flag and exits non-zero on failure. Run it in CI on every change and on a schedule during the event against the live instance. It's the fastest way to catch a broken challenge before players do.
Two more passes complete the testing: a blind human playtest (below) and an AI assistant pass that tells you whether the challenge still fits your event's AI policy.
# solve.py (web-notes)
import re, sys, requests
base = sys.argv[1] if len(sys.argv) > 1 else "http://localhost:8080"
r = requests.get(f"{base}/api/notes/1", timeout=5)
m = re.search(r"CTF7\{[^}]+\}", r.text)
if not m:
sys.exit("[-] flag not found: challenge broken")
print(m.group(0))
Blind playtesting
- Give the challenge, exactly as players will see it, to someone who didn't build it. Don't hint. Watch where they get stuck.
- Record their solve time and compare it with your target tier.
- Ask what they tried that didn't work. Reasonable approaches that fail silently are a design smell.
- Check for unintended solutions. Playtesters find them far more often than authors do.
- Test from a fresh environment: a clean download, a new container and a different OS if files are involved.
- Run an AI pass as well, with a reviewer and public AI assistants, before the challenge is marked ready.
Load testing
For large events, hit shared services with realistic concurrency (for example, 50 simultaneous exploit runs) and confirm the limits hold. A challenge that works for one tester can fall over with 300 teams.
AI-resistant CTF challenge design
AI assistants can explain a cipher, summarize a decompiled function or draft a script in seconds, and some players run agents that work through a board with little human input. No challenge is AI-proof, and chasing that goal usually produces guessy, unfair challenges. The realistic aim is narrower: a board that still measures what your event is meant to measure, under the AI policy you've published.
There is no settled answer yet, so treat this section as a set of design levers you can combine, not a fixed recipe.
Start from your AI policy
Design choices only make sense against a rule. Decide first whether AI is allowed, restricted, not allowed or split onto separate scoreboards, and publish it before registration closes. Our CTF rules template has copy-ready clauses for each option, and should your CTF allow AI? walks through the trade-offs.
| AI policy | What the board must do | Design emphasis |
|---|---|---|
| Allowed | Reward players who use tools well, without letting easy solves decide the result | Dynamic scoring, multi-part and live challenges, fresh content |
| Restricted (assistants yes, autonomous agents no) | Keep a person in the loop on every solve | Interactive services, stages that need judgment between steps, winner verification |
| Not allowed | Produce a clean skills signal | On-site-only rounds, required writeups and verification calls for prize winners |
| Separate scoreboards | Keep both boards meaningful | The same challenges on both boards, then compare solve rates afterwards to learn which challenges AI changed |
Which challenges AI assistants solve fastest
These are rules of thumb, not guarantees. Models change between events, so test every time. The challenges most likely to fall to a single prompt are:
- Self-contained: one file or a short description holds everything needed to solve it.
- Textbook: a well-known technique with no twist, such as a classic cipher or a chain of encodings.
- Previously published: reskins of challenges whose writeups are public, which an assistant with web access can find.
- Single-step: one insight from start to flag, with nothing to observe or adapt along the way.
Challenges that tend to hold up longer need interaction with a live service whose behavior the player has to observe, span several dependent stages, sit in a realistic environment with noise to work through, or are judged on a written explanation rather than a string. Easy, self-contained challenges still have a place: they're the on-ramp for beginners, and a fast solve on a warmup costs you little.
Design levers that keep a board meaningful
- On-site-only elements. For in-person rounds, tie part of a challenge to something that exists only at the venue: a hardware kit at each table, a device on the event network, a clue printed on the badge. It can't be pasted into a chat window, which is why on-site finals are the strongest fit for a no-AI policy. See CTFs for security conferences for running on-site rounds.
- Multi-part challenges. Split a scenario into stages where each stage depends on what the player found in the last one, with a flag per stage so partial progress still scores. Players have to understand an intermediate result before they can use it, and per-stage solve rates show exactly where teams, and their tools, stall.
- Live targets over static files. A service that responds to what the player does asks for observation and iteration. A single downloadable file with one answer is the easiest thing to hand to an assistant, so keep those for warmups and teaching rounds.
- Per-team flags and parameters. Dynamic flags give each team a unique flag, so a flag from a shared chat or a solve transcript passed between teams is worthless and traceable. Be precise about what this does: it stops sharing, not AI solving.
- Fresh content. Write new challenges instead of reskinning public ones, and don't reuse last year's board for a prize event. The challenge ideas above give you seeds; the twist you add is what makes a challenge yours.
- Writeup and source embargoes. Keep author writeups, solve scripts and challenge source private until the event closes, and ask players not to publish solutions before then. Put the embargo in your rules so a mid-event writeup is a clear breach, then publish everything once the event ends.
- Reward reasoning. Ask prize-eligible teams for a short writeup, or hold a call where they walk through two solves, and announce it in advance. Report-based formats such as Raw Ops, where findings are triaged and scored with CVSS 4.0, judge how players explain impact and chain weaknesses, not just whether they found a string.
- Let scoring absorb easy solves. With dynamic scoring, a challenge that an assistant solves for half the field loses most of its value automatically, so it can't decide the result. See how CTF scoring works.
Don't trade fairness for AI resistance. Hidden instructions aimed at AI tools, deliberately misleading text and unexplained obscurity break the no-guessing principle above. They confuse honest players, can trip up screen readers and translation tools, and are easy to strip out. A challenge that resists AI only because it's guessy is a bad challenge.
How to test a challenge against public AI assistants
Add an AI pass to playtesting for every challenge before release. It takes minutes per challenge and tells you which ones to retier or rework before players find out for you.
- Protect the unreleased challenge. Pasting a challenge into a public tool can expose it before the event. Use a plan or setting that doesn't retain or train on your inputs, or a model you run locally, and check the tool's terms. Test with a dev flag, never the production one.
- Use the player-facing package only. A reviewer who didn't write the challenge gives the assistant exactly what players will see: description, files and connection details. No source code, no author notes.
- Run two time-boxed passes. A one-shot pass: hand the assistant the challenge and ask for a solution, with about 10 minutes and no human insight. A guided pass: the reviewer works the challenge with the assistant as a co-pilot for about 30 minutes, the way a typical player would under a restricted policy.
- Try more than one assistant. Results differ between tools and versions. Try two or three mainstream assistants and note which versions you used.
- Record the result next to the solve script (sample below) and decide using the table that follows.
- Re-test close to the event. A challenge that held up in August may fall in October. Re-run the one-shot pass in the final week for everything in the medium tier and above.
# ai-playtest.yml (private, stored next to solve.py)
challenge: web-notes
tier_planned: easy
reviewer: playtester-2 # not the author
date: {YYYY-MM-DD}
package: player-facing only, dev flag
assistants_tried: 2
one_shot: solved # solved | partial | not_solved
guided: solved
decision: retier easy -> warmup
notes: fine as an on-ramp; points will decay under dynamic scoring
| AI pass result | AI allowed or restricted | AI not allowed |
|---|---|---|
| Solved in the one-shot pass | Keep as a warmup, or add a dependent stage | Move to warmup, or redesign around a live or on-site element |
| Solved only in the guided pass | Keep. The player still had to think | Keep, and rely on winner verification |
| Not solved in either pass | Keep, and confirm humans can still solve it in the blind playtest | Keep, with the same human check |
Your AI policy lives in your published rules, not in a platform setting. Submission logs and anti-cheat signals (shared IPs, identical wrong answers, implausibly fast solve sequences) help you spot flag sharing and unusual patterns whichever policy you choose. Treat them as a reason to look closer, not as proof of AI use.
Writing the author writeup
Write the official writeup before the event, not after. It forces you to confirm the intended path is complete, and it gives your support team a reference when players ask "am I on the right track?" After the event, publish it with the source code. Players learn more from the author's reasoning than from any hint. Encourage teams to post their own writeups once the event closes, and embargo writeups while it's running. State the embargo in your CTF rules so everyone knows when publishing is allowed. For how to structure one, see our guide to writing a CTF writeup.
Common challenge-creation mistakes
- Guessy steps: hidden files, arbitrary passwords, unexplained stego tools.
- Flag baked into the image and leaked through
docker history, public registries or an attachment. - Unintended solutions: path traversal to
/flag, debug mode left on,.gitdirectory exposed. - Shared state: one team deletes the admin user or fills the disk and breaks the challenge for everyone.
- libc mismatch: the provided libc doesn't match the one in the container, so correct exploits fail.
- No solve script: nobody notices the challenge broke at 3 a.m. until the complaints arrive.
- Difficulty inflation: the author rates it easy because they built it. Playtest instead of guessing.
- Flag format drift:
flag{}in one challenge,CTF7{}in another. Players waste time second-guessing. - Recycled public challenges: a light reskin of a challenge with a public writeup falls to a web search or an AI assistant in minutes.
Pre-launch challenge checklist
| Area | Check |
|---|---|
| Design | One core idea. Intended path documented. No guessing required. |
| Difficulty | Tier assigned. Blind playtest time recorded and matches the tier. |
| Flag | Event format, regex-valid, unique, dynamic where possible, not in the image. |
| Description | Connection info, file list with checksums, flag format, author credit. |
| Hints | Tiered hints written, costs set and release criteria agreed. |
| Container | Pinned base image and dependencies. Non-root user. Healthcheck. |
| Isolation | Read-only FS, capabilities dropped, no-new-privileges, memory/CPU/PID limits, egress blocked. |
| Files | Attachments match the deployed version (same binary and libc). |
| Tests | solve.py passes in CI and against the deployed instance. |
| Unintended paths | Playtesters tried to cheese it. Debug mode off. No .git or backup files exposed. |
| Load | Survives expected concurrency with limits applied. |
| AI pass | Tested with public AI assistants on a dev flag. Result recorded and tier matches your AI policy. |
| Writeup | Author writeup complete and stored privately until the event ends. Embargo stated in the rules. |
Deploying challenges on CTF7
On CTF7, organizers upload challenges through the admin panel with descriptions, attachments, external links and hints. Standard events can bring their own Docker images, and Premium events get managed container deployment and challenge hosting. Dynamic flags, static or dynamic scoring and anti-cheat monitoring are built in, and you can pause rounds and track submissions live. Compare the tiers on the pricing page, or read the full guide to hosting a CTF.
Don't have time to write and test 30 challenges? Our CTF challenge development service designs, builds and tests custom challenges for your audience and difficulty curve, from jeopardy categories to attack-defense services.