$ cat writeup.md…
$ cat writeup.md…
fortid2026
Task: an NES space-shooter ROM (iNES mapper 0) with a hidden easter-egg phrase guarded by a 6502 validation routine over a player-drawn nametable buffer. Solution: decode the custom string table (offset + Caesar shift), follow a themed BACON pun to a Bacon cipher, then reverse the 6502 checker to reconstruct 95 expected bits from two EOR'd ROM tables and decode 5-bit groups into the Latin phrase.
Great Scott! The biggest meteor storm in the galaxy is headed straight for your sector.
English summary: We are given an NES ROM (meteor_shower.nes, 40976 bytes) containing a
space-shooter game. The goal is to recover a hidden phrase embedded in the game and wrap it
in the FortID{...} flag format (words separated by _).
file identifies an iNES ROM: mapper 0 (NROM), 2×16K PRG-ROM + 8K CHR-ROM, horizontal
mirroring. The 16-byte iNES header is NES\x1a followed by PRG/CHR bank counts and flags.
PRG-ROM therefore lives at file offset 0x10, and with mapper 0 the second 16K bank is
mapped to CPU 0xC000 while the first is at 0x8000. For analysis the 32K PRG was carved
to prg.bin and treated as based at 0x8000.
strings on the ROM reveals obfuscated text such as )LDSDNQ RGNVDQ. Applying a Caesar
+1 letter shift yields METEOR SHOWER — matching the task title and confirming a
deliberate text-encoding scheme rather than compiled noise.
The in-game string table stores each character as:
byte = (char - 1) + 0x40 # i.e. char = (byte - 0x40) then Caesar +1 on letters
So decoding is: subtract 0x40, then apply Caesar +1 to letters. Separators and
punctuation are encoded with special byte values:
0x00 → space0xff → newlineq → ! r → . v → & u → 'Decoding the message table (located immediately after the title string) yields the normal
game text (D-PAD TO MOVE, PRESS START, GAME OVER, MISSION COMPLETE, YOU SURVIVED,
CONGRATULATIONS) plus an easter-egg block:
EASTER EGG FOUND!GREAT JOB ON FINDING THE EASTER EGG.I HOPE YOU LIKE BACON & EGGS... FIVE STRIPS AT A TIME.The pun is the key hint: BACON + FIVE STRIPS AT A TIME points to the Bacon cipher with 5-bit groups.
Disassembling the PRG with radare2 (-a 6502 -b 8 -m 0x8000) reveals a validation routine
at CPU address 0x8162. The routine checks a pattern the player must draw into a
nametable-style buffer:
i = 0 .. 0x5E (95 iterations).0x0400,
masked by a bit-selection table at 0x8609 (0x80, 0x40, ..., 0x01).0x8718 — a 130-byte pseudo-random table.0x8798 — a 130-byte run of small values {1,2,3,4,6,8}.0x16 must end as 0 for success. The routine sets carry
(sec) on success and clears it (clc) on failure.The expected bit for iteration i is:
expected_bit(i) = (TableA[k] ^ TableB[k] ^ i) & 1
where the index k is a stride-37 walk mod 128: zero-page 0x15 is initialized to
0x0B at 0x815e, and after each iteration k = (k + 0x25) & 0x7F.
So all 95 expected bits can be reconstructed purely from the two ROM tables, with no need to emulate the game or actually draw the pattern.
Carve the PRG, read the two tables, walk the stride-37 index to produce 95 bits, then group
them 5 at a time ("five strips") and map each 5-bit value 0..25 to A..Z (Bacon cipher).
95 bits / 5 = 19 letters.
#!/usr/bin/env python3 # Solution-only solver for "Meteor Shower" (fortid2026). # Recovers the hidden Bacon-cipher phrase from the NES ROM validation tables. with open("meteor_shower.nes", "rb") as f: rom = f.read() # iNES: 16-byte header, then 2x16K PRG. PRG maps to CPU 0x8000. PRG = rom[0x10:0x10 + 0x8000] def at(cpu_addr): return PRG[cpu_addr - 0x8000:] A = at(0x8718)[:130] # TableA (pseudo-random) B = at(0x8798)[:130] # TableB ({1,2,3,4,6,8}) # Reconstruct the 95 expected bits using the stride-37 (mod 128) index walk. bits = [] k = 0x0B # zero-page 0x15 init at 0x815e for i in range(0x5F): # i = 0..0x5E -> 95 iterations bits.append((A[k] ^ B[k] ^ i) & 1) k = (k + 0x25) & 0x7F # stride 37, mod 128 # Group by 5 ("FIVE STRIPS AT A TIME") -> Bacon letters, 0->'A' .. 25->'Z'. letters = [] for g in range(0, len(bits) - len(bits) % 5, 5): val = int("".join(str(b) for b in bits[g:g + 5]), 2) letters.append(chr(ord('A') + val)) phrase = "".join(letters) # 19 letters print(phrase) # -> a well-known Latin motto (8+8+3 letters) # Wrap per flag format: FortID{...}, words separated by '_'.
The 19 decoded letters spell a well-known Latin motto (word lengths 8 + 8 + 3). Re-insert
the word boundaries and wrap it in FortID{...} with _ separators to obtain the flag.
The recovered value is FortID{REDACTED}.
Use this technique chain when:
NES\x1a (or file reports an "NES ROM" / iNES image)
and the task hints at a hidden in-game phrase (gamepwn / console reversing).strings shows text that is almost-but-not-quite English (e.g. )LDSDNQ RGNVDQ) — try a
Caesar ±1 shift and a constant byte offset before assuming a bespoke cipher.BACON + FIVE STRIPS AT A TIME — that
names the real cipher (Bacon) and its grouping (5-bit). Treat such puns as the primary
technique hint for misc/stego/reverse easter eggs.0x0400) bit-by-bit
against values derived by EOR'ing two ROM tables with a loop counter — you can skip
emulation and reconstruct the expected bitstream directly from the ROM tables and the
index-walk constants (init 0x0B, stride 0x25, mask 0x7F).$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar