FiiO X1 NAND: 44 KB of high-entropy data, identified

The last unidentified thing on the flash chip was 43,535 bytes long, and it looked exactly like encryption.

The chip is the 128MB NAND of a FiiO X1, a $99 music player on an Ingenic JZ4760B, and by now I had a raw dump of all of it and a map that named almost every block: boot chain, firmware code (sys.bin), fonts, the previous owner's music library (Part 7 has the tour). One gap was left on the legend. The first 43,535 bytes of block 64, pages 0 through 20, where the boot resource partition begins.

I don't like gaps on a legend. The last one I left alone turned out to hold 294 blocks of data.

What the bytes looked like#

High entropy throughout: 7.99 bits per byte over the whole blob, out of a possible 8. My analysis script reported a chi-squared of 823.2 against a uniform distribution, and helpfully printed its own legend next to it: random ~255, compressed >1000. Every 32-bit word was unique, 10,883 out of 10,883. No file signatures. Not plaintext, and not the XOR encryption used on sys.bin, the firmware image.

On a chip where everything else is stored in the clear, that reads as a secret.

The wrong mental model#

High entropy on a flash chip means one of three things: encryption, compression, or a randomizer. Part 7 had shown that the Ingenic PN randomizer, a hardware scrambler built into the NAND controller, is unused on this chip. Compression looked unlikely, because nothing had a header. That left encryption.

The natural suspect was the same 172-byte XOR key that protects sys.bin. It decrypts the firmware perfectly. Maybe FiiO used it on the resources too.

header = data[:0xAA0F]
dec = bytes(header[i] ^ KEY[i % len(KEY)] for i in range(len(header)))

Garbage. No file signatures, no ASCII, nothing the XOR key could explain.

Working the problem backwards#

The NAND couldn't say what the bytes were. But x1.fw, the 46MB firmware update image, contains every resource file the firmware puts on flash. If these bytes exist, they exist in the update.

So, a little over two minutes in: take the first 64 bytes of block 64 and search the firmware image for them.

One hit, at offset 0x1CA5E00.

The secret was sitting in the public update file, byte for byte, and my first reading was that it had been encrypted before packing. Finding a secret in a public download did not, at first, make it any less of a secret to me. The file's own name did that.

First, compression after all, behind some offset? zlib.decompress as raw deflate, zlib and gzip, at offsets 0, 2, 4, 8, 16, 32 and 64; lzma.decompress at offsets 0, 4, 8 and 13. The script's verdict, in full:

No standard compression found

Then the name. Rockbox's packtools.cpp documents the x1.fw format: an SFHI magic header, a file table with up to 2,048 entries (56-byte name, sector offset, byte size) and a checksum. The image dated June 7, 2016 holds 2,024 files: coordinate INIs, litegui theme PNGs, TrueType fonts, Unicode tables, the boot logo, and sys.bin. A flat archive with no compression layer and no nesting. Names and offsets.

Getting from that table to a file name took the next forty-odd minutes. The hit at 0x1CA5E00 falls inside one of the files (the script's 104.0 KB counts 1,024-byte kilobytes; the 106KB and 63KB in this post are decimal):

File: litegui\theme1\list\bg.png
FW offset: 0x1C96800
Size: 106511 bytes (104.0 KB)
Mystery blob is at offset 62976 inside this file

A 320×240 PNG. The background behind the list view in theme 1.

63 kilobytes into a background image#

The match starts at byte 62,976 of bg.png, offset 0xF600. Only the last 43,535 bytes of the 106KB file sit in block 64, and all 43,535 of them match the image byte for byte.

The first 63KB is not in front of it: block 63 is erased. Looking for the rest took a little over an hour, including a detour through blocks 160 to 163, where the boot logo and some UTF-16 XML turned up instead. A search of the whole boot resource partition, blocks 64 to 163, for the file's own bytes found nothing. I never searched past block 163. The complete file, all 106,511 bytes, sits in blocks 702 and 703, among the fonts, where one search of the full dump finds it.

The tail tells the rest. The chunk scan of block 64:

PNG chunk markers in first 44KB:
  0xAA07: IEND (length field = 0)

IEND found at 0xAA07!
  Bytes around: 0000000049454e44ae426082

IEND, the PNG end chunk: a zero length, the chunk type, and the CRC-32 AE426082. Textbook. After it, 497 bytes of zero padding, then the first of the block's 29 PNG files at 0xAC00.

The boot resource partition stores files back to back across NAND blocks, and bg.png straddles a block boundary. Whatever block held this copy's first 63KB has since been reused or erased; my best explanation is the FTL's block recycling. The tail survived in block 64 because nothing reclaimed it.

Deflate doing its job#

The "opaque high-entropy data" is compressed pixel data: the back half of a PNG's IDAT stream.

High, even entropy is what deflate is supposed to produce. A good compressor leaves no repeating words and no recognizable structure, and every tool I pointed at the blob reported exactly that. None of them could say why, because the answer, "the middle of a compressed stream with no header in front of it", needs context that byte statistics don't have. My zlib attempts failed for the same reason: a deflate stream can't be picked up mid-flight.

The chi-squared legend had this blob backwards. It said compressed data scores above 1000. This scored 823. The legend was written by someone with opinions about compression, and that someone was me.

Every failed identification ruled out something true. Not encrypted. Not the XOR key. Not raw plaintext. The mistake was treating high entropy as suspicious on a device where the firmware is barely encrypted and 25MB of fonts sit in the clear. Compressed pixels are the most ordinary thing on the chip.

Which brings me to this series' fifth award: Most Convincing Impression of Encryption, presented to the background behind a list.

Case closed#

Part 7's map left one gap: the first 44KB of block 64, pages 0 through 20. That gap is now bg.png, bytes 62,976 through 106,511 of a theme background whose first part didn't survive in this partition.

1,024 blocks, every one accounted for, as far as the data area goes.

Seven parts ago this started with a boot ROM and a USB cable. The boot ROM led to the DDR settings, DDR to hwstub, hwstub to the IPL, the IPL to factory.x1, and hwstub again to the full NAND dump when the SPL never ran factory.x1 from my SD card. The dump became a byte-level map of everything on the chip. What's left is the firmware itself, 4.1MB of µC/OS-II already loaded in Ghidra.

Trying to decrypt and decompress it took under four minutes. Naming it took most of the first hour; looking for its first 63KB took a little over an hour more and found nothing, because I stopped at block 163. Two hours on a picture of nothing in particular, and the longer half went to a part sitting 539 blocks further down the chip. In my defence, that half hid very well. The tail was the last thing on the chip I couldn't name. Now there isn't one. Time to read the code.

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

Sources#