black3dm0nd Research logoblack3dm0nd Research logoblack3dm0ndoffensive security research
InfoPublished

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.

black3dm0ndOct 10, 2026updated Oct 10, 202613 min readSecurity Research
Access ControlChallenge DesignCtfDockerLocal LabRate LimitingSecure DesignWeb Security
CVSS
N/A
Status
Published
Artifacts
5 indexed
Build the CTF Lab, Then Make It Fight Back cover image
$ cat ./research/local-ctf-environment/index.mdx --section summary
Loaded 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.

Local CTF challenge board
The board is the friendly part. The interesting part is what the challenges and platform force you to think about.

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.

TemplateHub LFI challenge
TemplateHub is intentionally plain. The feature is file rendering, so the bug belongs there.

The first level is deliberately plain:

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

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

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

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

NetOps Console command injection challenge
A host check is enough surface for command injection. No need to invent a novel.

Level one:

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

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

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

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

PixelBoard stored XSS challenge
The review flow gives stored XSS somewhere to land.

Level one is raw output:

python
1return f"<div class='post'>{comment}</div>"

Level two removes `", "", comment)

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

Admin challenge management screen
The admin side is platform code. It gets platform-grade controls.

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:

python
1def require_admin(user=Depends(get_current_user_csrf)) -> User:2    if not user.is_admin:3        raise HTTPException(403, "Admin only")4    return user

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

Banned user screen
A banned account should hit an authorization wall, not a vague broken-state bug.

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:

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

Admin user management with password policy
The UI can explain the policy. The backend enforces it.

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.

Admin audit log
Audit logs turn platform changes into evidence instead of guesses.

For flags, shared strings are too easy to pass around. Per-team flags make casual sharing less useful:

python
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]}}}"
Per-team flag in the challenge flow
A valid flag for one team should not automatically be valid for every team.

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

Build the CTF Lab, Then Make It Fight Back gallery image
Build the CTF Lab, Then Make It Fight Back gallery image
Build the CTF Lab, Then Make It Fight Back gallery image
Build the CTF Lab, Then Make It Fight Back gallery image

Affected technology

CTF PlatformsDockerFastAPIPostgreSQLRedisReactChallenge IsolationAccess Control

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.