labs/self-hosted-ctf-platform.mdx
NullSpace: Building a Local CTF Environment That Fights Back
A practical build writeup for a self-hosted CTF platform using FastAPI, PostgreSQL, Redis, React, Docker Compose, isolated vulnerable challenge apps, per-team flags, and an admin bot.

$ cat ./labs/self-hosted-ctf-platform/index.mdx --section notesLoaded local, published lab content from the repository.
NullSpace: Building a Local CTF Environment That Fights Back
I called it NullSpace because I wanted the lab to feel like a controlled void: isolated, resettable, hostile by design, but not chaotic.
I did not build it because the world was missing another scoreboard. I built it because I wanted the whole system under my hands: users, teams, sessions, submissions, scoring, hints, admin actions, flags, rate limits, and isolated vulnerable services. I wanted to learn the parts people usually skip until they break.
That is where the real practice is. Not in clicking through a finished platform, but in building the thing, breaking it, hardening it, rebuilding it, and finding the next weak assumption.

The design tension is the whole project: the platform has to be hardened, and the challenges have to be vulnerable. If the command-injection box gets popped, that is success. If it can reach the platform database, that is failure.
The stack I used
Nothing exotic:
- FastAPI for the API.
- PostgreSQL for durable state.
- Redis for lockouts and short-lived state.
- React and TypeScript for the frontend.
- Traefik for routing.
- Docker Compose for orchestration.
- Flask for the challenge apps.
- Headless Chromium for the XSS admin bot.
The platform has teams, a scoreboard, challenges, hints, submissions, announcements, and an admin panel. The challenge side has three deliberately vulnerable apps: LFI, command injection, and stored XSS.

The platform is not a challenge. That sentence drove most of the architecture.
One script to stop me fighting Compose
I wrapped the lab in a small control script:
1./ctf.sh up2./ctf.sh down3./ctf.sh fresh4./ctf.sh destroyThat is not fancy. It is useful.
up starts the stack. down stops it. fresh rebuilds state, regenerates secrets, and gives me a clean lab. destroy removes the local mess when I want to start over properly.


A lab that is painful to reset becomes a shrine. You stop touching it. You start protecting its weird state. Then it stops being a lab.
The network model is the security model
Putting every container on one Compose network is the fastest way to make the demo work and the easiest way to ruin the design.
The platform services live together. Each challenge gets its own network. The proxy is the routing point. The vulnerable apps do not get a route to PostgreSQL or Redis, and they do not get a route to each other.
The shape is roughly:
1services:2 proxy:3 image: traefik4 networks:5 - edge6 - platform_net7 - lfi_net8 - cmdi_net9 - xss_net10 11 api:12 build: ./platform/api13 depends_on:14 - postgres15 - redis16 networks:17 - platform_net18 19 postgres:20 image: postgres21 networks:22 - platform_net23 24 redis:25 image: redis26 networks:27 - platform_net28 29 lfi:30 build: ./challenges/lfi31 networks:32 - lfi_net33 34 cmdi:35 build: ./challenges/cmdi36 networks:37 - cmdi_net38 39 xss:40 build: ./challenges/xss41 networks:42 - xss_netThe real file has health checks, environment variables, Traefik labels, volumes, and security options. The point is the boundary. If a player gets RCE in the command-injection challenge, their next step should not be psql.
The platform gets the boring controls
The platform stores accounts, sessions, teams, scores, submissions, challenge metadata, and admin actions. It deserves normal application security.
Passwords are hashed properly. Sessions are server-side. State-changing routes have CSRF checks. Admin routes use role dependencies. Request bodies go through validation. Login and flag submission have rate limits. Admin actions go into an audit log.

This is not overengineering. This is the minimum I want before I invite people who enjoy breaking web apps to use the platform.
The LFI box is allowed to be bad
The first challenge is a template viewer with a file-loading bug.

The flag sits outside the normal content directory. The player abuses the file loading behavior to read it. That is the challenge.
The important part is where the challenge lives. It has its own container and its own network. If the LFI works, the player reads challenge files. They do not read platform secrets, Redis data, Compose files, or anything else that should not be in the room.
That is the pattern: make the vulnerable behavior real, then make the blast radius small.
The command-injection box can execute commands, not escape reality
The second challenge is a network console that shells out to run a connectivity check.

The container is locked down:
1cmdi:2 read_only: true3 cap_drop:4 - ALL5 security_opt:6 - no-new-privileges:true7 pids_limit: 648 networks:9 - cmdi_netRead-only filesystem. Dropped capabilities. No new privileges. Process limit. Separate network.
This does not make command injection safe. It makes the lab honest. The player gets command execution inside the target they were meant to break, and the environment does not hand them the rest of the platform as a bonus prize.
Stored XSS needs a browser with something to lose
The third challenge is stored XSS. Stored XSS without a victim is not much of a challenge, so I added an admin bot.

The bot is a headless browser. It visits submitted content and carries the challenge flag in a cookie. The cookie is deliberately readable because that is the exercise.
The bot is not a platform admin. It does not have platform credentials. It does not sit on the platform network. It belongs to the XSS challenge.
That separation matters. A victim bot is already dangerous enough. Do not make it a bridge.
Per-team flags are cheap and worth it
Static flags are easy. They are also easy to share.
I used per-team HMAC flags:
1def build_flag(secret: str, team_id: int, key: bytes) -> str:2 message = f"{secret}:{team_id}".encode()3 digest = hmac.new(key, message, hashlib.sha256).hexdigest()4 return f"flag{{{digest[:32]}}}"Same challenge, different valid flag per team. The player experience stays normal. The backend gets a flag that does not travel well.

It is not a full anti-cheat system. It is just a clean way to kill the laziest version of flag sharing. I will take that trade.
The scoreboard is event accounting, not decoration
The scoreboard looks like UI, but the backend has to treat it like event accounting.

Submissions need enough context for review: team, challenge, timestamp, result, and the metadata needed to debug weirdness without dumping sensitive material everywhere.

Tie-breaks matter. Hint costs matter. Re-submissions matter. Solves after challenge edits matter. Anything that changes score needs to be predictable because players will notice when it is not.
They always notice. Usually immediately.
What took more work than expected
The network layout took more attention than the app code.
It is easy to make Compose work. It is harder to make Compose work while preserving the isolation model. Every time I added a service, I had to ask the same question: if this container gets compromised, what can it reach?
The admin bot needed the same treatment. It has to behave like a victim, but it cannot become a privileged bridge into the platform. That meant separate network, separate purpose, and no platform credentials.
Rate limiting was also fiddlier than the code suggests. Login, registration, invite code attempts, and flag submissions are different behaviors. One policy for all of them would be lazy. The Redis mechanics were simple. The thresholds needed testing.
The reset path mattered more than I expected too. A fresh run has to reset secrets, flags, users, challenge state, and database state cleanly. If it leaves old state behind, you start debugging ghosts. Nobody needs that.
The build I would do next
The next version would make difficulty profiles first-class.
The same challenge should be runnable as beginner, intermediate, hardened, and fixed. Not separate random boxes. The same bug class, raised through levels so the player can see the mitigation path.
I would also add exports for event review: solves, failed submissions, hint usage, score changes, and admin actions. After a session, that data tells you which challenges worked and which ones need tuning.
The last thing I want is automated isolation checks. Something that proves a challenge container cannot reach postgres, redis, or the platform API. Trusting the Compose file by eye is fine until the file gets big.
The rule I kept coming back to
Harden the platform. Break the challenges. Do not confuse the two.
That one rule answers most design questions. Should the command-injection box reach Redis? No. Should the XSS bot have platform admin credentials? No. Should the scoreboard trust the browser? Absolutely not.
The platform is where people authenticate and scores are kept. The challenges are where people attack. Docker Compose gives you the tools to separate them, but it will not do the thinking for you.
That is why I built NullSpace myself. I wanted to feel the edges of the system by holding the pieces, then keep pushing until the weak parts showed themselves.
Lab timeline
References
Related labs

Nmap Evasion, Speed & Balance Cheatsheet
A quick lab reference for authorized Nmap scanning profiles, timing controls, discovery choices, scan types, source tricks, version scanning, and resumable output.
