$ cat writeup.md…
$ cat writeup.md…
Hack.lu2026
Task: A client-side WebAssembly nonogram page displays flag-shaped strings for millions of indexed 7x7 puzzles, including deliberately impossible cards. Solution: Reverse its clue and bitstring functions, enumerate hint-satisfying grids, and independently verify the decoded result.
After the success of Every 5x5 Nonogram and Every 5x6 Nonogram, we bring you: Every 7x7 Nonogramflag
The organizers also referenced the Every 5x5 Nonogram and Every 5x6 Nonogram projects. The challenge page at https://nonoflag.work/ presents an enormous scrolling collection of 7x7 nonograms, each displaying a flag-shaped string. Identify the puzzle and filled-cell arrangement that produce the intended result.
The page serves a static JavaScript application (nono.js) and a WebAssembly module (nono.wasm), without a submission endpoint. JavaScript computes TOTAL = wasm.seedCount() * 1000; the WASM export returns 2427, so there are 2,427,000 indexed cards. The client calls generateByIndex(index) for row and column hints and cellBits(index, mask) for the displayed string. The mask sets bit 7*r+c when cell (r,c) is filled; crossed and untouched cells both contribute zero.
wasm-decompile nono.wasm exposes the small exported functions even though the module contains large data sections:
A seed array at linear-memory offset 0x1000 supplies one 64-bit seed per thousand cards. generateByIndex advances a xorshift64 state by index % 1000, turns seven 7-bit pieces into row clues, and permutes clue patterns to make column clues.
A bitmap at 0x10000 marks 50,263 card indices. For marked indices, generateByIndex overwrites the first column hint with [7]. Those cards are decoys: the replacement makes the total number of filled cells demanded by the columns different from the row total, so they cannot be solved. Skip marked indices rather than treating them as the target.
JavaScript writes four public 64-bit constants to 0x260. For each lane c = 0..3, cellBits writes a 64-bit word at 0x280 + 8*c:
word[c] = seed[c] XOR mix(mix(index*4+c) XOR mask)
Here mix is the SplitMix64 finalizer: XOR with right shift 30, multiply by 0xbf58476d1ce4e5b9, XOR with right shift 27, multiply by 0x94d049bb133111eb, then XOR with right shift 31, using 64-bit arithmetic throughout. JavaScript reads 49 consecutive little-endian five-bit values from the resulting words and indexes abcdefghijklmnopqrstuvwxyz_01345.
A flag-shaped string is not evidence of success. cellBits returns one for every index/mask pair and does not check the nonogram. The useful constraint is the browser's checkCard: all seven row runs and seven column runs must agree with their respective hints. Rather than search 2^49 masks for each card, enumerate the 128 possible patterns of each seven-cell row and prune on column compatibility.
Obtain nono.wasm from the challenge page and save the following script beside it as solve.js. The script constructs a lookup from run-length clues to all legal seven-bit lines. At every unmarked index, it loads the hints, recursively chooses rows, and retains only column patterns matching each newly chosen cell. A complete path supplies the 49-bit mask; cellBits then reproduces the browser's display. The scan can stop at the first satisfying card; the discovered card has exactly one satisfying mask.
#!/usr/bin/env node const fs = require('fs'); const alphabet = 'abcdefghijklmnopqrstuvwxyz_01345'; const seeds = [ 3561879259065934231n, 15782532247631268772n, 8112443065256320506n, 9962020198062936231n, ]; function runs(bits) { const result = []; let count = 0; for (let j = 0; j < 7; j++) { if ((bits >> j) & 1) count++; else if (count) { result.push(count); count = 0; } } if (count) result.push(count); return result.join(','); } const patterns = new Map(); for (let bits = 0; bits < 128; bits++) { const key = runs(bits); if (!patterns.has(key)) patterns.set(key, []); patterns.get(key).push(bits); } (async () => { const bytes = fs.readFileSync('nono.wasm'); const wasm = (await WebAssembly.instantiate(bytes)).instance.exports; const mem = new Uint8Array(wasm.memory.buffer); const view = new DataView(wasm.memory.buffer); for (let c = 0; c < 4; c++) view.setBigUint64(0x260 + 8*c, seeds[c], true); function clue(offset) { const length = mem[offset]; return Array.from(mem.subarray(offset + 1, offset + 1 + length)).join(','); } function display(index, mask) { wasm.cellBits(index, mask); let text = ''; for (let j = 0; j < 49; j++) { const bit = 5*j, p = 0x280 + (bit >> 3); const word = mem[p] | (mem[p + 1] << 8); text += alphabet[(word >> (bit & 7)) & 31]; } return ['flag', '{', text, '}'].join(''); } const total = wasm.seedCount() * 1000; for (let index = 0; index < total; index++) { // This bitset marks deliberately contradictory first-column clues. if (mem[0x10000 + (index >> 3)] & (1 << (index & 7))) continue; wasm.generateByIndex(index); const rows = Array.from({length: 7}, (_, r) => patterns.get(clue(0x200 + 5*r)) || []); const cols = Array.from({length: 7}, (_, c) => patterns.get(clue(0x223 + 5*c)) || []); const chosen = []; let solution = null, ways = 0; function search(r, possibilities) { if (r === 7) { ways++; let mask = 0n; for (let k = 0; k < 7; k++) mask |= BigInt(chosen[k]) << BigInt(7*k); solution = mask; return; } for (const bits of rows[r]) { const next = []; for (let c = 0; c < 7; c++) { const remaining = possibilities[c].filter( v => ((v >> r) & 1) === ((bits >> c) & 1)); if (!remaining.length) break; next.push(remaining); } if (next.length === 7) { chosen[r] = bits; search(r + 1, next); } } } search(0, cols); if (ways) { console.log(`index=${index} mask=0x${solution.toString(16)} ways=${ways}`); console.log(display(index, solution)); break; } } })().catch(error => { console.error(error); process.exitCode = 1; });
The first satisfying card is index 1461121, with unique filled-cell mask 0x108fcd28d06b2. Independently reimplementing the SplitMix64 formula in Python and evaluating the same index and mask reproduced the WASM output exactly. The recovered value is intentionally omitted from this solution-only writeup (flag{REDACTED}).
Use this approach when:
seedCount() and clues come from generateByIndex, rather than a backend API.$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar