Interactive Walkthrough — Applied Cryptography

Capture the Flag:
Resume Edition

There is a capture-the-flag trail sitting in front of my résumé, running the same AES-GCM as the real gate. One of them falls in seconds. You can break it yourself, below, in this tab.

My résumé lives at /resume as AES-GCM ciphertext. The site is static (GitHub Pages, no backend), so there’s nobody in the request path to check who’s asking. Encryption is the only gate there is. A crawler that fetches the file gets bytes.

Behind that gate I put a second door. It’s a three-stage trail at /ctfthat ends in the same passphrase, built for the reader who’d rather take the word than ask for it. Stage two is cryptographically identical to the real payload. Same AES-256-GCM, same PBKDF2-SHA256, opened by the same client code, byte for byte the same path.

One of them falls in seconds. The other doesn’t. Nothing about that difference is cryptographic. It comes down to a single property that has nothing to do with the algorithm, and it’s the property most client-side encryption gets wrong.

Rather than ask you to take that on faith, the next panel builds a working replica of stage two in your browser and hands you the loop.

I

Break it yourself

Everything below is real. The payload is generated in this tab with WebCrypto: random salt, random IV, and a 256-bit AES-GCM key derived by PBKDF2-SHA256 from a passphrase picked at random out of the candidate list. Nothing is faked or pre-computed, and the timings are your machine’s.

The only thing I’ve handed the attacker is what my real trail gives away too. The list the key came from.

Exhibit A — stage 2 replicaidle
Press “Run the sweep” — every guess below is a real PBKDF2 derivation and a real AES-GCM tag check.
Tried0
Keyspace90
Elapsed0.0s
Derivations/s—
Key entropy6.5 bits

No cryptanalysis, no side channel. A loop over a list, and an authenticated cipher that says “yes” the moment you are right.

Push the skills slider to 20 and the same sweep takes about nineteen times as long. That’s the real trail’s keyspace, 380 ordered pairs. Now try the iteration slider instead. The total climbs, slowly, and every legitimate reader of my résumé pays that cost on every unlock. Section III draws the trade.

II

The trail, end to end

Three stages, all served from one origin as static files. No scanning, no fuzzing, no request loops pointed at my host. Everything a solver needs is already public when they arrive. The filename gets computed rather than guessed, which is the line between a puzzle and a directory brute-forcer.

There are two ways in, both deliberate. My robots.txt disallows /ctf/, which keeps compliant crawlers from turning “solve this” into “search for the writeup,” and doubles as the breadcrumb for anyone who reads robots.txt to see what wasn’t meant for an index. The other door is the devtools console on a locked /resume, where the page prints the algorithm, the iteration count and the trailhead.

Both are places engineers already poke around, and neither is anywhere near a recruiter who just wants to read a résumé. The console banner is deliberately inert: it logs and nothing else. No fetch, no storage, no globals. The tests assert that a locked /resume touches the network zero times, and a bit of showing off was not going to be what broke that.

robots.txtdevtools consoleSTAGE 0/ctf/README.txtbase64 seedSTAGE 1SHA-256/ctf/<16 hex>.jsonAES-GCM envelopeSTAGE 2“Languages & Technologies”rendered in public on /resumesupplies every candidate key380 triespassphrase+ flagSTAGE 3/resumegate
The dashed box is where it goes wrong. That payload’s key comes from a list the protected page renders in public. Every other edge in this diagram is ordinary, correct cryptography.
III

Which lever actually moves?

Total attacker work is candidates × cost-per-guess. Put both terms in bits and the argument stops being rhetorical. Doubling either one adds exactly one bit, so on paper they look symmetric.

They aren’t symmetric, and the difference isn’t in the math. It’s in who pays. Drag both sliders.

Exhibit B — the two leverslive
Keyspace entropy 8.6 bitsKDF work factor 19.2 bits
Attacker total27.8 bits
Every honest unlock600 ms
Sweep on one GPU< 1 s
Enumerable. At 380 candidates an attacker can simply list every key. The KDF is charging them 19.2 bits and charging your readers 600 ms per unlock, for a secret that gets found regardless. This is my trail.

The keyspace bar is free. Widening the real trail from 20 candidates to 380 took one line of code, flatMap in place of map. It cost me nothing and cost a solver 4.25 more bits. The KDF bar is rented. Every bit on it gets paid again by every reader who legitimately unlocks the page, forever, on the slowest phone they own.

That ceiling is why a KDF can never be the answer on its own. It buys a constant factor bounded by your users’ patience. Entropy is the only term here that scales without a bill attached.

Stretch a guessable secret and all you get is a slow break.
IV

Four preconditions

An offline break like this needs four things true at once. My trail hands over all four, and so does most client-side encryption I’ve seen shipped. Switch any one off and watch the verdict change.

Precondition 1
Attacker holds the ciphertext

Static file, public host. Fetch once, and every guess after that runs on their hardware. Nothing rate-limits, locks out, or even logs the attempt.

Precondition 2
A correctness oracle, free

AES-GCM is authenticated: a wrong key fails the tag and throws, a right one returns plaintext. Every candidate is a clean boolean.

Precondition 3
The keyspace can be listed

The key is not random. It comes from a finite set printed on the page it protects. Nobody guesses the secret; they enumerate its source.

Precondition 4
No cost that grows

The toll per guess is identical on attempt 1 and attempt 10,000. No lockout, no backoff, no second factor.

Broken

All four hold. The sweep in Exhibit A runs start to finish, offline, unobserved, which is exactly the situation my trail is in.

Look at what’s not on that list. AES-256 isn’t weakened. PBKDF2 isn’t misconfigured. The salt is random, the IV unique, the tag verified. Every primitive does its job correctly and the payload opens anyway, because correct primitives were never what protected it.

V

The iteration-count trap

When the original stage two fell in 0.47 seconds, my first instinct was to crank the iteration count until the sweep hurt. I didn’t, and the reasoning matters more than the puzzle does.

The résumé payload runs 600,000 iterations, OWASP’s current floor for PBKDF2-HMAC-SHA256, about 0.6s in a browser. It ran at 310,000 until recently, which was the floor when I built it. That upgrade bought 0.95 bits, and every legitimate reader pays for those 0.95 bits on every unlock. The one-line keyspace change bought 4.25.

Raising the trail’s count would have been worse than useless. Both its README and its prize argue that stage two falls because its key is enumerable, pointing at the real payload’s higher cost as the thing stage two lacks. Push stage two past the real payload and the exhibit starts claiming a slow KDF is what saves you, which is the exact misconception it exists to kill. There’s now a build-time test that fails if the trail’s iteration count ever creeps past the real one. Mostly that’s a guard against future me, quietly inverting the lesson some evening.

PropertyTrail stage 2Real résumé payload
CipherAES-256-GCMAES-256-GCM — identical
KDFPBKDF2-SHA256PBKDF2-SHA256 — identical
Client codethe résumé decryptorthe same file, unmodified
Iterations100,000600,000
Key sourceA list printed on the page it protectsA word published nowhere
Keyspace380 — fully enumerableNot enumerable from anything shipped
Result~13s average in a browserThere is no sweep to run

Six of seven rows are identical or favor the puzzle. The two that differ are the only two that decided anything.

VI

Where you will actually ship this

The trail is contrived. The pattern underneath it isn’t. This is one of the more common ways a real system ends up with an encryption feature that protects nothing — OWASP files the family under A02: Cryptographic Failures — and every item below is those same four preconditions in different clothes.

  • PIN-protected share links. “Anyone with the link and the 6-digit code opens this.” Blob on a CDN, decryption in the recipient’s browser, code space of 10⁶ ≈ 20 bits. Offline, with the GCM oracle attached. Minutes of work, and your server logs zero failed attempts because it never sees them.
  • Password-protected archives and documents. An encrypted ZIP, 7-Zip, RAR or Office file mailed out, locked with a password a human picked to remember. The moment it leaves your control, guessing is free. John the Ripper and hashcat have carried dedicated modes for two decades (11600 for 7-Zip, 17210–17230 for PKZIP, 9400–9600 for modern Office).
  • Secrets “encrypted” inside a shipped bundle. API keys sealed under a key derived from the bundle identifier, the app version, a device-model string, or a constant three files over in the same repo. If the key material ships next to the ciphertext it is obfuscation, not encryption, and the keyspace is one. MITRE calls it CWE-798.
  • Client-side vaults behind a short PIN. Note apps, extensions, offline-first tools encrypting local data behind a 4–6 digit unlock. The threat model is “someone finds my phone.” It rarely covers copying the encrypted store off the device and working on it at a desk, where 13–20 bits evaporate.
  • HS256 JWTs signed with a chosen secret. A token handed to a client is a signed message and a free verifier in one envelope. Per PortSwigger’s JWT material, hashcat (mode 16500) re-signs the token’s own header and payload with each candidate secret and stops when a signature matches. If your signing key is a word, it’s already cracked. Watch the algorithm-confusion variants (alg:none, RS256→HS256) while you’re in there.
  • Wi-Fi pre-shared keys. aircrack-ng exists to capture one WPA handshake: deauth to force it, sniff the reconnect. After that the whole attack is offline, against whatever passphrase somebody thought up while standing at the router (hashcat mode 22000). The protocol is sound. The entropy usually isn’t.
  • Anything you rotated after publishing. Ciphertext that reached a CDN edge, an archive crawler or a git object is public permanently. Rotating the plaintext later doesn’t claw back the copy somebody already holds. All it changes is which version they open when the break finally lands.
The one-question test

Every case above answers yes to one question. Could I write down the complete set of possible keys using only information I publish?

If yes, you don’t have a gate. You have a delay, and its length is set by how badly the reader wants in and what hardware they own. A 10⁶-member set doesn’t save you. Neither does a third of a second per guess.

VII

Remediation, in priority order

Ranked by what they buy. Only the first two change the shape of the problem; the rest is hygiene that starts mattering once those two are handled.

  • Gate on a server if one exists. Sealing something client-side turns an access-control problem into a password-cracking problem, and cracking happens offline, unmetered, somewhere you can’t watch it. A server rate-limits, locks out, expires, revokes and logs. Reach for client-side encryption when there genuinely isn’t a server, which is my situation on static hosting, and not because it sounds tougher.
  • Generate the secret; never let a human or a list choose it. Entropy has to come out of a CSPRNG, full stop. A 32-hex-char token is 128 bits and ends the conversation on the spot. Six words from the EFF diceware list lands around 77 bits, still far past anything anybody is going to sweep. What doesn’t count: a word, a PIN, a name off a published list, or anything derived from data you also serve. Those aren’t secrets. They’re indexes into a set your attacker can rebuild.
  • Audit the derivation, not the algorithm. Follow the key back to wherever it came from and ask what constrains it. “PBKDF2-SHA256, 600,000 iterations, AES-256-GCM” sails through any checklist you point at it and tells you nothing about whether the input was one of twenty strings printed on the page.
  • Prefer a memory-hard KDF, and know its ceiling. Argon2id (OWASP’s first choice: m=19 MiB, t=2, p=1) or scrypt make every guess cost memory as well as cycles, which is what kills the GPU advantage that makes PBKDF2-SHA256 so cheap to sweep. A much better constant factor. Still a constant factor. And it’s worth saying why this site doesn’t use one. WebCrypto implements PBKDF2 and nothing else, so Argon2id in a browser means shipping a WASM build to every reader. PBKDF2 at OWASP’s 600,000 is the current standard available at this particular seam, which is a constraint I’d rather state than quietly work around.
  • Salt per secret; never reuse an IV under one key. One salt across several files under a single passphrase is fine, and it saves a derivation. That’s what my résumé bundle does, with a distinct IV per file. Sharing a salt across users is the dangerous version, because then one precomputed sweep hits everybody at once. And don’t reuse an IV under a single GCM key. That one doesn’t weaken the cipher gradually. It breaks it, which NIST SP 800-38D is unusually blunt about.
  • Don’t publish the candidate set. It sounds obvious and it happens constantly. The key turns out to be the codename in the README, or the launch date off the marketing page, or the customer’s own subdomain. Kerckhoffs’s principle — the system may be public, the key may not. Being non-secret isn’t the bar. Being unlisted is.
  • Assume every failed attempt is free and silent. If your safety story leans on somebody getting bored, or a lockout firing, or you noticing the traffic, go confirm any of those exist on the path an attacker actually takes. In an offline break, none of them do.
  • Write the threat model down and check the design against it. Mine’s written down. Keep the résumé away from scrapers, crawlers and training sets, and make a determined human spend real effort to get in. The encryption delivers the first one completely. The trail gives up the second on purpose.
VIII

The five-question audit

Point these at anything in your codebase that decrypts in a client. One answer landing the wrong way is enough.

  • 01Can the ciphertext be obtained without authenticating?If they can, every guess after that is offline, and none of your server-side controls are on that path.
  • 02Where did the key material come from — a CSPRNG, or a human, a list, or a string you also publish?Only the first one is a secret. The rest are indexes into a set somebody can rebuild.
  • 03How many candidates could you write down from public information alone?If you can name the number, that number is your real key length. Put it in bits and compare it to 128.
  • 04Does a wrong guess fail cleanly and cheaply?Authenticated ciphers hand the attacker a perfect oracle. That’s correct design. Just don’t bank on ambiguity to slow anybody down.
  • 05If this ciphertext were public forever starting today, what would it cost you?Assume it already is. CDN caches, archive crawlers and git objects don’t honor a rotation.
IX

The deal this makes

I’d rather write this down myself than have someone point it out to me. Anyone willing to spend half an hour can now reach my gated résumé. Metrics, contact block, PDF, all of it. That’s the deal, and I made it knowingly.

Scrapers and crawlers still get ciphertext, which was always the actual goal. The trail filters for effort. It was never a lock. One flag on the encrypt script drops it entirely if I ever want the gate invite-only again.

Everything regenerates on every re-encrypt: new salt, new stage-two key, new flag. A published solution goes stale on the next run. That’s less about spoiler discipline than about a property I wanted from the start. A trail that’s only solvable on average isn’t solvable. So the test suite solves the whole chain end to end across several rotations, and fails the build if any one of them turns out to be a dead end.

What I want a solver to leave with is the only part that generalizes, and it’s the same thing Exhibit A just did on your machine. They recovered a 256-bit AES key and never touched AES. The cipher was never the weak part. In my experience it almost never is. The weak part sits upstream of the algorithm, in where the key came from, and that’s the line to audit first.

X

Further reading

Primary sources behind the walkthrough. If you read two, make them the OWASP storage sheet and NIST 800-63B — between them they cover most of what teams actually get wrong here.

OWASPPassword Storage Cheat Sheet

Current parameters: Argon2id m=19 MiB/t=2/p=1, scrypt N=2¹⁷, bcrypt work factor ≥10, and PBKDF2-HMAC-SHA256 at 600,000 iterations — what my payload uses.

NIST · SP 800-63BDigital Identity Guidelines

Rev 4: 15-char minimum for single-factor, composition rules prohibited, blocklist checks required, a 100-attempt rate-limit cap, salted and hashed storage against offline attack.

NIST · SP 800-38DGalois/Counter Mode

The GCM spec, including the IV-uniqueness requirement and exactly what an attacker recovers when a nonce repeats under one key.

RFC 8018PKCS #5: PBKDF2

The function this whole thing leans on, defined in a couple of pages, with the salt and iteration-count reasoning stated plainly.

RFC 9106The Argon2 Family

The recommendation for new work, and the clearest explanation of why memory-hardness blunts a GPU where more iterations do not.

MDNWeb Crypto API

Everything Exhibit A is built on — deriveKey, encrypt, getRandomValues, and the secure-context rules that govern them.

MITRE · CWE-916Insufficient Computational Effort

The formal name for what stage two demonstrates, cross-linked to the hard-coded and guessable-credential entries.

LatacoraCryptographic Right Answers

Opinionated and readable — the fastest way to check whether the primitive you reached for is the one a specialist would have picked.