black3dm0nd Research logoblack3dm0nd Research logoblack3dm0ndoffensive security research
InfoPublished

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.

black3dm0ndOct 10, 2026updated Oct 10, 202611 min readLab Build
Challenge IsolationCtfDocker ComposeFastapiLab BuildPostgresqlReactRedisTraefik
NullSpace: Building a Local CTF Environment That Fights Back cover image
$ cat ./labs/self-hosted-ctf-platform/index.mdx --section notes
Loaded 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.

CTF platform login screen
The login screen is boring on purpose. The platform is the part I do not want players to break.

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.

Admin challenge management screen
Challenge management lives on the platform side, so it gets treated like privileged app surface.

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:

bash
1./ctf.sh up2./ctf.sh down3./ctf.sh fresh4./ctf.sh destroy

That 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.

Docker Compose stack starting
The stack comes up from one command. That matters more than it sounds when you are testing repeatedly.
CTF control script output
The script keeps the workflow boring. Boring workflows get used.

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:

yaml
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_net

The 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.

Admin audit log
Audit logs are not glamorous. Neither is trying to remember who changed a challenge mid-run.

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.

LFI challenge app
TemplateHub is the LFI target. Small feature, clear bug, contained impact.

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.

Command injection challenge output
Command execution is the intended win condition. The containment around it is the real design work.

The container is locked down:

yaml
1cmdi:2  read_only: true3  cap_drop:4    - ALL5  security_opt:6    - no-new-privileges:true7  pids_limit: 648  networks:9    - cmdi_net

Read-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.

Stored XSS challenge app
PixelBoard has an admin-review flow so stored XSS actually matters.

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:

python
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.

Per-team flag flow
A leaked flag from one team should be useless to another team.

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.

CTF scoreboard
Scores, solve order, and tie-breaks need deterministic rules or players will find the edge cases for you.

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

Admin submissions view
The submissions view is for debugging the event, not collecting trophies.

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 cover image
InfoPublished
featuredMethodology

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.

$ open research/nmap-evasion-cheatsheet/index.mdx
Oct 10, 2026 5 min read
#Cheatsheet#Evasion#Labs#Network Scanning+2