Skip to content

Latest commit

 

History

History
113 lines (86 loc) · 9.44 KB

EXPLOIT.md

File metadata and controls

113 lines (86 loc) · 9.44 KB

2021-04-11 Update

The exploit is now 100% reliable. Huge thanks to Nicolas Noble for figuring out the issue (the exploit was relying on uninitialized memory containing only zeroes, which is not the case in real life with real SDRAM).

The updated exploit now modifies a "jal" instruction (i.e. a function call) instead of a function pointer, and makes it jump to the buffer containing the last sector read from the memory card (which contains the initial payload, which loads more code from the memory card); and does so with a "fake" directory entry which is initialized to known values, thus removing the randomness of the initial exploit. Specifically, it modifies the instruction at 0x80031f50, which originally calls set_card_auto_format (a useless and even dangerous function). The original instruction jumps to 0x80068510. The exploit increments it to jump to 0x8020be48 (see builder.cc). Because of the limited range of the increment, this new version requires 4 directory entry slots, and takes slightly longer to execute (10-20 seconds).

Exploit details

FreePSXBoot uses a bug in the PSX BIOS to load arbitrary code. The bug is in the code which read the directory of the memory card and is explained in detail in this document.

This exploit is currently only available for the SCPH-9002 BIOS, but should be portable to every other version (unless Sony somehow introduced the bug in the later versions of the BIOS).

Introduction

A PSX memory card has 128 kB of storage, divided in 16 blocks of 8 kB each. The first block is reserved for special purposes, leaving 15 blocks usable for games.

Each block is divided into 64 sectors (or frames) of 128 bytes each. The sector is the minimum unit the PSX can work with, i.e. the PSX reads and writes multiples of 128 bytes.

The first block occupies sectors 0 to 63. It contains 15 directory entries, describing the content of the following blocks (e.g. file name, size, ...), and a list of broken sectors and their replacement, and some other unimportant stuff. Details on the data format can be found here.

The directory entry structure is as follows:

  • 4 bytes: block allocation state
  • 4 bytes: file size, in bytes
  • 2 bytes: index of the next block number if the file occupies multiple blocks; 0xffff otherwise
  • 21 bytes: file name in ASCII (null terminated)
  • Rest: unused (except the last byte which is a checksum)

The bug

Memory card management

The PSX shell (i.e. the software launched when booting the PSX without a CD) can manage memory cards (copy and delete files). When it parses the directory entries, it copies the first 32 bytes of each directory entry to the RAM, and checks if they are sane:

  1. Copy all the directory entries to the RAM (in an array of 15 elements of 32 bytes)
  2. Go through each entry representing the first block of a file; repair it if needed
  3. Go through each entry not representing the first block of a file; repair it if needed

This is done in a function that I will call ReadMemCardDirectory, located at 0xbfc08b3c in SCPH-9002.

Missing boundary checks

The PSX shell assumes that the file size and index to the next block in each entry are valid, and doesn't do any boundary check on them. For step 3, it goes through each directory entry, and blindly follows the next block in the file:

entry* next = &entries[first->next_index];

By manipulating the next index, we can make next point to anywhere from entries to entries + 0x20 * 0xfffe. Whatever data is there will be processed as the next entry.

In addition, the PSX shell doesn't fix the entries immediately: if the block is referenced, it will be marked as so, and the repairs are processed after all the entries have been checked. In pseudocode:

uint32_t is_referenced[15] = { 0 };
for (int i = 0; i < 15; i++)
{
	entry* ent = &entries[i];
	if (is_beginning_of_file(entry))
	{
		for (int i = 0; i < ent->size / 0x2000; i++)
		{
			uint16_t next_index = ent->next_index;
			is_referenced[next_index]++;
			if (next_index == 0xffff)
			{
				break;
			}
		}
	}
}

You can see the offending line in context here.

We control next_index, and we also control ent->size: we can increment any 32-bits variable in the (limited) range of next_index. But we must ensure that ent will point to known chunks of memory, otherwise we will lose the control of where it points to. Fortunately, there is a large chunk of 00 bytes shortly after the entries array, and they will be interpreted as pointing to the index 0, making ent point again to a directory entry that we control.

In practice, this means we can increment by a controlled value the memory at a chosen (but limited) location. And it starts sounding like game over, but there is a problem...

is_referenced is an array allocated on the stack, and it looks like we could use that to overwrite a return address and take control of the program counter. However, that is not possible because the array is at the top of the stack, and everything after it is just unused data. In fact, in the PSX memory map, the stack is mapped to the last addresses of the RAM: the is_referenced array is at 0x801ffcd0, merely 816 bytes before the end of the RAM. And these 816 bytes contain only unused data...

RAM mirroring saves the day

For some reason, the 2 megabytes of RAM of the PSX (i.e. 0x200000 bytes, mapped from 0x80000000 to 0x80200000) are mirrored 4 times. This means that reading (and writing) at address 0x80200000 will actually read/write at 0x80000000. And that's the beginning of the RAM, and it contains, among other stuff, a bunch of function pointers. And we can modify them!

Payload

Overwriting the right function pointer

ReadMemCardDirectory is called twice, once for each memory card slot. The very first thing it does is to call a BIOS function through a function pointer, allow_new_card (this just sets a flag for later). This is the perfect function pointer to overwrite:

  • It does something trivial and relatively useless: it is easily replicable in a payload
  • It is called at the perfect time: just after our exploit has overwritten the function pointer
  • It can be easily restored by the payload

So the plan is to find an index and a size for the first directory entry, with the following constraints:

  • 0x801ffcd0 + index * 4 == 0x802009b4. The first address is the is_referenced array; the second one is the allow_new_card function pointer, in the mirrored RAM: the index must be 0x0339.
  • entries + 0x20 * index points to memory which will make the code shown above point back to the first directory entry, allowing us to control how many times the value is incremented. Fortunately, the memory in that region is all 0s, thanks to SDRAM decay.
  • Our payload is located at 0xa000be48, which is a mirror of 0x0000be48. The original function is at 0x00004d3c: we must increment it 0x710c times. This means the size of our directory entry must be 0x710c * 2 * 0x2000 = 0x1c430000. This number must be positive (i.e. less than 0x80000000) or the PSX code will not process it.

This is how our first directory entry (sector 1 of the memory card, offset 0x80) must look like:

00000080 51 00 00 00 00 00 43 1C 39 03 46 52 45 45 50 53 Q.....C.9.FREEPS
00000090 58 42 4F 4F 54 00 00 00 00 00 00 00 00 00 00 00 XBOOT...........
000000A0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
000000B0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
000000C0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
000000D0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
000000E0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
000000F0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 6D ...............m

Loader

I have said earlier that the payload is located at 0xa000be48. This address is used as a buffer which contains the last read sector of a memory card in slot 0. After doing its repairs, ReadMemCardDirectory reads the broken sector list (i.e. sector 16 to 35 in the memory card). The last byte of each of those sectors is a checksum (all other bytes XORed). If any checksum of a read sector is wrong, ReadMemCardDirectory bails and erases the directory entries. We use this to our advantage:

  • We'll put the payload in sector 16; it will have a wrong checksum, which will prevent further reading, ensuring it remains in the buffer at 0xa000be48.
  • Erasing the directory entries ensures that no other code does some weird memory manipulation when going through it.
  • It is (negligibly) faster.

The payload must be in sector 16 (offset 0x800), and is limited to 128 bytes. This is very small: only 32 MIPS instructions. Fortunately, this is just enough to make a loader which will load more code from the memory card (see loader.S, which is exactly 128 bytes large).

Known issues

  • The exploit doesn't work all the time on the real hardware, due to SDRAM decay and the fact the kernel doesn't explicitly initializes its memory to all zeroes. This is likely to be improved with future versions, but requires some more convoluted exploitation. Fixed in 2021-04-11 update.
  • For BIOS versions 3.x, the next index must be incremented by 2. (TODO: See why this occurs)
  • For BIOS versions 4.3+ (PSone), the next index must be incremented by 4. (Apparently this also works on PAL 3.x systems?)