srdnlen25: SNES pwn

back · home · ctf · posted 2025-01-19 · fun super nintendo reversing/pwn chall

srdnlenCTF quals were this past Saturday (2025-01-18). I played with Cubemastery, and I had a lot of fun working on the SNES pwn challenge ("A Child's Dream"). I thought it was a super unique challenge! Thank you to davezero and church for writing it :)

I wanted to write about it since the only SNES CTF resources I found online were tangentially related. Honestly I think the hardest part was figuring out what was going on (aka RE), since 'exploitation' was trivial. But I did end up getting first blood at 13 hours into the competition, so I guess it being weird scared away other teams.

the challenge

The description says, [i]f you believe you have the solution, open a ticket where you describe with the keystrokes your exploit, and we will open an online instance for you.... huh??? Discord-based exploit verification? I think that dissuaded a lot of people from trying initially.

the game

The ROM was based on Arkanoid (Doh It Again) for the SNES. It was super stripped down compared to the original game. I hadn't done any SNES reversing or ROM hacks previously, so I have no idea what I'm doing, but I found a good emulator/debugger (Mesen). It only screwed me over a couple times :)

I spent about 5 hours randomly pressing inputs on the virtual console and seeing what happened. It took me about two hours to realize that pressing right bumper caused execution to jump to address $000001, the emulator would just hang. (This is because it ran into a stp instruction at some point, which will hang the console until reset. But to me it just looked like the game was hanging.)

Note the right bumper being held on the virtual joypad and execution jumping to $01 on the call stack.

observations of buggitude

Once I figured that out, I was testing what keypresses would cause execution to jump to a different address. Sometimes, if I pressed the right keys in sequence, it would jump to $000002! Or sometimes even $000100! Why?! Even worse, even for the same input across resets, I would get different outcomes (sometimes hang, sometimes not, sometimes a brk forever loop, sometimes not...). Things got a lot better when I disabled randomized memory on boot in Mesen...

We can control a couple bytes at $00-$0a, which is around where execution jumps. Byte $0 is paddle position, and byte $8 we can control by pressing up and down on the DPAD! That's two bytes controllable. Another byte is the current position of the ball, which is doable if we can step frame by frame. Also worth nothing, there's a "step backwards" feature of the debugger, but it doesn't work half the time (if you hit too many brks, if you hit a stp, if it just lost your execution history in the mail...).

You can change some of the bytes highlighted through gameplay. But not enough to make workable shellcode!

bad ideas and button mashing

I really though the challenge was going to be manipulating the string memory with shellcode in order to print the flag via pausing. The flag is stored in memory around other strings that are printed, like "PAUSE" or "PLAYER 1 READY". I thought the challenge would be to overwrite the null byte separating these values, so when you go into the PAUSE menu, it will print the flag as well. This was totally wrong but it sounded plausible. In the original ROM, they didn't use null-terminated strings, so that put me on that path as well.

It turns out, there IS a win function. I didn't find it originally because I found the flag through PRG memory, which has a different address space (but the same bytes), and there is no reference to the PRG memory flag in the code. So there's another lost hour or two from not understanding SNES memory. It was also really annoying to search for anything in memory because there was a copy of the exact same thing every $010000 bytes. If you CTRL-F for anything in the ROM or memory, you'll find 0xff copies of it. Surely not understanding this mechanism (called "banks") won't hurt me later...

Win function at 0xdedf. I found the DISPLAYCHAR subroutine by breaking on reads of the "PAUSE" string in memory.

So we just need to jump to 0xdedf. There's also a snippet of code at 0xc8f0 that jumps to 0xdedf and I elected to use that instead. I saw it once and then Mesen refused to disassemble it ever again. It looked really handy because it was only a byte off from the previous call return address, so I thought we had to get a 1 byte overwrite to change that value somehow.

But that was all wrong. I went back to my caveman roots and just mashed buttons for another couple hours. I saw with a certain strange series of key inputs, I could get addresses like $004001, which is like, almost usable! Way better than $000001! Can I do arbitrary addresses?

But I couldn't get any further than this. I really needed to understand HOW the PC control was happening. I was really dreading reversing the 50000 instruction input handling code. The Ghidra loader did not work and I did not want to fix it. But I stepped through a bajillion instructions (after breaking on any read from joypad input) to see that the corruption happened when returning from this function (rtl == return from subroutine long):

Ok, so it's stack corruption. That makes a lot of sense. I open another memory view and point it to the stack, then step through the corruption again. I put a break at $0000 to $0100, and I see that the stack pointer is overwritten when we get to this mvn instruction!

mvm

achieving enlightenment

Moreover, I remember hitting this instruction before, and it was different! And why is code at $28 anyway? It changed from mvn $00,$7e to mvn $7f,$00 after you hit right trigger. Is that the source of the memory corruption?! Yes!

Great, the bytes were being copied to the stack. But which bytes? I couldn't figure out where the bytes were coming from. This instruction uses the X and Y registers (you think x86 has not many registers? SNES has only four) for the source and destination addresses. X was $0000 and Y was the stack. But no matter what bytes were at $000000, they were not being copied to the stack.

Wait... mvn $7f,$00? When I hover over the address $7f, Mesen shows me that it's address $00007f, which is not controllable. But it's part of the byte code..... And the source and dest address are already accounted for the registers. What if it's the bank number? What if it's copying not from bank $00 ($000000) but bank $7f ($7f0000)? My clumsy greasy fingers type CTRL-g 0x7f0000 as fast as I can.

That's it!!! The bytes at 0x7f00000 are the ones copied to the stack. You can directly control them by moving the paddle left and right to select the slot (1, 2, or 3) and press up and down to change the value. Slots 2 and 3 are the return address you go to. So all I have to do is key in 00 ef c8 and press right bumper, and we win! (The return address is one fewer than where rtl will return to, so 0xc8ef instead of 0xc8f0). If you press up or down while pressing A, it will jump 0x11 at a time. Perfect!

Oh yeah! Got it working locally :) The key combo is right, down+A (0x00 --> 0xf0), down (0xf0 --> 0xef), right, down+A x3 (0x00 --> 0xd0), then down x8 (0xd0 --> 0xc8), then right bumper (trigger stack corruption). I play it on 25% speed because playing it on regular speed would be crazy. Every time you lose (you miss the ball with the paddle) the values reset. So you would have to jump back and forth, mentally keeping track of the index and current values, while hitting the ball. Now I have to figure out wtf the organizers meant by a remote instance. They wouldn't make me play it on full speed on X11-thru-browser with 200ms input lag right?

playing it on full speed with 200ms input lag

Ah... it's realtime.

What followed was an incredibly embarassing one hour where I tried to do the combo correctly, while being constantly watched by the patient and virtuous organizers.

It had some problems with the docker instance terminating and the websocket/X11 thing disconnecting me at the start, but they fixed the crashing and I switched to firefox (I used chromium because I thought it would have fewer issues). While I was waiting for their fixes, I grinded practice on my local copy. The realtime version became doable if you aimed to get it bouncing around the tiles for a while while you frantically keyed inputs.

It was also 2:30AM and I was using the incorrect binding for A for 45 minutes. Once I figured out I was using the wrong button, it only took me a couple more tries, until:

OH YEAHH! First blood dub.

Most of my understanding breakthroughs on this challenge came from noticing I was bashing my head against a wall and taking a step back, which I think is deceptively hard to do. In general I've been enjoying CTFs a lot recently by focusing on the puzzle-solving fun of it rather than extrinsic validation grindset.

The organizers were super nice :) The most offputting aspect of the challenge ended up being my favorite part.

If you have any questions or feedback, please email my public inbox at ~sourque/public-inbox@lists.sr.ht.