FiiO X1 bootloader in Ghidra: 94 functions at the wrong base

I believed Ghidra, the disassembler and decompiler I read firmware with, when it said the bootloader had 94 functions. In this series, believing a tool is how most mistakes start, and this one started early.

The bootloader is the SPL, the second-stage loader of the FiiO X1, a $99 music player built on an Ingenic JZ4760B MIPS chip. The boot ROM runs the IPL, an 8 KB stage that wakes the RAM (Part 4); the IPL loads the SPL, 1 MB of code; the SPL loads sys.bin, the 4.1 MB firmware that plays the music. I had a copy of the SPL from the flash chip, and after Part 10, where my equalizer builds stopped at the boot screen, broke playback, or showed up one slot off and then not at all, I wanted the boot chain mapped: what the SPL initializes, what it checks before it runs the firmware, and what it leaves behind for whatever runs next. A port of Rockbox, the open-source firmware for music players, would need all three answers.

94 functions and no cross-references#

Ghidra had the SPL loaded as a raw binary at 0x80000000, analyzed, 94 functions, all unnamed. The strings were the best part: Getting boot sel keys ..., start by poweron, wdt reset, boot_recovery, boot_normal, a full set of fastboot command names. The ones I checked had zero references. Ask Ghidra who prints Getting boot sel keys and it answers: nobody.

The decompiler still produced C. Structurally valid, plausible, the kind you could paste into a slide:

func_0x8060089c(0x806195c8);
func_0x8060089c(0x806195e4);
func_0x80600860(0x806195f8);

Those are the first three calls in what Ghidra called FUN_800000e0. Every call goes to an address starting 0x8060, and every argument is a pointer starting 0x8061. The segment Ghidra was analyzing ended at 0x800fffff. The code was calling into a region that, as far as the tool knew, did not exist.

So award number six goes to the decompiler: Most Plausible Description of Empty Space. It turned pointers at nothing into tidy C without a single complaint, which I have to respect as a craft.

The ratio should have tipped me off sooner. The IPL from Part 4 had 21 functions in 8 KB. The SPL is 128 times bigger and had four and a half times the functions. Bootloaders don't get sparser as they grow. The tool had stopped following the calls.

0x80600000#

One more function settled it. The UART character writer polls 0xB0031014 and writes the byte to 0xB0031000, UART1, and when the character is a newline it calls func_0x80600800. The function itself sat at offset 0x800. It was calling itself, at an address 0x600000 higher than Ghidra had put it.

The SPL is linked to run at 0x80600000. The binary was fine; my import was six megabytes off.

Delete the import. Import nand_spl.bin again with base address 0x80600000. Let auto-analysis run.

237 functions. Getting boot sel keys now has one reference, from FUN_806000e0; factory.x1 has one, from FUN_806113cc. Within the session I had named 64 of the 237, every function on the boot path, and the rest is mostly NAND error correction, USB descriptors and LCD plumbing.

Nothing in Ghidra warns you that a raw binary is at the wrong base. You get fewer functions and no cross-references, and if you don't know what number to expect, 94 looks like a number.

The boot decision in spl_main#

spl_main, at 0x806000e0, prints its build time, Apr 13 2015 and 12:38:10, then reads two registers and a few GPIO pins: the reset status register CPM_RSR at 0xB0000008, the RTC's hibernate wake-up status register HWRSR at 0xB0003030 (the name comes from X-Boot's jz4760b.h, below; the SPL prints it as hwrsr status), PA30, the wakeup key (my notes list it as the MENU button), and PF10, which my notes call charger detect. It prints both register values to the UART and writes zero back to HWRSR.

The decision falls out of those, in order:

  • a watchdog reset (bit 1 of CPM_RSR), or HWRSR reading exactly 0x10, the bit the header calls PPR reset: the SPL prints wdt reset or start by reset and carries on without looking at the keys
  • a power-on: it carries on if the wakeup key is held or PF10 is set; with neither it prints not press wkup key, power off and powers the chip down
  • later, after the hardware init and the XOR key table, one more pin picks the path: PF11, which the firmware's button table lists as an active-low button. Low, the SPL prints Enter Recovery(boot) routine ... and takes the recovery path; high, it boots normally

Recovery is where updates live, and it tries two files from the SD card in order. First factory.x1: read it, XOR-descramble it, jump to 0x80001000. No CRC, no model check, no version check, no signature (Part 5 found this path from the other end). If there's no factory.x1, it looks for x1.fw, the official update file, and that path does check things: the 32-bit checksum from Part 10 over the file, a model string, and a firmware date that must not be older than the installed one. Its status messages run from read sdcard fail to version too old, and after writing it reads every sector back from NAND and compares.

What boot_normal checks before it jumps#

Normal boot is the path I cared about. This is the decompiled boot_normal at 0x80600db4, after the re-import and before I renamed anything inside it:

void FUN_80600db4(void)

{
  int iVar1;
  uint uVar2;
  int iVar3;
  byte *pbVar4;
  uint uVar5;
  uint uVar6;
  undefined4 local_18;
  undefined4 local_14;
  
  local_18 = 0x2e737973;
  local_14 = 0x6e6962;
  uart_puts_nl(s_Load_kernel_from_ihfs_____8061980c);
  iVar1 = FUN_80610774(&local_18,&DAT_80619788);
  if (iVar1 != 0) {
    FUN_8060fb08(iVar1,0,2);
    uVar2 = FUN_8060faac(iVar1);
    FUN_8060fb08(iVar1,0,0);
    FUN_8060fecc(iVar1,&SUB_80001000,uVar2);
    if (0 < (int)uVar2) {
      uVar5 = 0;
      uVar6 = 0;
      do {
        if (uVar6 + DAT_806b9880 <= uVar5) {
          uVar6 = uVar6 + DAT_806b9880;
        }
        iVar3 = uVar5 - uVar6;
        pbVar4 = &SUB_80001000 + uVar5;
        uVar5 = uVar5 + 1;
        *pbVar4 = (byte)*(undefined4 *)(iVar3 * 4 + DAT_806b9884) ^ *pbVar4;
      } while (uVar5 != uVar2);
    }
    FUN_806100c4(iVar1);
    uart_puts_nl(s_Load_done__80619828);
    FUN_80600fc0();
    (*(code *)&SUB_80001000)();
    uart_puts_nl(s_We_should_not_come_here_____80619834);
    return;
  }
  FUN_8060073c();
  return;
}

The two constants at the top are the string sys.bin in little-endian. Then: open it from IHFS, the firmware's own filesystem on the NAND; seek to the end for the size; read the whole file to 0x80001000; XOR every byte with a key table built from primes; close; call FUN_80600fc0, which writes back the 16 KB data cache and invalidates the 16 KB instruction cache; jump.

That's the whole security model: open, read, XOR, flush, jump, and it fits in one sentence with room to spare. No CRC, no hash, no comparison against anything. Whatever correctly XOR-encrypted MIPS code sits in sys.bin runs.

Which answers one question from Part 10 and moves two others. Modified firmware is not rejected by the bootloader; Part 10 had already booted a sys.bin with one byte changed, and now I could see why nothing stood in the way. So whatever stopped my equalizer builds from booting lives in the firmware itself, not in the SPL, and Part 15 eventually finds it. The other leftover, my theory that the firmware caches equalizer presets somewhere on the NAND, isn't a boot question at all; Part 12 settles it in the firmware.

X-Boot: the SPL's source on GitHub#

Fifty named functions read like a vendor SDK: register access through macros, NAND through a driver layer, an Android-style partition table (recovery, boot, system, userdata, cache, kpanic, storage). Some strings make the lineage hard to miss. One prints X-Boot: . The USB code prints jz4740_handle_fastboot, named for an older Ingenic chip. And the SPL contains a complete Android fastboot command set, flash, erase, download, getvar, reboot, reboot-bootloader, whose getvar version reply is OKAY0.5. A music player that has never run Android ships with Android's flashing protocol, because the SDK did.

The SDK is Ingenic's X-Boot, and the source is on GitHub: ingenic-src-android/xboot. One commit, pushed in February 2014, three stars. Its boot/nand/jz4760b_nand.c is a 983-line NAND driver for this chip's family, with BCH error correction from 4-bit to 24-bit, and include/jz4760b.h defines its registers. The repository's own nand_boot.c loads a Linux kernel from NAND and jumps to it. The X1's SPL replaced that part, and kept the wording: before it loads a µC/OS firmware image, it still prints Load kernel from ihfs ....

What I couldn't find in Ingenic's public trees is HiBy's work (HiBy wrote the X1's firmware): the IHFS filesystem, the prime-based XOR scheme, and the factory.x1 handler. The rest of what I had named looks like SDK code.

I had spent earlier parts of this series reading NAND access, BCH and register layouts for this chip out of hex dumps. A C version with comments had been on GitHub the whole time, the sort of discovery that is mostly good news and slightly humiliating.

What the boot chain allows#

On paper, the boot chain has no signature check anywhere, and that leaves three ways to run my own code:

factory.x1 on the SD card. Recovery tries it first. The SPL reads it, descrambles it and jumps to 0x80001000 with the RAM initialized, the LCD up, 22 MB of heap at 0x80800000, and the hardware state Part 5 catalogued. Nothing I've built has run this way yet; Part 6's dumper never got that far.

A modified sys.bin inside x1.fw. Decrypt, modify, re-encrypt, repack with the right CRC, bump the date. The Part 10 patcher already does this, and the SPL flashes the result to NAND, where every normal boot runs it.

Fastboot. The command set is compiled in, flash included. I haven't found what triggers it.

For a Rockbox port the first path is the interesting one: a small bootloader as factory.x1 that offers a menu, Rockbox from the SD card or the stock firmware from NAND, built against Ingenic's own drivers instead of against my guesses about them. That is what Part 13 starts.

The tell was the function count#

The fix took one dialog box: delete, import, type 80600000. Finding it took a decompiler that, at the wrong base, had kept writing confident C about calls into nowhere, and a reader, me, who kept accepting it. The number 94 was the first sign, and it was there from the first listing.

The award stays with the decompiler. If there were a runner-up category, it would go to the person who saw 94 functions in a megabyte of bootloader and moved on.

Filed under: reverse-engineering, mips, embedded, fiio, hardware

Sources#