Matthew Dudley
← Writeups

Building a six-flag CTF into my portfolio

October 2026securitycloudflare workers

There are six flags hidden around this site. This is how the challenge is put together and how it is scored, with no spoilers, so the hunt still works after you read it.

A resume lists what you say you can do. A small CTF on the same site lets a visitor check. It also forced me to think about both sides at once: where a flag should hide to be fair but not obvious, and how to verify a submission without handing out the answer. That second half is the interesting engineering, and it is the part I can talk about freely.

Six flags, six kinds of looking

The flags are not six copies of the same trick. Each one rewards a different habit, so finishing the set means you looked in the places a real recon pass would look:

  • reading the page source instead of just the page,
  • checking the files a crawler is told about,
  • inspecting HTTP response headers,
  • looking at the cookies a site sets on you,
  • poking around the artifacts left in a client,
  • and one longer forensic puzzle that stands on its own.

They are ordered roughly easy to hard. The early ones take seconds if you know where to glance. The last one is a sit-down problem. Every flag has the same shape, m4t{...}, so you always know when you have found one.

Checking a flag without shipping the answer

The obvious wrong way to score a CTF is to put the answers in the page and compare in JavaScript. Anyone who opens the console wins all six at once. So nothing about the correct answers reaches the browser, not the flags and not even a list of valid ones.

Instead the server stores only the SHA-256 hash of each flag. When you submit a guess, it is trimmed, lowercased, hashed, and the hash is looked up against that set. A hash matches or it does not. The server answers with which flag number you solved, and never with anything that would help you work backward to a flag you have not found.

browser                         Cloudflare Worker
   |  POST /api/ctf/submit          |
   |  { "flag": "m4t{...}" }         |
   | -----------------------------> |
   |                        trim + lowercase
   |                        sha256(guess)
   |                        lookup in hash table
   |  { correct, id }               |
   | <----------------------------- |
   |                                |
   the flags and their hashes stay on the server; the
   browser only ever sends a guess and hears yes or no
The submit path. A one-way hash means the stored value cannot be turned back into a flag.

A one-way hash is the right primitive here. SHA-256 of a flag is cheap to compute and effectively impossible to reverse, and the flags are long and random enough that guessing the input is not a path either. To keep anyone from treating the endpoint like a brute-force oracle anyway, submissions are rate limited per address, to a small number per minute. That is not the real defense, it is just good manners for a public endpoint.

Whose progress is it

Your solved flags are tracked in your own browser, not in an account on my server. There is no sign-up and I am not collecting who solved what. It keeps the thing friendly to play and means the challenge holds no personal data, which is one less thing to secure and one less reason for anyone to care about the database behind it.

A forensic puzzle that builds itself

The hardest flag is a small digital-forensics scenario. It ships as a dataset of fictional process telemetry from a single workstation: a baseline of known-good software and a log of what actually ran one day. Hidden in the noise, one process is a lookalike. It carries a trusted name but runs from the wrong folder and has a hash that is not in the baseline. The work is the work a defender does: establish what normal looks like, then find the one thing that does not fit.

The dataset is generated by a script, and the generator is seeded, so every run produces byte-for-byte identical files. That determinism matters more than it sounds. It means the flag is fixed, the scenario is reproducible, and I can regenerate the whole thing from source instead of hand-maintaining a pile of log files. The generator is not served to visitors; only its output is.

What I would change

The rate limiter lives in each server instance's memory, which is fine for a hobby endpoint but would drift under real load. A shared counter would be the honest fix. I would also like a seventh flag that can only be reached by chaining two of the others, since the best CTF moments come from one finding unlocking the next. For a first pass, though, six flags across six kinds of looking does the job: it shows how I think about hiding things, and more importantly about checking them.


Try the CTF →