Neven Marić
FortID 2026

CryptoFortID 2026

Human RNG

A QR code of the flag was XORed with 1681 bits typed by a human. The key is biased, and Reed-Solomon makes the QR affine in the flag, so I scored candidate flag blocks by how human the implied key looks.

  • one-time-pad
  • qr-code
  • reed-solomon
  • bigram-model
  • statistical-attack
  • known-structure

What this one teaches

  • Why a one-time pad stops being one when the key is typed by a human, and how to measure the bias with a bigram model.
  • That a QR code is an affine function of its data bytes over GF(2), because Reed-Solomon parity is linear, so every candidate plaintext implies exactly one key.
  • How to split a search into independent blocks when error-correction blocks depend on disjoint parts of the message.
  • How to score a candidate against random candidates (a z-score) to tell a real hit from noise.

Hints

Stuck? Open one hint, then go back and try again before reading the next.

Hint 1 of 3Show

The 1999 public bits show how this particular human types. Measure how often a bit differs from the one before.

Hint 2 of 3Show

Candidate flag gives candidate key. Which candidate keys look like the public bits?

Hint 3 of 3Show

The QR splits into independent Reed-Solomon blocks, so you do not need to search all 44 characters at once.

Full solution below. Spoilers ahead.

Task description

You’ve heard about LCGs, LFSRs, MTs, Xorshifts, RDRANDs, Lavarands… But, can you crack a Human RNG? (watch human_rng_at_work)

What is handed out

File Contents
encrypt.py Encodes the flag as a QR code and XORs its modules with a keystream
bitstream.txt 1999 public bits typed by a human, followed by <1681-bits-redacted>
ciphertext.png The 41x41 encrypted QR code, scaled by 10
human_rng_at_work.mp4 A 3 minute phone video of someone typing on a keyboard

There is no remote service. encrypt.py builds a version 6, ECC level H, mask 0 QR code of the flag (41x41 = 1681 modules, no border), takes the last 1681 bits of bitstream.txt as the key (the redacted ones), XORs the modules in row-major order and saves ciphertext.png. It also states the shape of the flag body:

The handout ciphertext: the 41 by 41 QR of the flag after XOR with the human keystream, scaled 10x. The finder patterns and all QR structure are gone, leaving noise that will not scan

# The flag body is lowercase English words separated with underscores.
assert len(body) == 44
assert re.fullmatch(r"[a-z]+(?:_[a-z]+)+", body)

So this is a one-time pad whose pad was typed by a human. The goal is the 44 character body without the redacted key bits.

The human is a biased RNG

A one-time pad is unbreakable only when the key bits are uniform and independent. These were typed by a person, so they are neither. Measuring the 1999 public bits:

Statistic Public bits Ideal RNG
P(next bit differs from the previous one) 0.702 0.5
Lag-1 autocorrelation (bits as +1/-1) -0.41 0
Run lengths 1/2/3/4/5/6 998/254/117/29/4/1 geometric with ratio 1/2
Held-out cross-entropy of a bigram model about 0.86 bits per bit 1.00

People avoid repeats and alternate too much. A bigram model (the probability of each bit given the one before it) captures almost all of the structure. Trained on the first half of the public bits and scored on the second, order 1 reached 0.86 bits per bit, and orders 2 and 3 did slightly worse (0.87 and 0.88), so there is nothing beyond the immediate neighbour to model. Autocorrelation is near 0 past lag 3 and LZMA barely compresses the bits. Each key bit therefore carries about 0.14 bits of non-randomness, which adds up to well over a hundred bits of evidence across the 1160 modules that depend on the flag.

The bigram model, and how a candidate is scored

The model is a 2x2 table of log-probabilities L2[prev][cur], Laplace-smoothed, from adjacent public bits. A candidate key’s score is sum over i of L2[k_{i-1}][k_i]. A key that alternates about 70% of the time, like the human’s, scores high under this table; a uniform random key scores low. That gap is the entire signal the attack rides on.

Which modules are already known

Many modules do not depend on the flag body: the finder, timing and alignment patterns, the format information for (H, mask 0), the dark module, and the encoded constants (mode indicator, length, FortID{, }, terminator, the EC 11 padding). Reading the ciphertext and XORing against the all-a QR shows exactly 521 such positions out of 1681, with the remaining 1160 depending on the body. At those 521 positions key = ciphertext XOR qr is known exactly, and it already looks human (for example 101010101).

The QR is affine in the flag, and the blocks are independent

Reed-Solomon parity bytes are a GF(2)-linear function of the data bytes: multiplication by a constant in GF(256) is linear over GF(2) and addition is XOR. The fixed mask and function patterns cancel under XOR. So the whole QR matrix is an affine function of the flag bytes. I precomputed 44 x 27 delta matrices D[j][v] = QR(body with char j = v) XOR QR(base) and confirmed on random bodies that XORing single-character deltas reproduces a multi-character change exactly. For a candidate body:

key = ct XOR M0 XOR (XOR over j of D[j][body_j])

Then I score how human that key looks. Version 6-H splits the data into 4 Reed-Solomon blocks of 15 data and 28 error-correction codewords. Each block’s modules depend only on its own 7 to 15 characters. The 4-bit mode-indicator offset makes body characters 6, 21 and 36 each straddle two blocks:

Block Body characters scored
A 0 to 5, plus the high nibble of 6
B 7 to 20, plus the low nibble of 6 and the high nibble of 21
C 22 to 35, plus the low nibble of 21 and the high nibble of 36
D 37 to 43, plus the low nibble of 36

Each block is scored on its own, summing L2[k_{i-1}][k_i] over adjacent key bits whose values this block (plus the fixed modules) determines. Neighbours in other blocks are unknown, which adds the same constant to every candidate, so it drops out of the ranking.

Why a character-level hill-climb finds nothing

A simulated annealing over all 27^44 bodies found only noise (best around -1598, against around -1430 for the real key). Changing one character flips about 28 times 8 parity modules pseudo-randomly, so the score has no gradient to climb. The right answer is a needle, and the search has to enumerate structured candidates, English word sequences, block by block.

Dead end: the video

The video has roughly 1600 to 1700 audible keystrokes (about 10 per second over 175 s), close to the 1681 redacted bits, so I tried classifying each press as 0 or 1 by sound. I tried onset detection with a press/release split, k-means and GMMs on log-band spectra, narrow resonance bands near 6 and 11 kHz, stereo time and level differences, pairwise waveform cross-correlation and a learned projection that maximises lag-1 anti-correlation. I also ran a supervised offset scan against key row 6, which is fully known (41 bits). The best result was about 55% label alternation, and nothing held up on held-out data. The hand covers the keys on camera, so the video’s only real hint is that a human made the key.

Block A: the first seven characters

I enumerated underscore-joined word sequences from the top 20,000 English words (wordfreq), trimmed to 7-character prefixes, and scored every one against block A. Re-running that search offline:

$ python solve.py search-prefix
humans_  -256.4
jc_q_sf  -268.3
jc_q_si  -268.3
...
26199562 candidates, mean -307.8 std 8.1

humans_ is the single best of 26,199,562 prefixes, beating the runner-up by about 12 bits and sitting 6.4 standard deviations above the mean. This is the one step that needed the long search; everything after it was cheap.

Blocks C and D: a guessed phrase confirms them

Given humans_ and the task’s theme, I guessed humans_are_terrible_random_number_generators and scored all four blocks against 20,000 random bodies (the “random” and “max” columns):

block A:  -256.4  random -309.2±8.3  max -275.1  z=6.4
block B:  -372.4  random -369.9±8.7  max -330.9  z=-0.3   <- wrong
block C:  -319.0  random -382.7±9.5  max -339.3  z=6.7
block D:  -267.2  random -309.5±8.4  max -273.2  z=5.1

Blocks A, C and D were strongly significant and block B scored like noise. That confirmed ..andom_number_generators across the last 23 characters and left 12 unknown characters in the middle (body[7..18]), between the first word and the start of the last words.

Block B: the middle twelve characters

A systematic search over one to three word combinations was running. In the meantime I scored guesses by hand against block B:

Middle (12 characters) Block B z
are_terrible -0.3
arent_secure -0.3
are_horrible -0.6
are_insecure 1.3
are_not_good 5.6

are_not_good was my own guess, and its block B score (-321.1, above the random max of -330.9, z = 5.6) singled it out. I stopped the systematic search before it would have reached that phrase on its own. Checking the full body gives four significant blocks:

$ python solve.py check <44-character body>
block A:  -256.4  random -309.2±8.3  max -275.1  z=6.4
block B:  -321.1  random -369.9±8.7  max -330.9  z=5.6
block C:  -319.0  random -382.7±9.5  max -339.3  z=6.7
block D:  -267.2  random -309.5±8.4  max -273.2  z=5.1

Checking the whole key

Computing key = QR(flag) XOR ciphertext for the recovered body:

Public bits Recovered key
Alternation rate 0.702 0.724
Fraction of ones 0.475 0.485
Bigram log-likelihood about -0.86 bits per bit -0.851 bits per bit

The total bigram score is -1430.9. Filling the flag-dependent modules with random bits instead gives about -1788 with a standard deviation near 24, so the real key sits roughly 15 standard deviations out, and all 521 fixed modules agree with the key read directly off the ciphertext. The recovered key is statistically the same human who typed the public bits.

The plaintext QR rebuilt from the recovered flag body, rendered clean: an ordinary scannable version 6 code, which is what the ciphertext becomes once the human keystream is XOR-ed back out

The solver’s two entry points are python solve.py search-prefix and python solve.py check <44 character body>. It needs qrcode, pillow, numpy and wordfreq. There is no endpoint to submit to, so the recovered body (shown only in the flag field) is the result.

Takeaways

Even a person trying to be random alternates about 70% of the time, and a human keystream is not a one-time pad: use a CSPRNG. Small per-bit bias adds up, especially against a plaintext as structured as a QR code, whose function patterns and fixed prefix expose key bits directly and whose Reed-Solomon parity is a deterministic linear function of the data rather than more randomness. The independent blocks turned a 27^44 search into four small ones, and checking each hypothesis against the known key bits early is what kept the acoustic dead end from eating more time.

The flagRevealFortID{humans_are_not_good_random_number_generators}

This CTF was possibly solved, and the writeup most likely written by an LLM. Apologies for inconsistencies.