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.

PSVR Overview

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: MSP-02 side A MSP-02 side B

eMMC connection: eMMC connection

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.

OSS

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
C&M SH950C-CM stb_catv_cnmuhd SH950C-CM http://opensource.lge.com/osSch/list?types=ALL&search=SH950C-CM
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:

Power-on

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.

PCIe hax

Clearly, the most attractive entrypoint is PCIe. Here’s what the setup looked like: pcie setup

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.

Breaking TrustZone

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 DrmDiag_LoadArmImg and/or 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.

Onto FIGO

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;
While the IRAM destination address is assumed to be IRAM+0, the DRAM offset is configurable. What happens if we just bruteforce offsets in DRAM until we hit where IROM stack must be? (At this point, we didn’t yet have a way to see the IROM or stack contents)

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)[0]
        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 😱.

Appendix

Some more old notes that might be useful 🤷

Filesystems

Part Name XTS Key Notes
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)
p7 rootfs rootfs_key.bin
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
p10 rootfs_2 rootfs_key.bin rootfs fallback
p11 boot_2 FIGO images (boot fallback)
p12 cache cache_key.bin Empty
p13 tzk_normal_2 FIGO images (tzk_normal fallback)
p14 firmware_2 firmware_key.bin linux-firmware fallback
p15 userdata userdata_key.bin Contains persisted runtime logs
p16 factory_fw factory_fw_key.bin Stores DFU files for provisioning