PS4 Aux Hax 5: Flawed Instructions Get Optimized
Aaaand we’re back, after an extended delay, to … continue talking about hacking PS4 peripherals 😅.
This time, the DUT is the PS4 Virtual Reality peripheral: PSVR. We managed to find some major flaws - breaking secure boot and extracting all key material; let’s go!
Yet another PS4 peripheral?
Digging into the PSVR was initially done in anticipation of the PS5 release after it was announced that PS5 would maintain compatibility with PSVR, thinking it could be a possible initial entrypoint for PS5. While it didn’t wind up being used as such, it was still pretty fun to hack (sound familiar?). And who knows, maybe the attack surface is useful in some way.
A PSVR setup includes multiple components:
- PSVR mainboard
- PSVR headset
- PS camera
- Some sort of input (PS Move, PS Aim, DS4, …)
- PS4/PS5 console
- HDMI display output
Multiple revisions of the PSVR mainboard exist, however they all carry the same Marvell 88DE3214 SoC and share all key material (or at least, the keys relevant to PSVR). The host console is connected to PSVR mainboard via HDMI and USB, both of which wind up being processed in the Marvell 88DE3214. Under normal operation, an authentication sequence is performed over USB before the console accepts the device as a PSVR. So, the goal was to extract the secrets needed for that authentication and reverse the communication protocol. This post will mainly cover the main SoC on the PSVR mainboard, as we were interested in using secrets within PSVR to reach juicy attack surface on the host console.
At the time, the most useful teardown of the device was on 4gamer. It’s still a great resource you should check out.
We also poked around on the mainboard a bit to locate interesting components and I/O and dump eMMC. The following board scans contain some annotations:
Some things which stood out:
- UART is breifly active during early boot (output only).
- eMMC has easily accesible traces on both sides of the board.
- eMMC content appears mostly encrypted (what is there looks android-ish).
- Interfering with eMMC traces during early boot generates some additional error logs on the UART.
- The Marvell 88E8080 is used for the USB connection to the host console, and is connected to 88DE3214 via PCIe.
While some of the sources for the device are published (start at the PSVR-related config - note “morpheus” is a codename referring to PSVR), the eMMC dump and UART error spew were helpful to confirm that the boot and non-linux components of the firmware running on the 88DE3214 were likely identical to other devices based on the same chip (aka BG2Q4K / Armada 1500 Pro 4K). At the time, we found the following related devices (things in this post should apply directly to them):
|Retail Branding||Marketing Name||Device||Model||Kernel Source|
|KDDI||Power Up Unit||C02BB1||C02BB1||?|
|LGU+||U+ tv UHD||tvg2||ST940I-UP||http://opensource.lge.com/osList/list?m=Mc111&s=Sc113|
|LGU+||U+ tv woofer||hg2||LAP250U||kernel tgz identical to ST940I-UP|
|LGU+||U+ tv woofer 1.5||hg2_iot||LAP255U||kernel tgz identical to ST940I-UP|
Unfortunately it seems the above links have broken over time, but you may be able to find them elsewhere.
Spelunking the old chromecast git repositories was also useful for boot and hardware information:
The board will boot into a low-power mode as soon as power is supplied. To fully power-on, the power switch on the headset control cable must be pushed momentarily. This is pin 13 on the connector, and is also a large test pad on the other side of the board. The board can fully boot and run without heatsink but the SoC gets relatively hot.
After fully booting, the board will appear as a usb device.
Possible initial entrypoints
The HDMI ports on the PSVR mainboard for console and TV have CEC routed between them, presumably to the Marvell SoC as well.
amp_devices_vpp_cec_isr in the 88DE3214 sources looks like it has same bug as PS4 EMC CEC handler. Never got around to checking if Marvell CEC hardware can buffer > 16bytes.
The kernel has USB(gadget) support enabled. The Marvell 88E8080 device is configured to act as DWC3 USB controller over PCIe (note the Armada only supports PCIe 2.0 1x). This means usb and usb-gadget stuff is exposed, as well as PCIe DMA (the Armada has no IOMMU). The driver also enables bus mastering.
The kernel has berlin ethernet support enabled, and the berlin-fe device is enabled by default. It’s likely some unpopulated pads on the board are ethernet. The marvell/geth driver is used for it.
EHCI seems enabled in kernel + dts, unsure if there are any unpopulated usb pads on the board.
Clearly, the most attractive entrypoint is PCIe. Here’s what the setup looked like:
After getting the connection running, it was apparent that external PCIe devices have a surprising level of access to SoC internals. Of course, it has nearly full access to DRAM (apart from regions marked Secure for TrustZone), however it also gives access to SRAMs used during boot of the various ARM cores, SRAMs of peripheral cores, and ability to read/write any MMIO registers existing in the physical address space of the SoC. This allowed directly dumping the bootrom and reversing the secure boot implementation.
After creating some tooling to introspect and inject the running linux kernel and dump userspace processes, etc., it was evident that a TrustZone compromise would be required to get at the secrets.
Inspecting the TrustZone / linux interface turned up a lot of vulnerabilities. However, only one was needed to dump the secrets used for PSVR authentication.
libdrmtz.ta command 0x22 seems to be called
MV_IMAGETOOL_Decrypt_AES_FIGO. This takes the destination load address as a parameter (among others), and allows it to point into Normal World memory. Thus, encrypted ARM images can be decrypted and dumped, giving plaintext of the TAs to look for further bugs, in addition to any secrets which may be leaked by having decryption oracle access to TrustZone binaries.
As it turned out this was already enough to recover secrets used by PSVR, as they are contained within TAs and do not rely on hardware secrets.
However, it is possible to go further and dump all hardware secrets and compromise the root of trust. To that end, it’s interesting to note that libdrmtz.ta also contains the symbol
rom_figo_figo which seems to be a non-production version of the IROM of the “FIGO”. It seems like a variable which is intended to be compiled out in release builds, but the compiler has not actually excluded it in this case. One of the FIGO DROM keys is also in the binary (also unused):
g_puROMKey is the key stored at
FIGO_BASE+0x56d0 which gets used as KEK id 0x10.
The FIGO (“Flow Instructions Get Optimized”; what a great name) is the secure coprocessor resident in the SoC. While it doesn’t directly control secure boot, it does authenticate / decrypt loaded images and can be used to house and derive key material within it’s own secluded part of the SoC. While it has IROM, DROM, fuses, crypto, etc. peripherals, it also has DRAM (of course) and IRAM to support loading and executing firmware at runtime. These firmwares tend to be relatively small and handle a limited set of responsibilites per image.
The FIGO is quite interesting as it runs a unique instruction set (nicely for us, documented in some of the publicly available code from chromecast, etc.) and of course, because it hides somewhat-better protected secrets 😆 A (dis)assembler for FIGO code can be found here.
An important architectural fact to note is that the FIGO hardware block contains an MPU which allows the FIGO to protect subregions of its MMIO space from external access. Subregions can be disabled, read-only, write-only, or read-write to the world outside of FIGO. Normally, FIGO IROM and firmwares use this to prevent secrets being directly read from its various memories and peripherals (otherwise, we could just dump most FIGO secrets directly from external PCIe device!).
Shoving firmware into FIGO
Before getting into the actual FIGO vulnerabilities, it is worth mentioning another design flaw in TrustZone code. Normally, after TrustZone has been initialized and FIGO setup for runtime usage, it is intended that FIGO is not directly accessible to Normal World. Instead, TrustZone wraps functionality which may be implemented in FIGO modules and dynamically loads / calls into the modules as needed. This dynamic loading calls
MV_DRMLIB_GetFIGOFW to retrieve a buffer containing firmware which will be sent to FIGO IROM for authentication and execution on the FIGO. Internally,
MV_DRMLIB_GetFIGOFW looks up a pointer into a previously-loaded array of FIGO firmwares residing in Normal World memory. It could be argued this is secure as the FIGO should be authenticating the images in the first place, however it needlessly exposes attack surface. As we’ll show, it can be used to exploit the FIGO from Normal World.
DrmDiag_OTPRead exposes a good way to abuse this functionality as it has minimal side effects. Essentially, the FIGO image in Normal World DRAM can be updated by our PCIe device, then the PCIe device can cause Normal World kernel to call
DrmDiag_OTPRead which will try to load and execute the arbitrary data supplied from Normal World as FIGO firmware. Combined with the PCIe device’s access to MMIO, this removes the need to fully take over Secure World in order to attack the FIGO.
Racing the FIGO
When initially trying to understand how FIGO loads and authenticates FIGO firmware, it was observed that while IROM is processesing an image load request, some parts of the FIGO DRAM would be deprotected in the MPU and visible to our PCIe device. Specifically, the image format processed by the IROM has a header containing a rfc3394-wrapped key. The IROM stores data used to perform the AES unwrap at
FIGO_BASE+0x1980 which is visible in the MMIO mapping to our PCIe device as the IROM processes it. This unwrapped key is used to decrypt the body of the FIGO firmware image, which allowed us to decrypt and start reversing some FIGO firmware.
Pwning FIGO IROM
The FIGO image header describes 2 load sections, one for each IRAM and DRAM.
// @ +0x78 in FIGO image header // count of 4byte instructions u16 ins_cnt; u16 pc_offset; // count of 8byte data u16 dat_cnt; // DRAM load addr / 8 u16 dat_offset;
That’s right, we get ROP in IROM context!
From reversing the FIGO firmwares we had decrypted at this point, we were able to construct a little shellcode to dump all the fuses. We also reconfigured the MPU to drop all protections, enabling direct dumping of full IROM and DROM (which also stores some keys) from PCIe 🤘
FIGO IROM, again!
Let’s present our re-implementation of the image authentication and decryption which matches behavior of FIGO IROM. Do you see the problem?
def img_decrypt_and_verify(data): kek_id, _, key_wrapped = struct.unpack_from('<II24s', data, 0) sign_id, sign_type, sig_len, msg_digest, bind_info = \ struct.unpack_from('<IHH32s16s', data, 0x40) # hueristic using figo insn fields img_type = IMG_TYPE_ARM if all_zero(data[0x78:0x7c]) else IMG_TYPE_FIGO img_len, enc_len = 0, 0 if img_type == IMG_TYPE_ARM: img_len = struct.unpack_from('<I', data, 0x7c) enc_len = align_up(img_len, 0x10) - 0x300 else: ins_cnt, pc_offset, dat_cnt, dat_offset = \ struct.unpack_from('<HHHH', data, 0x78) ins_len = ins_cnt * 4 dat_len = dat_cnt * 8 dat_load_addr = dat_offset * 8 enc_len = img_len = align_up(ins_len, 0x20) + align_up(dat_len, 0x10) key = None if kek_id in (0x80, 0x81): for kek in iter_customer_kek(kek_id): key = aes_unwrap(kek, key_wrapped) if key is not None: break else: kek = kek_id_to_kek(kek_id) if kek is None: print('kek lookup failed. kek_id %02x' % (kek_id)) return None key = aes_unwrap(kek, key_wrapped) if key is None: print('unwrap failed. kek_id %02x wrapped %s' % ( kek_id, key_wrapped.hex())) return None if sign_type != 2: print('unexpected sign type %d' % (sign_type)) return None if sig_len != 0x100: print('unexpected sig len %d' % (sig_len)) return None hdr_signed = data[0x68:0x280] payload = aes_cbc_iv_zero_decrypt(key, data[0x380:0x380 + enc_len]) # signing key id 2 should match otp:28, but w/e #sha256(e+n).digest() == sign_key_digest e = data[0x80:0x180] n = data[0x180:0x280] sig = data[0x280:0x380] signed_img = hdr_signed + payload signed_len = len(signed_img) & 0xffff if not img_rsa_verify(signed_img[:signed_len], sig, e, n): return None return payload
… yes, the RSA verification (RSA PSS w/SHA256) does 16bit truncation when calculating the payload size! This means that if there exist any images (including, say, ARM TrustZone TAs) larger than 0xffff bytes, in order to pass signature check on FIGO hardware, they must be signed in such a way that likely a large percentage of the payload can actually be freely modified without causing the signature check to fail. There are indeed such images in PSVR firmware 😱.
Some more old notes that might be useful 🤷
|boot0, boot1||bootloader files with fallback partition|
|p1||fts||Android-style key-value storage for environment|
|p2||factory_setting||factory_setting_key.bin||Global and per-device key stores, all encrypted per-device|
|p3||tzk_recovery||FIGO image (recovery TZK and TZBP)|
|p4||recovery||FIGO image (recovery bootimg)|
|p5||tzk_normal||FIGO image (TZK and TZBP)|
|p6||boot||FIGO image (bootimg)|
|p8||firmware||firmware_key.bin||Empty linux-firmware tree|
|p9||trusted_storage||ta_storage_key.bin||Unused, but seems to have garbage in non-allocated sectors|
|p11||boot_2||FIGO images (boot fallback)|
|p13||tzk_normal_2||FIGO images (tzk_normal fallback)|
|p15||userdata||userdata_key.bin||Contains persisted runtime logs|
|p16||factory_fw||factory_fw_key.bin||Stores DFU files for provisioning|