Сломанный QR-код (Broken QR code)
Сломанный QR-код (Broken QR code)
Platform: hackerlab | Category: Misc | Type: Challenge | Difficulty: Medium | OS: NA | Author: XESXOR | Date: 2026-06-02 | Status: Solved Techniques: qr_reconstruction, reed_solomon_erasure_decoding, per_bit_reliability_recovery, format_info_bch_decode, codeword_deinterleaving, ec_constrained_bruteforce, full_symbol_reencode_verification
Summary
Task: a Version-4 QR code with an oversized opaque spider/shield logo covering the center; all standard decoders fail. Solution: rebuild the 33x33 module matrix, BCH-decode format info, de-interleave 4 blocks, Reed-Solomon erasure-decode the readable blocks, then recover the logo-covered block via per-bit reliability + EC-constrained brute force, and verify by full-symbol re-encode against all reliable modules.
Сломанный QR-код (Broken QR code) — hackerlab
Description
Мы хотели сделать брендированный QR-код, но, кажется, переборщили с размером логотипа...
English summary: We are given task.zip → task.png, a 300×300 RGBA PNG containing a QR code with a large branded logo (an orange shield plus gray spider legs) drawn over the center. The logo is opaque, so it physically destroys many data modules. Every off-the-shelf decoder (zbarimg, pyzbar, OpenCV QRCodeDetector) returns nothing. The goal is to recover the flag (format CODEBY{...}) by reconstructing the QR using its built-in Reed-Solomon error correction plus structural knowledge of the QR standard.
Analysis
The logo is opaque — gray legs are ~RGB(93,93,93) and the orange shield has R>180, B<120 — so the covered modules are genuinely lost, not merely tinted. No amount of image cleanup recovers them. The only path is QR's error-correction machinery.
QR geometry recovery:
- Quiet zone = 10px; the QR region spans pixels [10, 290] in both axes; module size ≈ 8.48px.
- Run-length analysis of a finder pattern (the 1:1:3:1:1 ratio) confirms the grid.
- 33×33 modules ⇒ QR Version 4.
- Sampling each module center (majority vote in a small window) yields a 33×33 binary matrix (1 = dark). Logo pixels (non-pure-black) initially read as light.
Crucially, while building the matrix we also build a per-module logo-coverage fraction map (logofrac.npy): for each module, what fraction of its pixels falls under the logo. This reliability map is the key to the whole solve.
Solution
Step 1 — Format information (BCH)
Both 15-bit format-info copies (around the finders) are read, XORed with the mask 0b101010000010010, and matched to the nearest BCH(15,5) codeword. Both copies agree:
- EC level Q, mask pattern 100 (mask 4).
- Mask 4 inverts a module where
((row//2 + col//3) % 2 == 0).
Step 2 — Block structure (Version 4-Q)
Version 4-Q has 100 total codewords, interleaved into 4 blocks of 25 codewords each (9 data + 16 EC) → 36 data codewords total + 7 remainder bits. Interleave order is round-robin: all block[i][0] data codewords first, then block[i][1], …, then EC codewords the same way.
Step 3 — Read stream, de-interleave, RS erasure decode
Unmask with mask 4, walk the standard zig-zag module order skipping function patterns, and de-interleave into the 4 blocks. A codeword is marked an erasure if any of its 8 bits is under the logo. reedsolo.RSCodec(16) with erase_pos=[...] corrects up to 16 erasures per block:
- block0: 12 erasures ≤ 16 → OK → data
2032344ae84504decc - block1: 16 erasures = 16 → OK → data
4ccccdd7d45497d8cc - block2: 22 erasures > 16 → fails
- block3: 20 erasures > 16 → fails (but it is pure padding)
Step 4 — Parse the partial message
De-interleaving the recovered data bytes and parsing the QR bitstream:
- Mode
0010(alphanumeric), length 6 →CODEBY - Mode
0100(byte), length 19 → flag body (19 chars incl. braces)
Blocks 0+1 give the first 11 content chars: {1337_QR_c0. Block3 decodes (structurally) to pure padding EC 11 EC 11 … — it lies past the message, so it needs no recovery. The unknowns are content chars 11..18 (8 chars) plus the closing }, all in block 2.
Step 5 — The key trick: per-BIT reliability
Block 2 was flagged with 22 erasures only because a codeword counts as erased if any of its 8 bits is covered. But the thin logo strokes touch only a few bits per byte — most individual bits are read reliably. Switching from per-codeword to per-bit reliability (a bit is uncertain only if its module's logo-coverage fraction > 0.5) collapses the unknown space. Enumerating only the uncertain bits of each content char, and keeping only printable flag characters, gives tiny per-char candidate sets:
11 ∈ {d,e} 15 ∈ {0,4,8,D,H,L,P,T,X,d,h,l,p,t,x}
12 ∈ {3,7} 16 ∈ {0,2,4,6}
13 ∈ {K,O,_} 17 ∈ {C,G,S,W,c,g,s,w}
14 ∈ {d,f} 18 = }
Total ≈ 17,280 combinations — trivially brute-forceable.
Step 6 — Constrain with EC bytes, then verify with full re-encode
Two block-2 EC codewords (positions 14 and 15) are reliably read. For each candidate, re-encode block 2 with RSCodec(16) and require those EC bytes to match. Two survivors remain: CODEBY{REDACTED} and CODEBY{REDACTEDOdh6W} (the latter matched the 2 EC bytes only by chance).
Final disambiguation — reconstruct the ENTIRE QR (all 4 data+EC blocks, re-masked) for each candidate and compare against every reliably-read module (coverage ≤ 0.3):
CODEBY{REDACTED}→ 475/475 reliable bits match, 0 mismatches ✓CODEBY{REDACTEDOdh6W}→ 23 mismatches ✗
The flag is leet-speak for "QR code flag" (c0d3_fl4g).
Winning solver (factored brute-force + EC filter)
python read-only • copy allowedimport numpy as np, qrlib, itertools from reedsolo import RSCodec N = 33 m = np.load('matrix2.npy') # 33x33 module matrix (1=dark) lf = np.load('logofrac.npy') # per-module logo-coverage fraction func = qrlib.build_function_mask() mf = qrlib.mask_func(4) # mask pattern 100 # zig-zag module order (skip function patterns) positions = [] col = N - 1; up = True while col > 0: if col == 6: col -= 1 for r in (range(N-1, -1, -1) if up else range(N)): for c in (col, col-1): if func[r, c]: continue positions.append((r, c)) up = not up; col -= 2 def rb(si, bp): r, c = positions[si*8 + bp] v = int(m[r, c]) if mf(r, c): v ^= 1 # unmask return v, float(lf[r, c]) # de-interleave: 4 blocks of 9 data + 16 EC blk_idx = {b: [] for b in range(4)}; idx = 0 for _ in range(9): for b in range(4): blk_idx[b].append(idx); idx += 1 for _ in range(16): for b in range(4): blk_idx[b].append(idx); idx += 1 LFT = 0.5 ALNUM = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:' # reliably-read EC bytes of block 2 ec_known = [] for jj in range(16): si = blk_idx[2][9 + jj] bits = [rb(si, bp) for bp in range(8)] if all(l <= LFT for v, l in bits): val = 0 for v, l in bits: val = (val << 1) | v ec_known.append((jj, val)) prefix = '{1337_QR_c0' # content chars 0..10 from blocks 0,1 cand = { 11: ['d','e'], 12: ['3','7'], 13: ['K','O','_'], 14: ['d','f'], 15: list('048DHLPTXdhlptx'), 16: ['0','2','4','6'], 17: list('CGSWcgsw'), 18: ['}'], } def build_data36(content19): bits = '0010' + format(6, '09b') # alnum mode, len 6 s = 'CODEBY' for i in range(0, 6, 2): v = ALNUM.index(s[i])*45 + ALNUM.index(s[i+1]); bits += format(v, '011b') bits += '0100' + format(19, '08b') # byte mode, len 19 for ch in content19: bits += format(ord(ch), '08b') bits += '0000' # terminator while len(bits) % 8: bits += '0' data = [int(bits[i:i+8], 2) for i in range(0, len(bits), 8)] pad = [0xEC, 0x11]; pi = 0 while len(data) < 36: data.append(pad[pi % 2]); pi += 1 return data[:36] rsc = RSCodec(16); sols = set() keys = [11,12,13,14,15,16,17,18] for combo in itertools.product(*[cand[k] for k in keys]): content = prefix + ''.join(combo) # 19 chars b2 = build_data36(content)[18:27] # block 2 data (9 bytes) ec = list(rsc.encode(bytearray(b2))[9:]) if all(ec[jj] == val for jj, val in ec_known): sols.add('CODEBY' + content) for f in sorted(sols): print('FLAG:', f) # Survivors filtered by full-symbol re-encode vs all reliable modules -> # CODEBY{REDACTED} = 475/475 reliable bits match