$ cat writeup.md…
$ cat writeup.md…
sekai2026
Task: static single-page web app with 6 Nikoli logic puzzles (tapa, nurikabe, fillomino, shikaku, skyscrapers, kakuro); each solution grid derives an SJCL key that decrypts a hidden ASCII-art heptomino. Solution: solve every puzzle with z3/OR-Tools, reproduce the app's canonical-token logic in Node to decrypt the 6 pieces, then assemble them as a no-rotation jigsaw into a 7x6 rectangle that reads the flag.
Six (or seven?) handcrafted logic puzzles. Best enjoyed with a pencil, not a solver. Connection: https://67-hunt.chals.sekai.team
A static single-page web app presents six Nikoli-style logic puzzles built on
the pzpr.js / puzz.link library, with SJCL for crypto. Every clue uses only
the digits 6 and 7 (the "6-7" / "sixseven" theme). Solving a puzzle
reveals an encrypted piece; the six pieces form a meta "seventh" puzzle whose
answer is the flag.
index.html)Each puzzle element carries two attributes:
data-src — the problem in pzpr URL format (clues only).data-ct — an SJCL ciphertext.The clever part: the decryption key is derived from the player's own solution grid, not stored anywhere. When a puzzle is solved correctly, the app walks the board in a fixed parity-based order and builds a canonical token:
| Puzzle | Token source | Cells/borders used |
|---|---|---|
| tapa, nurikabe | cell.qans (0/1 shaded) | parity-0 cells |
| skyscrapers, kakuro | cell.anum + 1 | parity-0 cells |
| fillomino | border.qans | border.isCmp() | parity-1 borders |
| shikaku | border.qans | parity-1 borders |
That token is the SJCL decryption key. Fixed payload parameters:
iv = "sixsevenSIXSEVEN", iter = 6767, salt = "sixseven+67=".
A correct solution → correct token → SJCL decrypts to an ASCII-art reveal of a heptomino (7-cell omino) with one letter per cell. A wrong solution throws during decryption — this is the key to disambiguation (see kakuro).
pzpr.js in Node to parse each data-src into clean clue structures.data-ct, recover the piece.Each puzzle got its own solver. All clues being 6/7 made grids dense but small.
z3. Unique solutions, fast.z3 enumerated 438 valid
fillings. Disambiguation used the SJCL decryption as an oracle: only the
author's intended grid produces a key that decrypts without error. One filling
out of 438 decrypted cleanly → unique answer.z3 with island labeling, distance-based white connectivity,
black-region root connectivity, and the critical constraint "adjacent white
cells share the same island label". Without that last constraint z3 merged
separate islands and produced invalid boards.z3/grilops were too slow → OR-Tools CP-SAT. Crucial gotcha:
non-clue cells are not limited to 6/7 (the real solution contains 1s and
2s), so a binary 6/7 coloring assumption failed.For each solved puzzle, a small Node harness (try_*.js) set the solution into
a pzpr board, ran the exact parity-based token-derivation loop, and fed the
token + fixed SJCL params to sjcl.decrypt. Each yielded an ASCII heptomino.
The six pieces are named by their shape letter, and together spell the meta:
| Shape | Puzzle | Cells (letters) |
|---|---|---|
| J | tapa | S E K A I / 6 7 |
| I | nurikabe | { S E K A I } |
| G | fillomino | C R E S + underscores |
| S | shikaku | I T S E N T E |
| A | skyscrapers | I B B L L I + underscore |
| W | kakuro | X Y G E R + underscores |
→ J I G S A W = the "seventh" puzzle / meta.
Piece geometry (no rotation/reflection — each keeps its orientation):
J: #### I: ## G: ## S: .### A: .#. W: ..#
..#. .# #. .#.. ### ###
.##. .# ## ###. ### ###
.# ##
.#
.#
6 pieces × 7 cells = 42 = a 7-wide × 6-tall rectangle. Brute-force
placement (assemble2.py) with no rotation and no reflection finds exactly
one tiling:
SEKAI{S
CRIBBLE
_67_LIK
E_ITS_A
_SEXY_I
NTEGER}
Reading row-major:
SEKAI{SCRIBBLE_67_LIKE_ITS_A_SEXY_INTEGER}
(reads as "scribble 67 like it's a sexy integer" — a 6/7 joke).
# assemble2.py — no rotation, no reflection; find the unique 7x6 tiling pieces = { 'J': {(0,0):'S',(1,0):'E',(2,0):'K',(3,0):'A',(2,1):'I',(1,2):'6',(2,2):'7'}, 'W': {(2,0):'_',(0,1):'X',(1,1):'Y',(2,1):'_',(0,2):'G',(1,2):'E',(2,2):'R'}, 'A': {(1,0):'I',(0,1):'B',(1,1):'B',(2,1):'L',(0,2):'_',(1,2):'L',(2,2):'I'}, 'S': {(2,0):'I',(3,0):'T',(4,0):'S',(2,1):'E',(0,2):'N',(1,2):'T',(2,2):'E'}, 'I': {(0,0):'{',(1,0):'S',(1,1):'E',(1,2):'K',(1,3):'A',(1,4):'I',(1,5):'}'}, 'G': {(0,0):'C',(1,0):'R',(0,1):'_',(0,2):'E',(1,2):'_',(0,3):'_',(1,3):'S'}, } def normalize(cells): minx=min(c[0] for c in cells); miny=min(c[1] for c in cells) return {(x-minx,y-miny):v for (x,y),v in cells.items()} orients={n:[normalize(p)] for n,p in pieces.items()} def solve(W,H): grid=[[None]*W for _ in range(H)]; out=[]; names=list(pieces) def place(i): if i==len(names): out.append(''.join(grid[y][x] for y in range(H) for x in range(W))); return for o in orients[names[i]]: ow=max(c[0] for c in o)+1; oh=max(c[1] for c in o)+1 for oy in range(H-oh+1): for ox in range(W-ow+1): if all(grid[oy+cy][ox+cx] is None for (cx,cy) in o): for (cx,cy),v in o.items(): grid[oy+cy][ox+cx]=v place(i+1) for (cx,cy) in o: grid[oy+cy][ox+cx]=None place(0); return out for W,H in [(7,6),(6,7)]: for f in solve(W,H): print(W,'x',H,f)
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar