Six challenges for GZCTF — an OWASP
Top 10 target and a PWN target in each of two live-engine modes
(Attack & Defense and King of the Hill), plus two Jeopardy
challenges, so one game exercises all three ranking modes (the attack map's
A&D / KotH / Jeopardy switch). Import the whole set into a GZCTF instance with
one repo binding (admin → Repo Bindings): the server clones this repo, turns
events/tcp1p-testing/.gzevent into a Game, and imports every challenge.yml
beneath it — the src/ services and checker/ images build automatically, so
there's nothing to push.
events/
tcp1p-testing/
.gzevent # → one Game (the repo binding imports this)
Web/
owasp-portal/ (A&D) OWASP Top 10 web target — every vuln leaks the flag
koth-throne/ (KotH) OWASP Top 10 web hill — every vuln crowns you
Pwn/
pwn-armory/ (A&D) multi-bug PWN target — every bug leaks the flag
koth-pwn/ (KotH) multi-bug PWN hill — every bug crowns you
Crypto/
token-forge/ (Jeopardy) JWT alg:"none" forge — DynamicContainer, unique flag/team
Misc/
relic-archive/ (Jeopardy) layered-encoding warmup — StaticAttachment, static flag
# A&D/KotH leaf: challenge.yml + src/… + checker/… + solver/solve.py
# Jeopardy leaf: challenge.yml + (src/… for a container | dist/… for an attachment) + solver/solve.py
# (no SLA checker — jeopardy is flag-scored, not health-scored)
The category is the folder (Web/, Pwn/, Crypto/, Misc/) — the importer
takes it from the path (the category: in each challenge.yml is kept in sync
for clarity).
Need a platform to import these into first? Stand one up with the GZCTF platform template (
make wizard && make setup && make platform-up).
Template contract. The service auto-builds from ./src/Dockerfile
(supervisord PID 1 so a botched exploit doesn't drop the box). The checker
is the enochecker3 harness — checker.py/run.py copied verbatim, you only edit
checks.py (each @check gets a Target; return = Ok, raise Mumble =
up-but-wrong, t.get/post/sockets → Offline). All four checkers verify
functionality/health only; capturing the flag (A&D) or crowning (KotH) is the
solver/ job.
- A&D: one container per team; the platform plants a fresh flag at
$GZCTF_FLAG_FILEeach tick. Steal other teams' flags and submit them. - KotH: ONE shared "hill"; no per-team flag. Each round the platform issues
a control token — write it exactly into
/koth/king(the platformTrim()s the file and matches it against the token). Hold it while the hill is healthy to score.allowSelfReset: false(shared hill).
A Flask "team portal" (register / login / notes CRUD / search / password reset /
settings & prefs import / link preview). Each legitimate feature carries one
OWASP-2021 vuln; the live flag is mirrored into admin note #1 and the config
table each request so every class can reach it.
| OWASP 2021 | Where | Flag leak |
|---|---|---|
| A01 Broken Access Control | GET /api/notes/<id> |
IDOR — no ownership check, read admin note #1 |
| A02 Cryptographic Failures | session cookie |
md5/secret="secret" → forge role=admin → /admin |
| A03 Injection | GET /api/search?q= |
' ) UNION SELECT 1,k,v FROM config-- - |
| A04 Insecure Design | POST /reset |
reset token returned in response → admin takeover |
| A05 Security Misconfiguration | GET /debug?file=/flag |
arbitrary file read (DEBUG on) |
| A06 Vulnerable Components | POST /import/yaml |
PyYAML 3.13 yaml.load → !!python/object RCE |
| A07 Auth Failures | POST /login |
default admin:admin123, no rate limit |
| A08 Integrity Failures | POST /import/prefs |
pickle.loads of user blob → RCE |
| A09 Logging Failures | GET /logs/app.log |
flag logged cleartext to a web-served log |
| A10 SSRF | GET /fetch?url=file:///flag |
no scheme/host filter |
solver/solve.py implements all ten paths; checker/checks.py exercises only
the legitimate flow (health + a full core_flow).
A C menu heap "item manager" over TCP (one process/connection via socat),
compiled no canary / no PIE / no RELRO / exec stack. print_flag() reads
$GZCTF_FLAG_FILE and is the canonical win target.
| Bug | Menu | Notes |
|---|---|---|
| Stack buffer overflow | 0 Set nick |
32B buf, read() 256 → ret2win (offset 40, print_flag@0x401226+1) |
| Format string | 6 Echo |
printf(buf) → leak + %n write admin |
| Use-after-free / double free | 4 Delete |
slot never NULLed; free-twice |
| OOB array index | 1/2/3/4 |
idx unchecked (incl. negative) |
| Signed size → malloc | 1 Add |
negative size → huge size_t |
| Heap overflow | 3 Edit |
writes a caller-supplied length, unbounded |
| Command injection | 7 Ping |
system("ping -c1 " + host) |
| Uninitialized heap leak | 1→2 |
malloc not zeroed + optional write |
solver/solve.py does command-injection and ret2win; checker/checks.py
drives Add→Show→List→Edit→Show→Echo→Secret(locked)→Delete legitimately.
A Flask "hill". Crowning (writing /koth/king) is admin-only and players get
no admin account, so taking the hill means exploiting one of ten OWASP vulns —
each lands your exact round token in /koth/king.
| OWASP 2021 | Where | How it crowns |
|---|---|---|
| A01 Broken Access Control | POST /throne + X-User-Role: admin |
trusts a client role header |
| A02 Cryptographic Failures | session cookie |
forge role=admin (md5/secret="secret") → /throne |
| A03 Injection | POST /login |
SQLi admin'-- auth bypass → admin → /throne |
| A04 Insecure Design | POST /reset |
leaked reset token → take admin → /throne |
| A05 Security Misconfiguration | GET /debug/write?file=/koth/king&data= |
arbitrary file write |
| A06 Vulnerable Components | POST /import/yaml |
PyYAML 3.13 RCE writes the marker |
| A07 Auth Failures | POST /login |
default admin:admin123 → /throne |
| A08 Integrity Failures | POST /import/prefs |
pickle RCE writes the marker |
| A09 Logging Failures | X-Forwarded-Log: /koth/king + User-Agent: <token> |
header-controlled raw log write |
| A10 SSRF | GET /fetch?url=…/internal/crown?token= |
reach the localhost-only crown |
solver/solve.py implements all ten (crown_via_AXX); checker/checks.py is a
read-only health probe — never crowns — confirming GET / is up and the crown
is still guarded (non-admin POST /throne → 4xx, not 5xx, not 200).
A C binary hill over TCP (no canary/PIE/RELRO/exec-stack). Only do_crown()
(0x401226) writes /koth/king, enthroning the global banner. Set banner
to your token, then flip is_admin (0x4038c0) or ret2 do_crown().
| Bug | Menu | Notes |
|---|---|---|
| Stack buffer overflow | 3 Set nick |
64B buf, read() 512 → ret2 do_crown (offset 72) |
| Format string | 4 Echo |
printf(buf) → %n write is_admin |
| OOB array write | 7/8 Notes |
unchecked idx → write is_admin |
| UAF / double free / heap overflow | 7/8/9 Notes |
slot never NULLed; unbounded edit len |
| Auth backdoor | 5 Login |
password letmein → is_admin |
solver/solve.py crowns via the auth backdoor and ret2win; checker/checks.py
is read-only — confirms the menu is alive and an un-privileged crown is denied
(it never sets banner or flips is_admin, so it can't touch /koth/king).
A Flask "members area" that authenticates with a JWT. You are issued a guest
token; GET /flag only serves the flag to an admin token. The verifier
trusts the token's own alg header and still honours alg:"none" (RFC 7519's
unsecured JWT, signature skipped) — so you forge an admin token with an empty
signature.
| Field | Value |
|---|---|
| Type | DynamicContainer — one container per team, unique flag at $GZCTF_FLAG |
| Bug | verify() accepts alg:"none" and returns the claims unverified |
| Win | header {"alg":"none"} · payload {"admin":true} · empty sig → GET /flag |
provide: ./dist ships the Flask source; solver/solve.py forges the token and
reads /flag. Auto-builds from ./src/Dockerfile (a DynamicContainer is a
container type, so the service build applies just like the A&D/KotH ones).
A warmup. We recovered a string that was Base64-encoded, then ROT13'd, then written backwards. Peel the layers off in reverse to read the relic.
| Field | Value |
|---|---|
| Type | StaticAttachment — one fixed flag, an attachment, no container |
| Artifact | dist/relic.txt = reverse( rot13( base64( flag ) ) ) |
| Recover | reverse → ROT13 → Base64-decode |
solver/solve.py decodes it; the static flag lives in flags: in the
challenge.yml.
Both the service (src/Dockerfile) and the checker (checker/Dockerfile)
are built automatically on import — you don't push images or set containerImage
/ checkerImage. To deploy:
- In the GZCTF admin UI open Repo Bindings and add a binding — Repo URL
https://github.com/TCP1P/TCP1PADTesting, Ref empty (default branch), Interval60, no token (this repo is public). - Hit Scan now. The poller finds
events/tcp1p-testing/.gzevent, creates the Game TCP1P A&D + KotH Testing, and imports all sixchallenge.ymls (hidden). The fivesrc/services (4 A&D/KotH +token-forge) and four A&D/KotHchecker/images build in the background — watch admin → Builds. (relic-archiveis aStaticAttachment— nothing to build.) - The game imports hidden: open admin → game → Info, set your own start/end time, and unhide it. Later syncs keep the challenges current but won't revert your Info-page edits.
Local smoke test (event tree paths):
EV=events/tcp1p-testing
docker build -t owasp-portal $EV/Web/owasp-portal/src
docker build -t owasp-portal-checker $EV/Web/owasp-portal/checker
echo 'flag{local_test}' > /tmp/flag
docker run -d --name svc -v /tmp/flag:/flag:ro owasp-portal
IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' svc)
docker run --rm --network host \
-e GZCTF_TARGET_IP=$IP -e GZCTF_TARGET_PORT=8080 -e GZCTF_ROUND=1 -e GZCTF_TEAM_ID=1 \
owasp-portal-checker; echo "exit=$?" # 0 = Ok
python3 $EV/Web/owasp-portal/solver/solve.py $IP 8080 # prints captured flagsJeopardy smoke tests:
EV=events/tcp1p-testing
# token-forge (DynamicContainer): platform injects the flag as $GZCTF_FLAG
docker build -t token-forge $EV/Crypto/token-forge/src
docker run -d --name tf -e GZCTF_FLAG='TCP1P{local_test}' -p 8080:8080 token-forge
python3 $EV/Crypto/token-forge/solver/solve.py 127.0.0.1 8080 # FLAG: TCP1P{local_test}
docker rm -f tf
# relic-archive (StaticAttachment): no container, decode the artifact
python3 $EV/Misc/relic-archive/solver/solve.py $EV/Misc/relic-archive/dist/relic.txtThese are intentionally vulnerable. Run them only inside the isolated A&D environment — never expose them on a trusted network.