Threat Modelling a Room Full of Attackers: Securing a CTF Platform
Most apps trust their users. Mine can’t — every single one is there to break something.
Learning to Model Threats
So I have to admit that before I started this, I had very limited knowledge on how to actually model threats the formal way. Over the years of being a red teamer, I’ve become adept at reading threat models during the scoping phase of an engagement - but actually creating a threat model for my own application? That was new territory.
I needed to start from the basics. What makes a good threat model? How does a threat model even differ from risk management? What types of questions should I be asking myself throughout? The clearest answer I found came from Adam Shostack’s four-question framework, which boils the whole exercise down to: what are we working on, what can go wrong, what are we going to do about it, and did we do a good job? Everything else - STRIDE, kill chains, attack trees - is just a technique for answering that second question.
The distinction between risk management and threat modelling took me a moment to settle. The way I now think about it is a threat model finds and reasons about specific things that can go wrong with a particular system, at design time. Risk management is the broader ongoing process of taking those findings and deciding what to prioritise and triage based on likelihood and impact. One feeds the other.
After a couple of late nights, I was ready to actually put something on paper. What follows isn’t an exhaustive dump of every threat identified - it’s a handful that mattered, and the ones that make a CTF platform a genuinely unusual thing to model.
Mapping the Battlefield
You can’t reason about what can go wrong until you can see what you’re working on, so the first real step was drawing the system out. Flagpost is a self-hosted CTF competition platform. Flagpost doesn’t host the vulnerable challenge boxes, the organisers handle that themselves, this is the platform around the challenges: registration and teams, live scoreboard, flag submission and scoring, automation engine, collaborative notes, and all the necessary admin tooling to run events.
The core components break down into the following:
- Frontend - Next.js/React, talking to the backend over HTTP and WebSockets (for live updates)
- Backend API - Python/FastAPI, with JWT auth and argon2 hashing.
- PostgreSQL - Source of truth for the platform: users, teams, challenges, and the flags.
- Redis - Cache, sessions, and the pub/sub behind the real-time updates.
- MinIO - S3-compatible object storage for challenge files, and support attachments.
- Caddy - Reverse proxy and TLS termination at the edge.
With the trust boundaries drawn out, it looks something like this:

The assets I actually care about protecting fall out of this picture pretty quickly: flag and score integrity, credentials and session information, isolation between teams, and availability during a live, timed event. A scoreboard that falls over at hour three of a week long competition is its own kind of breach.
When Your Users Are the Threat
A CTF Platform isn’t your usual, run-of-the-mill application for a threat model. The expected userbase of this platform is seasoned cybersecurity professionals (or those aspiring to be), and like most people with an interest in this field, their curiosity is likely to get the better of them - and my platform is the outlet for that. I had to go into this with the mindset that although the platform is full of challenges, some users will treat the platform itself as the challenge.
That means treating internal, authenticated users as adversarial by default. In a normal app, the biggest trust boundary is typically “unauthenticated internet -> logged-in user”, and you largely relax once someone’s through the front door. Here, getting through the door means almost nothing - the people inside are the threat, and I have to assume that if there’s a way, they will find it.
Practically, this changes the math. Threats a typical model would file under “low-likelihood insider abuse” jump straight to high-likelihood. My working assumption for every threat below is an attacker with real skill, real tooling, plenty of time, and - because it’s a team event - the ability to collaborate. That assumption had to be baked into how I thought about, and rated everything.
Drawing the Line: What’s Meant to Be Hacked
As previously mentioned, Flagpost doesn’t host vulnerable challenge boxes, so the usual CTF worry of container breakout, or root-level RCE isn’t my problem to model. What Flagpost holds instead is the answer key. Every flag for the entire competition sits in its database, and the single shared goal of the entire userbase is to obtain flags. So the line I’m defending isn’t a sandbox boundary - it’s the vault. The platform is a vault full of secrets that a building full of skilled safecrackers are all, simultaneously, trying to get out of it.
That reframing changed my whole priority list. The cardinal sins for Flagpost are:
- Flag disclosure - any path that reveals a flag the team hasn’t earned: reading it out of storage, pulling a challenge file before its scheduled release, or extracting it through the submission mechanism itself.
- Score tampering - awarding points that weren’t earned, or corrupting first-blood and decay calculations.
- Availability - knocking the platform over mid-event
Everything downstream is triaged against those three. If a threat doesn’t touch the vault, the scoreboard, or availability of the platform, it drops down the list.
How I Approached It
With the system drawn, the method itself was deliberately boring - which I’ve come to think is the point. I used Shostack’s four questions as the spine, and STRIDE-per-element as the tool for the “what can go wrong” step. For each component and data flow that crosses a trust boundary, I walked the six STRIDE categories (Spoofing, Tampering, Repudiation, Information disclosure, Denial of service, Elevation of privilege) and asked whether each applied.
Given the adversarial-users assumption, I front-loaded effort on the boundaries closest to the vault - the submission endpoint, the data tier, anything touching flag release. I kept the working notes in a simple threat table; what made it into this write-up is a tiny representative slice, not the full worksheet.
STRIDE vs Kill Chains
Working in the defence industry, I’ve become very familiar with Kill Chains. I hear about them and use them every day, from incident response to engagement scoping, to mapping red team findings against Lockheed Martin’s Cyber Kill Chain. STRIDE was new to me, and my first instinct was to treat it as just another list to hold the kill chain up against.
That was the wrong instinct, and correcting that was one of the more useful things I took away from this exercise. STRIDE and kill chain aren’t competing lists - they’re different tools. STRIDE is a static taxonomy: what kinds of thing can go wrong with a given component, at the time of design. A kill chain is temporal: where in the lifecycle of an attack you are, and therefore where you can intervene to break it. One tells you what to worry about; the other tells you when you can catch it.
I used STRIDE to enumerate threats per element, then reached for MITRE ATT&CK to map each concrete threat to a real-world technique. STRIDE for breadth of discovery; ATT&CK for the specificity that makes a mitigation actionable. It also turned out that several STRIDE categories mapped straight onto mitigations I’d already planned as part of the platform’s core design before starting this exercise.
Key Threats & Mitigations
Rather than list every threat, here’s the anatomy of one particularly interesting one worked all the way through (followed by a condensed catalogue of the others that mattered the most)
Anatomy of a Threat: ReDoS through regex flag validation
Asset at risk: platform availability and the scoring engine, during a live event.
Threat (STRIDE: Denial of Service). Flagpost supports regex-based flag validation - an organiser sets the pattern, and the platform tests each submitted string against that pattern looking for a match. The submitter controls the input, so if any challenge’s regex is written with backtracking potential, a player can submit a crafted string that makes the regex engine spin indefinitely - pinning CPU on the validation path for a single request.
Attacker & preconditions. Any authenticated player. They don’t need to know the flag - they only need a challenge whose validation regex is vulnerable, and the freedom to submit arbitrary strings to it. Both are a given.
Attack path. Identify a regex-validated challenge -> craft an input that maximises backtracking against a plausible pattern -> submit it repeatedly -> validation work pins worker threads -> the submission endpoint degrades, and because workers are shared, so does the rest of the platform.
ATT&CK mapping. T1499.003 - Endpoint Denial of Service: Application Exhaustion Flood
Likelihood/impact. Likelihood is high: trivial to attempt, a skilled userbase, and organiser-supplied patterns that won’t all be ReDoS-safe. Impact is high during a timed competition. That combination puts it near the top of the list despite it being just a denial of service.
Mitigations. Validate flags with a non-backtracking engine (RE2) so patterns simply can’t backtrack; enforce a hard per-validation timeout and an input-length cap; run validation in workers with CPU limits so one bad request can’t starve the whole pool; and rate-limit submissions per user per challenge (which conveniently also helps mitigate brute-forcing)
Condensed catalogue
| Threat | STRIDE | Mitigation | ATT&CK |
|---|---|---|---|
| Flag brute-force / guessing (multiple-choice, weak flags) | Spoofing / Info disclosure | Per-user/challenge rate limiting, guess limits (already present), submission-rate monitoring | T1110 Brute Force |
| Race condition on first-blood / decay scoring | Tampering | Atomic, transactional scoring with row-level locking / unique constraints | T1499 (logic abuse) |
| Broken object-level authz / IDOR on team & admin endpoints | Elevation of Privilege | Server-side per-object authz on every request; explicit test coverage for custom RBAC roles | T1068 / T1548 |
| Pre-release flag or challenge-file disclosure (unlock chains, scheduled releases, direct MinIO URLs) | Information disclosure | Enforce release gating server-side; signed, expiring MinIO URLs; no direct bucket access | T1530 Data from Cloud Storage |
| Stored XSS in collaborative notes / profiles / challenge content | Tampering | Sanitise CRDT-rendered HTML, output-encode, strict CSP | T1059 (client-side) |
| WebSocket channel authorisation (subscribing to another team’s private feed) | Information disclosure | Authorise every subscription; scope real-time tokens to the team | T1557 |
| SSRF via automation-engine webhooks | Info disclosure / SSRF | Egress allowlist, block internal ranges & cloud metadata, disallow redirects | T1090 / T1552 |
| Full backup/export exposure (export includes secrets) | Information disclosure | Restrict export to the owner role, audit every export, encrypt backups | T1552 / T1530 |
Several of these lean on strengths the platform already had planned - argon2 hashing, no shipped default credentials, RBAC, comprehensive audit logging - which is why the mitigations above are mostly about tightening a specific edge rather than building something from nothing.
The Risks I Chose to Live With
Any threat model that claims to mitigate every single risk is either lying or over-engineered. I learned that part of the exercise is deciding which threats to mitigate, and which are simply not worth the time - and being able to say why.
The clearest example of this in Flagpost would be flag sharing between colluding teams. If multiple real teams agree out-of-band to swap flags over a side channel I can’t see, no amount of platform hardening stops them from doing it. I could spend days implementing controls at it (device binding, aggressive proctoring) and wreck the experience for the 99% who aren’t cheating, just for a marginal dent in the 1% that are. Not worth it. Similar logic applies to unintended solutions to challenges (fundamentally that is an authoring problem, not a platform one) and to the organiser-supplied regex - I can bound it, not eliminate it all together.
What the Model Taught Me
The biggest surprise for me was where the risk was concentrated. I expected the submission endpoint to dominate - and it matters - but a lot of the biggest threats lived in the features that exist to enhance the usability of the platform: the automation engine’s webhooks (SSRF risk), the collaborative notes (untrusted HTML rendered live), the backup that helpfully includes secrets. Convenience features widen the attack surface more than I expected.
Although I believe I had a solid foundation of design choices before this exercise, it did shape the design: pushing me towards a non-backtracking regex engine with timeouts for validation, explicit per-team authorization on WebSocket subscriptions, signed and expiring URLs for challenge files. None of those were on my radar before I drew the system out and walked the trust boundaries.
The honest reflection to close on: as a red teamer, I’d always consumed threat models and quietly answered I could produce one on instinct. I couldn’t (not a good one, at least). The structure and process you go through is what catches the threats your instinct skips, precisely because it forces you to look at the boring elements with the same discipline as the obvious front door. It’s easy to miss obvious threats when all you’re focused on is the obvious in front of you, not the mundane that actually holds the power to break a platform like this.