$ cat writeup.md…
$ cat writeup.md…
sunshinectf2026
Task: a 140-byte BPS patch for a Super Mario World SNES ROM injects custom 65816 code hooked into a fire-flower routine. Solution: statically diff the BPS patch (no base ROM), disassemble the injected 65816 trampoline, and reverse a single-byte XOR-decrypt loop (key 0x5A, null-terminated) that writes the flag into WRAM.
A weird copy of Super Mario World. The fire flower acts strange... Apply
ctf.bpswith Floating IPS (flips) onto a legally-acquired NTSC (US) SMW ROM. Hint: if you can't find what a modified byte does, check functions with a label containing "fire".
English summary: we are given only a 140-byte BPS patch (ctf.bps) meant to be applied
onto a stock NTSC Super Mario World ROM. The patch modifies the fire-flower behavior.
The goal is to recover the sun{...} flag hidden in the injected code. No emulator or
actual ROM is required — the delta patch alone is enough.
A BPS file is a delta patch format, so every byte it changes can be read directly without ever owning the base ROM.
BPS structure:
BPS1(length<<2) | command:
0 SourceRead, 1 TargetRead, 2 SourceCopy, 3 TargetCopyNote the BPS VLQ decoder is non-standard: it adds 1<<shift after each continuation
byte, so a naive LEB128 reader produces wrong sizes.
Parsing ctf.bps:
0x80000 (512 KB, stock SMW)0x100000 (1 MB) — the patch appends an entire new LoROM bank
(bank $10, mapped to SNES address $108000).The TargetRead actions expose the only real edits to the original ROM:
| File offset | Bytes | Meaning |
|---|---|---|
0x1c3 | 22 00 80 10 EA EA | JSL $108000 + NOP NOP (hook A, boot/early path) |
0xc5f9 | 22 36 80 10 EA | JSL $108036 + NOP (hook B, inside a fire-flower routine per the hint) |
0x7fd7 | 0A | one-byte tweak |
The appended bank at file 0x80000 (= $108000) contains:
source[0x1c3:0x1c9] — the original bytes displaced by hook A,
so the JSL trampoline still executes the code it replaced.$10800B — the real logic.SMW LoROM mapping reminder: file offset $10xxxx maps to SNES address $10:8000+,
so file 0x8000B = $10800B and file 0x80022 = $108022.
Disassemble the 65816 blob, tracking the M/X flags through REP/SEP so immediate
operand sizes are correct. The routine at $10800B is a straightforward XOR-decrypt
loop:
$10800B: PHY PHA LDX #$00 loop: LDA $8022,X ; read encrypted byte at $108022 + X BEQ done ; stop at terminator EOR #$5A ; XOR each byte with key 0x5A STA $7EC100,X ; write plaintext into WRAM $7EC100 INX BRA loop done: PLA PLY PLX PLB RTL
So the injected code copies an encrypted string from $108022, XORs each byte with
0x5A, and stores the plaintext into WRAM $7EC100, stopping at the first byte that
decrypts to 0x00. The EOR #$5A immediate is 8-bit (accumulator in 8-bit mode), so
the key is a single byte 0x5A. The byte just past the closing brace in the ciphertext
is 0x5A, which XORs to 0x00 and cleanly terminates the string.
Recover the flag purely from the patch — no ROM, no emulator:
#!/usr/bin/env python3 # Recover the injected flag from a BPS patch without the base SMW ROM. def read_vlq(data, pos): """BPS variable-length quantity decoder.""" value = 0 shift = 1 while True: b = data[pos]; pos += 1 value += (b & 0x7f) * shift if b & 0x80: break shift <<= 7 value += shift return value, pos def parse_bps(path): """Return the full target buffer produced by applying the patch to a zero source. We only need TargetRead / TargetCopy bytes, so a zero base ROM is enough to reconstruct every byte the patch itself supplies (which includes the appended bank).""" data = open(path, "rb").read() assert data[:4] == b"BPS1" pos = 4 src_size, pos = read_vlq(data, pos) tgt_size, pos = read_vlq(data, pos) meta_size, pos = read_vlq(data, pos) pos += meta_size src = bytes(src_size) # zero base; appended-bank bytes come from the patch out = bytearray(tgt_size) out_pos = 0 src_rel = 0 tgt_rel = 0 end = len(data) - 12 # three CRC32 footers while pos < end: cmd, pos = read_vlq(data, pos) length = (cmd >> 2) + 1 action = cmd & 3 if action == 0: # SourceRead out[out_pos:out_pos + length] = src[out_pos:out_pos + length] out_pos += length elif action == 1: # TargetRead out[out_pos:out_pos + length] = data[pos:pos + length] pos += length out_pos += length elif action == 2: # SourceCopy off, pos = read_vlq(data, pos) src_rel += (-1 if off & 1 else 1) * (off >> 1) out[out_pos:out_pos + length] = src[src_rel:src_rel + length] src_rel += length out_pos += length else: # TargetCopy off, pos = read_vlq(data, pos) tgt_rel += (-1 if off & 1 else 1) * (off >> 1) for _ in range(length): out[out_pos] = out[tgt_rel] out_pos += 1 tgt_rel += 1 return out target = parse_bps("ctf.bps") # Encrypted string lives at file offset 0x80022 (SNES $108022). enc = target[0x80022:0x80022 + 64] flag = bytearray() for b in enc: p = b ^ 0x5A if p == 0: # null terminator (ciphertext byte 0x5A) break flag.append(p) # The recovered ASCII is a Mario/Fire-themed sun{...} flag. print(flag.decode(), "-> sun{REDACTED}")
The XOR key 0x5A and null terminator make this trivial once the injected routine is
located. The "fire" hint pinpoints hook B: the trampoline that actually triggers the
decrypt sits inside a fire-flower routine, matching the "the fire flower acts strange"
flavor text.
Use this technique when:
.bps (or .ips) patch for a console ROM (SMW/SNES here) —
a delta patch can be statically diffed to find injected code without owning the base ROM.JSL/JSR trampolines pointing into it.REP/SEP needs M/X flag tracking to size LDA/EOR immediates.LDA addr,X / EOR #imm / STA wram,X / INX / BRA with a BEQ
exit is a classic single-byte XOR string decryptor with a null terminator.$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar