research/local-ctf-environment/index.mdx
Build the CTF Lab, Then Make It Fight Back
Why building a local CTF environment is better training than only solving boxes, how to turn one bug into several difficulty levels, and where platform hardening matters.

$ cat ./research/local-ctf-environment/index.mdx --section summaryLoaded sanitized MDX. Rendering public, published research content only.
A CTF box that falls over in one request does not teach enough
?page=../../flag.txt. ;id. <img src=x data-removed=alert(1)>.
Those are useful reps the first few times. After that, they become muscle memory. The player wins, the flag gets submitted, and nobody has to think much harder than the payload.
That is not the lab I wanted.
I wanted a local CTF environment where a vulnerability can start obvious, then get harder in ways that mirror the real arguments we have with code: bad filters, partial validation, access boundaries, rate limits, weak assumptions, and finally the actual fix. I also wanted the platform hosting those challenges to be hardened, because people who play CTFs are exactly the kind of people who will poke the scoreboard when the challenge is too easy.

The useful split is this: the platform should resist abuse, and the challenges should invite it. If those two worlds blur, you do not have a training lab. You have a future incident report.
Why build it instead of only solving public boxes
Solving teaches recognition. Building teaches cause.
When you solve an LFI challenge, you learn which paths and encodings work. When you build the LFI challenge, you decide why the app reads files in the first place, where the root directory is, which files should be reachable, where the flag sits, what the first bad patch looks like, and what actually fixes the bug.
That second side matters.
It is the difference between knowing a payload and understanding the shape of the mistake. It also makes public CTFs more useful afterward, because you start reading a challenge like an author instead of only like a player. You ask what the developer thought they were protecting. You look for the failed assumption, not just the sink.
I am not pretending everyone needs to build their own platform before learning security. That would be ridiculous. But if you already solve boxes and want better reps, writing the target yourself is one of the fastest ways to find out what you understand and what you were just copying.
Build ladders, not one-shot tricks
The best version of a challenge is not always the hardest version. It is the version that teaches the next thing.
I like four levels:
- Level 1: the raw bug.
- Level 2: the common bad mitigation.
- Level 3: a stronger control that changes the attack.
- Level 4: the real fix.
That structure keeps the challenge useful for more than one type of player. A beginner sees the class clearly. Someone more experienced gets the bypass exercise. The final level connects exploitation to remediation, which is the part too many labs skip.
The trick is not making every level more annoying. Annoying is easy. The trick is making each level force a better question.
LFI: stop pretending string replacement is path security
The LFI box is a small template viewer. It takes a page name and renders a file. That is believable enough. File viewers, template loaders, report renderers, export handlers, they all end up near this bug class sooner or later.

The first level is deliberately plain:
1page = request.args.get("page", "home.html")2target = os.path.join(WEB_ROOT, page)3return open(target).read()The player reaches outside the intended directory and reads the flag. Good. They know the class.
The second level is the patch people write when they know just enough to be dangerous:
1page = request.args.get("page", "home.html")2page = page.replace("../", "")3target = os.path.join(WEB_ROOT, page)4return open(target).read()This is a good bad fix. It gives you something to attack. Nested traversal, absolute paths, repeated decoding, symlinks, and platform-specific path behavior all become fair questions.
Then raise the bar:
1if not re.fullmatch(r"[A-Za-z0-9_\-/]{1,64}\.html", page):2 abort(400)Now the easy payload dies. That does not mean the design is finished. It means the player has to reason about allowed files, path resolution, and application behavior instead of only typing ../ until something works.
The actual fix is boring and correct:
1base = os.path.realpath(WEB_ROOT)2target = os.path.realpath(os.path.join(base, page))3 4if target != base and not target.startswith(base + os.sep):5 abort(403)Resolve the path first. Then check if it is still inside the directory you intended. Anything else is usually a long argument with the filesystem, and the filesystem has more patience than you do.
Command injection: the shell is the problem
The command-injection challenge is a fake network operations console. It has a host field and a connectivity check. It looks like something someone would build quickly and then forget to review properly.

Level one:
1cmd = f"ping -c 1 -w 2 {host}"2subprocess.run(cmd, shell=True, capture_output=True, text=True)That teaches the bug.
Level two blocks a few characters:
1if any(char in host for char in ";|&`$()<>"):2 abort(400)This is where the useful lesson starts. The shell has a grammar. Your blacklist has vibes. Those are not the same thing.
Players start looking for newline behavior, argument injection, expansion tricks, separators the filter missed, and parser differences. Good. That is the work.
Level three can validate the host shape:
1if not re.fullmatch(r"[A-Za-z0-9.\-]{1,253}", host):2 abort(400)Better. Still not where I want to stop.
The fix is not "find the perfect regex and keep shell=True." The fix is to stop sending user input to a shell.
1subprocess.run(["ping", "-c", "1", "-w", "2", "--", host], shell=False)Use an argument list. Stop option parsing with --. Keep validation anyway. Controls are allowed to work together. They do not get jealous.
Stored XSS needs someone worth attacking
Stored XSS without a victim is mostly page decoration.
For this challenge I wanted a review flow. The player submits content. A headless browser visits it as a challenge admin. The flag lives in a deliberately readable cookie because the lesson is impact, not cookie hygiene.

Level one is raw output:
1return f"<div class='post'>{comment}</div>"Level two removes `", "", comment)
1That is another good bad fix. It teaches that XSS was never married to one tag. Event handlers, SVG, malformed markup, browser quirks, and policy gaps all exist. The payload changes. The bug remains.2 3A later level can add CSP and force a different conversation. Can inline JavaScript run? Can images load? Is there a trusted endpoint? Is exfiltration possible inside the same challenge environment?4 5The proper fix is contextual output encoding:6 7```python8return f"<div class='post'>{escape(comment)}</div>"The browser can still visit. The admin bot can still exist. The user content just stops becoming markup.
The platform is not a challenge
This is the part I care about most.
The platform is not where players should get their wins. It stores accounts, teams, scores, submissions, hints, challenge metadata, and admin actions. If a player compromises a vulnerable container, that should not mean they can reach PostgreSQL, Redis, or the admin API.

The platform needs the boring controls:
- Server-side sessions.
- CSRF protection.
- Admin-only route dependencies.
- Ban checks.
- Password policy.
- Rate limits.
- Parameterized queries.
- Audit logs.
- Network separation from challenges.
Boring is the point. Nobody wants an exciting session management story.
For admin routes, I prefer dependency chains:
1def require_admin(user=Depends(get_current_user_csrf)) -> User:2 if not user.is_admin:3 raise HTTPException(403, "Admin only")4 return userThe lower dependency handles the session, CSRF, account state, and ban status. The admin dependency adds the role gate. Every privileged route declares it. That is easier to review than a pile of inline if statements scattered across handlers.

Rate limits are part of the lab too
CTF platforms attract automation. That is not a surprise. It is the weather.
Login, registration, invite codes, password reset, and flag submission all need different limits. A player mistyping a flag is not the same as someone brute-forcing credentials.
Redis is a good fit for this:
1class Lockout:2 def record_failure(self, subject: str) -> None:3 failures = redis_client.incr(self.fail_key(subject))4 if failures >= self.threshold:5 delay = min(self.base_delay * (2 ** (failures - self.threshold)), self.max_delay)6 redis_client.setex(self.lock_key(subject), delay, "1")The useful exercise is tuning it. Too loose and it is decoration. Too strict and players trip it during normal use. You find the balance by testing, not by feeling virtuous because a middleware exists.
Password policy, audit logs, and flags that do not travel
The password policy belongs on the backend. The frontend can show a checklist. That is user experience, not enforcement.

Audit logs are the same kind of boring until they matter. If an admin changes a challenge, clears a submission, bans a user, or posts an announcement, I want an actor, an action, a target, an IP, and a timestamp.

For flags, shared strings are too easy to pass around. Per-team flags make casual sharing less useful:
1digest = hmac.new(2 FLAG_HMAC_KEY,3 f"{challenge_secret}:{team_id}".encode(),4 hashlib.sha256,5).hexdigest()6 7flag = f"flag{{{digest[:32]}}}"
This is not magic anti-cheat. It just removes the laziest version of flag sharing. That is still worth doing.
The point of the local lab
The point is not having a pretty board.
The point is owning the loop: write the bug, exploit the bug, add a bad mitigation, bypass it, add a better control, test it, then document the real fix. Do that enough times and the vulnerability class becomes less like trivia and more like a system you understand.
That is why I like local CTF environments. They let you practice attack and defense in the same small room. You can make the target weak on purpose, then make the environment strong enough that the weakness stays where it belongs.
Build the bug. Break the bug. Harden everything around it.
Image gallery




Affected technology
Remediation
Treat the platform like production software, keep vulnerable challenges isolated, and design each challenge level to teach both the bypass and the fix.
Disclosure timeline
References
Related posts
No research found
Try a different search or filter set.
