ReversingFortID 2026
Meteor Shower
A NES dodge game hides an Easter egg that fires after 95 A/B presses. The expected bits are computed from the game's own meteor tables, and read five at a time as a Baconian cipher they spell a Latin phrase.
What this one teaches
- How an iNES ROM is laid out (a 16-byte header, then PRG program banks and CHR tile banks) and how an NROM cartridge maps its 32 KiB of code at $8000 so a file offset is just the CPU address minus $8000.
- Finding a win state by decoding a game's on-screen text and tracing writes to the state variable, without reversing the gameplay loop.
- Why a check that recomputes its own answer from data tables inside the ROM protects nothing: a static reader can run the same computation.
- What Bacon's cipher is (each letter encoded as five symbols from a two-symbol alphabet) and why its 24-letter and 26-letter variants decode the same bits to different text.
Hints
Stuck? Open one hint, then go back and try again before reading the next.
Hint 1 of 3Show
The Easter-egg screen text has two puns in it. Bacon wrote a cipher, and the controller has two buttons that look a lot like a two-symbol alphabet.
Hint 2 of 3Show
Find what writes the constant 4 to the game-state byte at zero-page $04. That one routine is the whole puzzle.
Hint 3 of 3Show
The check does not hold a stored answer. See what it reads to build the answer, and read the same bytes yourself.
Full solution below. Spoilers ahead.
Task description
Great Scott! The biggest meteor storm in the galaxy is headed straight for your sector. ☄️ 🚀
A note attached to the task said to wrap the recovered phrase in FortID{} and separate the words with _. The handout is one file, meteor_shower.nes, 40,976 bytes.
What an NES ROM actually contains
A .nes file is a cartridge image in the iNES format, which is the layout almost every NES emulator reads. It opens with a 16-byte header, and the rest is the raw contents of the cartridge chips in a fixed order. I parsed the header directly:
$ xxd -l 16 meteor_shower.nes
00000000: 4e45 531a 0201 0000 0100 0000 0000 0000 NES.............
The first four bytes are the magic number NES\x1a. Byte 4 is the PRG count and byte 5 the CHR count. PRG (program ROM) is the 6502 machine code and its data; CHR (character ROM) is the tile graphics the picture chip draws from. Here that is 2 PRG banks of 16 KiB (32 KiB of code) and 1 CHR bank of 8 KiB, which accounts for the whole file: 16 + 0x8000 + 0x2000 = 40,976.
Bytes 6 and 7 encode the mapper, the cartridge’s bank-switching hardware. Both are zero, so this is mapper 0, called NROM, the simplest case with no bank switching at all. On an NROM cartridge the 32 KiB of PRG is mapped straight into the CPU’s address space at $8000 through $FFFF and stays there. That makes the file trivial to navigate: a byte at CPU address A lives at file offset A - 0x8000 + 16, or, once the header is stripped, just A - 0x8000.
The 6502 finds its entry points through three vectors (two-byte pointers) at the very top of memory: NMI at $FFFA, RESET at $FFFC, IRQ at $FFFE. Reading them out of the PRG:
RESET vector: 0x8000
NMI vector: 0x8098
IRQ vector: 0x80c0
So execution starts at $8000, and $8098 is the per-frame interrupt handler (NMI, the non-maskable interrupt the picture chip raises once per video frame) where a game normally does its drawing and input. I did all of the analysis statically from the file. The original work disassembled the PRG with Capstone’s MOS65XX backend; I re-derived the payload below from the raw bytes instead, which does not need a disassembler.
The NES memory map, for a reader coming from another platform
The 6502 has a 16-bit address bus, so 64 KiB of address space, shared between RAM, hardware registers and cartridge ROM. The pieces that matter here:
$0000-$00FFis the zero page, the first 256 bytes of RAM. Instructions that address it are shorter and faster, so games keep their hottest variables there. This game’s state byte lives at$0004.$0200-$07FFis the rest of the 2 KiB of onboard RAM. This game builds its input buffer at$0400.$2000-$2007and$4000-$4017are the picture and audio/controller registers. Reading controller 1 means strobing$4016and reading one button per clock.$8000-$FFFFis the cartridge PRG, read-only, which on NROM is the 32 KiB loaded once and never swapped.
Because the ROM never changes and there is no bank switching, every address in a disassembly points at a fixed byte in the file, which is what makes a purely static recovery possible.
Reading the game’s text out of the ROM
Before touching the logic I pulled the game’s strings, since UI text usually names the win condition. The text is stored as tile indices: $80 + (letter - 'A') for a capital, $00 for a space, and $FF to end a string. Walking the table that begins at CPU $8640 (file offset 0x0640) and decoding on that rule gives every screen in the game:
PAD TO MOVE / PRESS START
GAME OVER / PRESS START
MISSION COMPLETE / YOU SURVIVED / CONGRATULATIONS!
EASTER EGG FOUND!
GREAT JOB ON FINDING / THE EASTER EGG.
I HOPE YOU LIKE / BACON & EGGS...
FIVE STRIPS AT A TIME.
The last four lines are a screen the normal game never shows, and they carry two puns. “BACON” points at Bacon’s cipher, and “FIVE STRIPS AT A TIME” points at reading the secret five symbols at a time, which is exactly how Bacon’s cipher groups its input. That screen is the target, and getting the game to display it is the task.
Finding the routine that switches to the Easter egg
Zero-page byte $0004 holds the game state, and the main loop dispatches on it: 0 is the title, 1 is playing, 2 is game over, 3 is mission complete, and 4 is the Easter egg. So the question becomes: what writes the value 4 into $0004? The disassembly has exactly one such write, in an input-recording routine at $80EF.
That routine reads controller 1 into two zero-page bytes, $01 for buttons currently held and $03 for buttons newly pressed this frame, using a mask table at $8601. The eight mask bytes are:
$ (mask table @ $8601)
01 02 04 08 10 20 40 80
which is A, B, Select, Start, Up, Down, Left, Right in that order. The $01 and $02 entries are the A and B buttons, and those are the only two the recorder cares about.
In every state except 4, each new press of A or B is recorded as a single bit, packed most-significant-bit-first into the buffer at $0400. Trimmed to the lines that carry the logic:
80fb: lda $01 / and #$03 ; isolate the A and B bits
cmp #$01 -> record bit 0 ; A pressed
cmp #$02 -> record bit 1 ; B pressed
... ora $0E into $0400,x ; $0E walks $80,$40,...,$01 as a bit mask
8129: inc $0C / cmp #$5F ; $5F = 95: once 95 bits are in...
8131: jsr $8158 ; ...call the checker
8134: bcc fail ; carry clear: wrong, fall through
8136: lda #$04 / sta $04 ; carry set: write state 4 (Easter egg)
813f: jsr $8143 ; wrong answer: clear the buffer, start over
So the hidden input is a sequence of 95 A/B presses, collected in any state, and after the 95th press the game checks them and either shows the Easter egg or silently resets the buffer.
How the check builds its own answer
The checker at $8158 never compares the input against a stored string. It recomputes the expected bit for each position from two 128-byte data tables, T1 at $8718 and T2 at $8798. It walks the tables with an index that starts at 11 and advances by 37 (modulo 128) each step, and XORs in the position counter:
x = 11
for i in 0..94:
expected = (T1[x] ^ T2[x] ^ i) & 1
mismatch |= input_bit[i] ^ expected
x = (x + 37) & 0x7F
return carry = (mismatch == 0) ; carry set only if every bit matched
The two tables are not check-only data. The same 2-byte addresses $8718 and $8798 are loaded in a second place in the code, near $82C6, which is the routine that places and times the falling meteors:
T1 ($8718) referenced at CPU $8180 and $82E5
T2 ($8798) referenced at CPU $8183 and $82F2
The pair at $8180/$8183 is the checker reading the tables; the pair at $82E5/$82F2 is the gameplay reading them for meteor spawn positions and speeds. Reusing live gameplay data as the key is a nice touch of obfuscation, and it protects nothing. The tables ship in the ROM, so anyone who can read the file can run the same loop the checker runs.
Rebuilding the 95 bits statically
I stripped the 16-byte header, took the 32 KiB PRG, sliced out the two tables at file offsets 0x0718 and 0x0798 (CPU $8718 and $8798), and ran the checker’s own derivation:
import sys
rom = open(sys.argv[1], 'rb').read()
prg = rom[16:16 + 0x8000] # NROM: 32 KiB mapped at $8000
T1 = prg[0x0718:0x0798] # $8718, 128 bytes
T2 = prg[0x0798:0x0818] # $8798, 128 bytes
bits, x = [], 11
for i in range(95):
bits.append((T1[x] ^ T2[x] ^ i) & 1)
x = (x + 37) & 0x7F
s = ''.join(map(str, bits))
print('T1[0:16]:', T1[:16].hex())
print('T2[0:16]:', T2[:16].hex())
print('bitcount:', len(s))
print('bits:', s)
print('decode:', ''.join(chr(65 + int(s[j:j+5], 2)) for j in range(0, 95, 5)))
Output:
T1[0:16]: 9a8ec43fd90a08bce421e73cf17c1115
T2[0:16]: 04030302030302020101020102010201
bitcount: 95
bits: 10010000100100000100011011001101000000000111101110100110010001101100110100000000001001001010011
decode: SCIENTIA...
The 95 bits come straight out of the ROM’s tables with no gameplay involved. I have shown the full bit string above because it is the payload, and clipped only the decoded letters, since spelled out in full they are the flag.
From bits to a Baconian phrase
Bacon’s cipher encodes each letter as five symbols drawn from a two-symbol alphabet (Bacon wrote them as the letters a and b). Here the two symbols are the two buttons: A is a (bit 0), B is b (bit 1). That is the pun on the Easter-egg screen, and it is also why the recorder only watches A and B. 95 bits divided by 5 is 19 letters.
Decoding five bits at a time with the 26-letter alphabet (A = 0 through Z = 25) gives 19 letters that split into three words, a Latin phrase meaning “knowledge is power”, the maxim usually attributed to Francis Bacon himself. The fit between the cipher’s author and the phrase’s author is the confirmation that the reading is right. The flag is that phrase, uppercased, joined with underscores, wrapped as the task note said.
The alphabet choice is the one place this can go wrong. The classic 24-letter Baconian alphabet merges I with J and U with V, and decoding the same bits that way gives gibberish:
26-letter: SCIENTIA... (clean Latin)
24-letter: TCIEOUIAQPUEOUIAETU (noise)
so the 26-letter variant is the intended one.
Why both variants exist and how to tell them apart quickly
Bacon designed his cipher for the Elizabethan alphabet, which had 24 letters because I and J were one letter and U and V were one letter. That gives a clean mapping of 24 letters onto the values 0 to 23, with values 24 and 25 unused. Modern reimplementations often use the full 26-letter alphabet so that J and V can appear. The two schemes agree up to the letter H and then drift apart, so the same bitstream decodes to different text depending on which table you pick. The fast way to choose is to decode both and keep the one that reads as language. A phrase that comes out as pronounceable words in a real language (here, Latin) is the right table; the other variant producing noise is the confirmation.
The gotcha that cost time
An early helper script was named dis.py. Python’s inspect module imports the standard-library dis module, and Capstone pulls in inspect, so the local dis.py shadowed the stdlib one and broke import capstone with a confusing error. Renaming the script fixed it. The lesson is to keep helper script names clear of standard-library module names.
Confirming the sequence on a 6502 emulator
As a dynamic cross-check, I ran the ROM’s own routines on py65, a pure-Python 6502 emulator: the 32 KiB PRG was loaded at $8000, the buffer was reset by calling $8143, then $80EF was called once per button (A for 0, B for 1), and the state byte $04 was read afterwards. The correct phrase drove $04 to 4 (the Easter egg), while flipping a single letter left it at 0 (rejected, buffer cleared):
SCIENTIA... state = 4 # Easter egg accepted
SCIENTIA...S state = 0 # last letter changed: rejected
The static re-derivation above stands on its own: the 95 bits are read out of the ROM’s tables, and they decode to a coherent Latin phrase by the author of the very cipher the screen names. The emulator run agreed with it.
Submission
The challenge has no remote service; the whole check lives inside the ROM, so there is no platform submission step. On real hardware or in any emulator, pressing the 95 A/B buttons in the derived order switches the game to the “EASTER EGG FOUND!” screen, which is the confirmation.
The flagReveal
FortID{SCIENTIA_POTENTIA_EST}This CTF was possibly solved, and the writeup most likely written by an LLM. Apologies for inconsistencies.