Three Teams, 5,308 Points, 24 Seconds: The First Real Event for OWASP CTF in a Box

· 14 min read

The final numbers from the Red Team Space CTF at Ekoparty:

  • 41 players, 28 teams
  • 129 pull requests, 556 scoring runs
  • 498 scores ingested, 0 dropped
  • about 465,000 requests served on the two busiest days
  • 20 Jeopardy challenges, 2 never solved
  • 0 pull requests merged
  • 3 teams tied for first

The last line is the one this post is about. But first, how we got there.

From Vegas to Buenos Aires #

The DEF CON 34 write-up ended with a promise. The question I kept hearing at the booth was how to run this for your own students, so the next job was making the platform self-hostable.

That became OWASP CTF in a Box, now an OWASP incubator project: one Docker Compose stack plus one free GitHub org for the forks. No private scoring image, no cloud bill you didn’t choose, nothing that phones home. Until October it had been exercised end to end against mocks and a hosted test box, but never by a real cohort opening real pull requests for hours.

Ekoparty is the long-running security conference in Buenos Aires. Like DEF CON it has villages: themed spaces inside the conference, each run by its own crew, with talks, workshops and usually a challenge or two. The Red Team Space crew wanted a CTF in theirs, October 7 to 9, at the CEC convention center. I was on site, running the box from the village.

It was the kit’s first real event. The release that ran it was v0.7.0. The release that came out of it is v1.0.0.

What Ran #

Two modules shared one leaderboard.

Secure Development is the DEF CON format: fork one of six deliberately vulnerable apps, patch a vulnerability, open a pull request, and an Action in the event org runs the rubric against the patch. 321 challenges, 668 points, and a stock app still scores exactly zero.

Jeopardy is the conventional half: find a flag, submit the string, take the points. Twenty challenges in four categories, written by the Red Team Space crew. The centerpiece was Operation Last Mile, a scenario we built together starting in mid-September. The crew wrote the Puente Sur application and most of the chain. I brought the on-site gate below, the deployment that hosted it, and Last Mile Signer, a crypto challenge in the same storyline. In the kit, Last Mile is a story: a chain of challenges where each step stays locked until your team solves the one before it.

The box ran on the kit’s AWS deploy rather than a laptop: three app tasks behind a load balancer, a managed Redis (cache.t4g.small, bumped from the default), and a single poller. That poller is the part I’m proudest of. Scores travel as a machine-readable comment on the pull request, and the box reads those comments every half minute or so. Nothing needs to reach the box from outside, which matters on venue wifi.

498 score comments went in. None were dropped.

Operation Last Mile #

The story had two halves: one you could only start by walking into the village, and one that ran on the internet.

Gate Zero: A Camera Feed Under the Tent #

The first two flags lived on a Raspberry Pi at the village. It broadcast an open wifi network with no internet behind it, and that was the point. When a phone joins a network, it quietly checks a few well-known addresses to see whether it is really online. If the answers come back wrong, it pops up a sign-in page. That’s a captive portal, the same mechanism hotel wifi uses. The device answered every one of those checks with a redirect, so joining the network was enough to land a player on the login screen of a fictional video recorder.

Joining at all earned the presence flag. The second flag needed a SQL injection, with a twist. The textbook bypass, ' OR 1=1--, does log you in, as whichever account comes first in the table. That account was a guest, and the guest’s only camera showed an empty hallway. To see the warehouse camera you had to notice who you had become and aim the injection at the operator account instead.

The flag wasn’t in the HTML, the headers or any file you could download. It was written on an inventory label stuck to a box, inside a live Motion JPEG stream that the device drew frame by frame in code: shelves, scanlines, a blinking REC, a running timestamp. The whole portal was designed and built three days before the event, and the last commit that day made the frame rendering about 3.7 times faster so the little board could keep up.

The funnel shows on the board: 14 solves for showing up, 10 for the camera.

One Rule for Physical Challenges #

A physical object is shared. Everyone in the village sees the same device, the same poster, the same tag. The remote chain, on the other hand, gives every team its own secrets. So the rule was that hardware only ever carries presence or a value common to everyone, and it sits at the ends of the chain or beside it as a side quest, never in the middle. Put a shared gate between two per-team steps and you either leak one team’s secrets to another or make the chain impossible to playtest remotely.

The Remote Chain #

Past the village, each team got its own instance of Puente Sur Logistics, a fictional freight company, and one goal: reroute shipment VERSPRITE-42. The path went through recon, SQL injection, an IDOR, an SSRF unlocked by a support ticket, an operations console, a BOLA on a supervisor queue, and finally abuse of a claim and reroute workflow. If those acronyms are new: an IDOR is reaching someone else’s record by changing an ID in the request, an SSRF is getting the server to make a request for you, usually to something internal you can’t reach, and a BOLA is the API flavor of the IDOR.

Most of our design work went into the shortcuts that aren’t there. An early version let the SQL injection read documents and reviews meant for later stages, so the data was split across three separate databases and the injection can only ever see shipments. IDs and correlation references are derived from each team’s own secret, so they can’t be enumerated and one team’s answers don’t open another team’s instance. Twenty instances started on a single host behind Cloudflare, ready to grow in blocks of five.

Our security review also asked a newer question: how much of this can an agent with a browser and a terminal do on its own? Probably a fair amount. The answer was not CAPTCHAs or perception puzzles, which hurt accessibility and don’t reliably stop an agent anyway. It was friction in the right places: recon signals scattered across dead ends, and values a later step needs that only appear in an earlier step’s output.

The Rehearsal Earned Its Keep #

We rehearsed on October 3 and ran a review of hostile inputs on the 5th. Three findings are worth retelling, because each one only shows up when you act like a contestant.

Scores were minutes late, and only on the real topology. A Secure Development score was ingested in under a minute, then took four to five minutes to appear on the board. Successive reloads disagreed with each other. The cause was caching. The app fetched the scorer’s board with a 30 second stale-while-revalidate window, which means a request past the window still gets the old copy and only kicks off a refresh in the background. On a quiet board that copy can be minutes old. And with three tasks, each kept its own copy, so every reload was a coin toss. One task on a laptop never shows it.

A solved story step could change teams. Story steps unlock when any current member of a team holds the previous step. So a player could solve step 3, leave, join a friend’s team, and unlock step 4 there without anyone on that team doing the work. The mitigation for the event was operational: close registration shortly after scoring opened. The real fix, judging an unlock by the team the solver was on when they solved it, landed two days after the event.

Team names could be invisible or reversed. Unicode has characters that take up no space, like the zero-width space, and characters that flip the direction of the text after them, like the right-to-left override used for bidirectional text. A rename to a zero-width space produced a team with no visible name on the board. A direction override could render a name backwards, or push text over the points column. HTML and emoji were already escaped and safe.

That last check paid off in a funny way. One of the 28 teams registered as <svg onload=alert('0xPWN')>. It sat in 8th place all weekend, rendered as exactly what it is: text.

The Tie #

Here is the top of the final board:

TeamPointsMembers
Oxguardians5,3082
provart5,3084
Mortadela’s5,3084
La Moneda Estoica5,2981

Three teams on the same total. Each one had every Secure Development point (668), 4,650 Jeopardy points, and 10 points spent on hints. In fourth, a single player, 10 points behind three full teams.

The rules said ties go to whoever got there first. The board disagreed. It showed provart first, Mortadela’s second and Oxguardians third.

The order came from the scorer, which ranks by Secure Development points and then by when a team finished Secure Development. The app added the Jeopardy points on top and re-sorted by total, and when totals are equal a stable sort keeps the order it was given. So the tie-break silently ignored the module where all three teams actually finished. Mortadela’s crossed the line twenty minutes after the other two and still sat second.

I checked it on site by going to the stored timestamps directly. Every solve is stored with milliseconds:

TeamReached 5,308 (GMT-3)Board saidRule says
Oxguardians13:59:08.1143rd1st
provart13:59:32.5401st2nd
Mortadela’s14:19:32.5312nd3rd

Twenty four seconds between first and second. The admin Activity log, meanwhile, showed times to the minute, so the top two both read 13:59 there. The precision existed. The screens just didn’t show it.

We announced the corrected order before the awards. Afterward the fix went in properly: every team standing now carries its last solve time across all modules, ties break on it, buying a hint never moves it, and a Secure Development solve is timed by when its score comment was posted rather than when the poller happened to read it. There is now a test with exactly those three teams and those timestamps.

When the content has a ceiling, ties are not an edge case. Four teams finished within 10 points of the maximum. Strong players clear the board, and when they do, the tie-break is the scoreboard. It deserves the same scrutiny as the scoring.

What the Numbers Say #

Some things the admin Insights page showed afterward:

  • Two challenges went unsolved. ¿Que dice señor? took 103 attempts and zero solves. Paciencia deber tienes… took 46. Every other challenge had at least four solving teams.
  • Showing up was the most solved challenge. The village presence flag had the most solves of the whole board. The remote chain held eight solvers through its first four steps, then dropped to six at the operations console and five at the end.
  • Hints were bought to be used. Nine players spent 170 points on hints, and every hint that could still help was bought before the solve, not after.
  • Secure Development was the smaller, harder crowd. 12 contestants opened the 129 pull requests, against 19 who scored in Jeopardy. WebGoat drew the most: 51 pull requests and 161 scoring runs.
  • Registering is not playing. 41 players joined teams, 24 submitted something, 22 scored. Thirteen teams finished on zero.

The scoring compute was about 21 hours of runner time over the three days, against roughly 293 at DEF CON. That works out to about 46 scoring runs per Secure Development contestant here, against about 97 per player there.

The Insights page also showed its own bug. Median time to solve read zero seconds for almost every challenge. It measured from a player’s first flag submission to their correct one, and people work offline and submit once they have the flag, so the duration was zero by construction. It now measures from first opening the challenge.

Running It From the Village #

The box itself held. What I lacked on the day was everything around it.

The deploy module had no alarms at all, so I built an uptime check by hand from the cloud CLI: a health check on the deep health endpoint, an alarm on it, an email on the alarm. Nightly database snapshots had failed three times in the week before the event, with the managed service unable to start them inside its window, while manual snapshots worked fine. The backup that worked was a manual snapshot after the event, exported, downloaded and verified locally. The event’s hotfix deploy failed because the deploy script read one setting from the shell instead of the event’s config file.

None of that hurt a contestant. All of it is now a checklist in the repo, filed as lessons from the first real event, because the next organizer should get those alarms from the module, not from someone improvising at a folding table.

About twenty issues came out of the three days, filed on October 10 and 11. Most were fixed within the week: the tie-break, the story carry, invisible names, a way for organizers to add or remove a team’s points with a reason, a QR code of the event address on the projector board, solve counts on the Jeopardy board. A few are still open, the leaderboard cache among them; it only affects a box that runs the app as more than one task, and a single machine never sees it.

What a Real Event Is For #

Until this month the README’s status line said the kit had “not yet run for a real cohort”. That was the honest gap. Now it reads “v1.0, proven at a real event”, with this event’s numbers underneath. Mocks prove the pipeline works. A rehearsal proves it works on the real topology. Only a room full of people who want to win proves the rules are what you think they are.

The scoring never lost a point. The ranking briefly got the podium wrong, in a way no mock would have produced, because no mock thought to finish two modules twenty four seconds apart.

Thanks to the Red Team Space crew for trusting a first event to the kit, for building Operation Last Mile with me, for a Jeopardy board people threw 103 attempts at, to Ekoparty for the space, to Versprite for sponsoring, to Bhushan Agrawal for the fixes and refactors that landed around the event, and to everyone who played. Especially the three teams who made me read timestamps to the millisecond.

A week after the closing, the kit shipped v1.0.0, its first stable release. Most of what changed since v0.7.0 came out of preparing for this event and running it: the tie-break, scoring timed by the comment, score adjustments, scheduled Jeopardy releases, first blood on the board, event time zones.

The OWASP CTF in a Box logo: a fox curled around a server box with a flag on top, next to the words CTF in a Box, Capture the flag, practice anywhere

The kit is open source under the OWASP Foundation. If you run a chapter, a course or a club, it is built for you to stand up yourself. The project page has the overview, and the repository has the two-minute quickstart and everything else.