MiscFortID 2026
Spill the Tea
A phone photo of a QR code buried under loose tea leaves. QR's Reed-Solomon code is linear over GF(2), so every readable module is one equation, and solving the system recovered the payload where byte-level erasure decoding was hopeless.
What this one teaches
- A QR code's Reed-Solomon code is linear over GF(2) at the bit level, so each visible module gives one linear equation even when its codeword is mostly hidden.
- Information-set decoding (solve from a subset of equations, count the violations among the rest) for a noisy linear system with a few wrong bits.
- Using structure as known plaintext (the mode indicator, the flag prefix, ASCII top bits, padding) and not assuming the padding follows the textbook.
- Classifying modules as white, black or unknown with colour cues rather than brightness, and re-encoding the result to compare against every observed module.
Hints
Stuck? Open one hint, then go back and try again before reading the next.
Hint 1 of 3Show
Do not write a codeword off because some of its modules are hidden. What does a single known bit tell you about the data?
Hint 2 of 3Show
Pick the format information (error-correction level and mask) by testing all 32 words against the modules you can read.
Hint 3 of 3Show
If the last block refuses to fit, check whether the padding bytes are where the standard puts them.
Full solution below. Spoilers ahead.
Task description
Oh no! Our recent hire spilled some tea over flag. Hope you can still read it…
The only handout is flag.jpg, a 2304 by 4096 phone photo taken with the flash on (the EXIF names a HONOR PTP-N49). There is no service. It shows a printed QR code with “SCAN FOR FLAG” under it, with loose-leaf tea, cornflower and marigold petals poured over it. A thick pile covers the middle of the symbol, and thinner strips lie over the top-left finder pattern and over both format-information strips (row 8 and column 8).

How a QR code is laid out, for readers who have never taken one apart
A QR symbol is a grid of black and white squares called modules. The three big square bullseyes in the corners are finder patterns, used to locate and orient the code; smaller alignment patterns and the dotted timing rows keep the grid square. Two short strips next to the finders hold the format information: five bits (expanded to 15 with error correction) that say which error-correction level is in force and which of eight masks was applied. A mask is a fixed checkerboard-like XOR pattern laid over the data so the symbol has no large plain areas.
The rest of the modules carry the message and its error-correction bytes. QR protects the data with a Reed-Solomon code: redundant bytes that let a scanner repair a symbol that is scratched, dirty or partly missing. That redundancy is why a damaged code often still scans, and it is exactly the structure this solve leans on. The redundancy is not secrecy, though, which is the defensive point at the end: hiding a code by smudging it does not hide what it says.
Things that gave nothing
The EXIF holds ordinary camera data. The 181 bytes after the JPEG end marker (FFD9) are the phone’s vendor trailer (it carries HiHonor_EhdrEn and HiHonor_ExtInfo blobs, the HDR gain-map metadata), not hidden data. Both pyzbar and OpenCV’s QRCodeDetector fail on the photo outright, which is what makes this a reconstruction problem rather than a scan.
Geometry
The top-right finder pattern is clean. From the four outer corners of the symbol in the photo, (198,757), (2112,756), (2172,2670) and (172,2692), the code spans roughly 1914 px across 41 modules, so a module is about 47 px in the photo and the symbol is 41 by 41, which is QR version 6. The alignment pattern at module (34,34) is visible and confirms it. The paper is tilted slightly, so I fitted a homography (a perspective transform) from those four corners and warped the code to a 41 by 41 grid at 40 px per module. Overlaying the grid shows every visible module edge lining up.

Classifying modules as white, black or unknown
Dark tea leaves have the same brightness and nearly the same texture as printed toner, so a plain “dark means black” rule misreads 18 known function-pattern modules, every one of them tea read as black. The classifier I settled on uses colour on the inner 24 by 24 pixels of each module:
| Class | Rule |
|---|---|
| white | at least 25% of pixels are white paper or pink |
| black | at least 60% neutral dark pixels (abs(R-B), abs(R-G) under 9), at most 5% brown tea pixels (R-B >= 13), and no white |
| unknown | anything else |
The pink is the useful cue. Where the flash passes through thin tea onto white paper, the paper glows salmon-pink (about RGB 183,91,79), while on black toner it stays dark. A pink pixel in a gap between leaves therefore means white paper underneath. Checked against the finder, timing and alignment patterns, this classifier gets 2 of 167 known modules wrong, both on the tea-covered top-left finder. The price is that 906 of 1681 modules come out unknown.

Why normal decoding is hopeless
Treating any codeword with an unknown bit as an erasure leaves 136 of 172 codewords erased. Reed-Solomon can only repair so much: at level H each 43-codeword block tolerates 28 erasures, far below 136. The format information is also fully hidden, so at this point I did not even know the level or the mask.
Every visible module is an equation
A Reed-Solomon code over GF(256) is linear over GF(256). Since GF(256) is a vector space over GF(2), the same code is also linear over GF(2) at the bit level. For a block with k data bytes, the 8k data bits determine every codeword bit through a fixed binary generator matrix, and applying the mask is just an XOR with a constant. So each module I can read gives one linear equation over GF(2), even when the rest of its codeword is under tea. Byte-level erasure counting throws that partial information away. Per 43-byte block I had roughly 135 to 157 observed bits, against 152 unknowns at level Q (19 data bytes) or 120 at level H, which is a solvable system rather than a hopeless one.
A handful of misread modules make the system inconsistent, so the solver uses information-set decoding (Prange’s method): repeatedly pick a full-rank subset of the equations, solve it, and count how many of the other equations the solution violates. The best solution has the fewest violations, close to 0 for the right format and 15 to 30 for a wrong one.
Known plaintext adds more equations, each a unit equation on the data bits:
- the byte-mode indicator
0100, - the prefix
FortID{(56 bits), - printable ASCII, so bit 7 of every character is 0,
- the terminator and padding.
In byte mode, character i sits at data bits 12+8i to 19+8i (after the 4-bit mode indicator and the 8-bit length), and data bytes are split sequentially into blocks (at level Q, block b holds bytes 19b to 19b+18), so each fact lands in exactly one block’s system.
Validating the machinery before trusting it
I built a synthetic version 6-H QR with segno, applied the photo’s exact unknown-module pattern to it, and ran the solver. It recovered the text with 0 inconsistencies, and only for the correct mask. That showed the module placement order, masking, interleaving and generator matrix were all right before any real data went in.
Finding the format information
There are only 32 format words, so I tried each on block 0 using the readable modules plus 0100 and FortID{:
H 3 cons 195 viol 16 len byte 71 <- best H, still bad
Q 2 cons 195 viol 15 len byte 149
Q 3 cons 195 viol 1 len byte 70 <- clear outlier
Q 7 cons 195 viol 14 len byte 121
Level Q with mask 3 fits 194 of 195 equations, and its length field reads 70, which matches a 70-byte flag.
Blocks 0 to 2, and the padding gotcha in block 3
With the ASCII constraints added, blocks 0 to 2 each solve uniquely with at most one discarded module:
block 0 constraints 214 violations 1 nullity 0
block 1 constraints 170 violations 1 nullity 0
block 2 constraints 176 violations 0 nullity 0
Block 3 refused to fit. The textbook tail is } at character 69, a terminator, then the pad bytes EC 11 EC 11 over bytes 72 to 75. That forced 12 violations, including modules that are clearly visible. Five of them made up one 2 by 4 module group, codeword 67, which is pad byte 73, and its unmasked reading 11?0110? fits 0xEC, not the expected 0x11. So the encoder wrote one extra 0x00 byte after the terminator (byte 72), and the padding then runs EC 11 EC over bytes 73 to 75. With that as a constraint, block 3 solves with only 3 violations:
cq hard 45 obs 156 best viol 3 ...6_Bu7_7h3_T34}\x00 ...5f5433347d000ec11ec
The solver’s ending was the tail of the flag, }, a 00 byte, then ec 11 ec. Endings I had guessed from the theme of the flag each contradicted 40 to 50 observed modules and were rejected, so the ending came from the solver and not from a guess.
Verifying by re-encoding, and the submission that came back rejected
I re-encoded the recovered data (the flag text plus 00 EC 11 EC) into all 172 codewords with mask 3 at level Q, and compared it against the photo:
observed vs rebuilt per block [known, mismatches]: [[135, 1], [151, 1], [157, 0], [156, 3]]
That is 594 of 599 observed module bits matching, with the five misses all on tea-covered modules that are easy to misread (petals, a dark leaf, a pink glow from a neighbouring square). Rendering the rebuilt matrix and scanning it with zbar decoded cleanly to a 70-byte byte-mode string.

The one wrinkle was the scoreboard. My first submission of this string was reported rejected, which put the whole decode in doubt for a while. Rather than start guessing variants, I leaned harder on the re-encode evidence, and the string was later confirmed as the correct flag: the rejection had not been the decode’s fault. The checks that held up while it was in doubt are worth keeping:
Why the decoded string is the only one the photo supports
- Every single-character substitution contradicts at least 26 observed modules, against 5 for this string (
solution/robust.py). - Length-71 and standard-padding variants score worse, 41 to 82 contradictions (
solution/hyp.py). - The best of 364,500 multi-character leetspeak variants still contradicts 32 (
solution/variants.py). - The file holds no further layer: the GPS block is empty, and the embedded images are the phone’s HDR gain map and thumbnails.
The scripts for these live in solution/, with run.sh chaining the whole solve. The arithmetic is self-consistent: the per-block figures sum to 599 observed bits and 5 mismatches, the 594 reported.
The recovered string reads as a play on courtroom testimony (“the whole tea and nothing but the tea”), and it is the flag in the field below.
Defence
QR error correction adds redundancy and gives no secrecy. Covering or smudging a printed code, or blurring one in a screenshot, does not reliably hide its contents, because every module that survives is a constraint on the whole message. If a code carries something sensitive, destroy it or do not print it in the first place.
The flagReveal
FortID{1_Sw3ar_70_Sp1ll_7h3_T3a_Th3_Wh0l3_Te3_4nd_N0thin6_Bu7_7h3_T34}This CTF was possibly solved, and the writeup most likely written by an LLM. Apologies for inconsistencies.