Skip to content

The boot chain

From reset to RetailOS, four stages run before a single line of the OS itself executes: the boot ROM, the ONB (Apple's own NOR-resident secondary loader), and the point where the ONB hands control to RetailOS on disk.

This page is the reverse engineering: what each stage does, read out of the image. What Chikuma built from it - a boot ROM and an ONB that are entirely C, and a chain that boots Apple's own RetailOS - is The boot chain, in C.

graph TD
    R["Reset<br/>0x20000000"] --> BM["bootrom_main<br/>0x20003458"]
    BM --> BC["boot_context_init<br/>reads GPIO group 3, pins 6/7"]
    BC -->|mode 1/2| NOR["boot_from_spi_nor<br/>0x20002fa8"]
    BC -->|mode 3/4| NAND["boot_from_nand"]
    BC -->|no image found| DFU["USB DFU loop<br/>dfu_command_dispatch"]
    NOR --> V1["dfu_verify_signature<br/>+ dfu_header_verify"]
    V1 -->|ok| JMP["jump_to_image<br/>0x200034a0"]
    V1 -->|fail| DFU
    JMP --> ONB["ONB: SEC → PEI → DXE_CORE → drivers → BDS"]
    ONB --> BDS["onb_bds_boot_candidate_policy<br/>tries osos, osr3, diag, wtf, aupd…"]
    BDS --> OS["RetailOS, on the disk's FAT16 firmware partition"]

Stage 1: the boot ROM picks a device from GPIO

bootrom_main zeroes its own stack frame - that frame is BootContext, the one structure the whole ROM passes around - then hands off to boot_context_init, which reads two GPIO pin pairs and picks a mode:

Mode Pins Path
1 group 3, pins 6/7 = 00 boot_from_spi_nor(0)
2 boot_from_spi_nor(2)
3 boot_from_nand(0)
4 boot_from_nand(1)

On the iPod Classic 6G, both pins float low, which selects mode 1 - the only path taken on this device.

Stage 2: SPI-NOR read, verify, decrypt, jump

spi_flash_read_image(bus, 0x800, 0x22020000, 0)     the container header
dfu_verify_signature(header, mode=2)
if header.enc_type in {1, 2}:
    spi_flash_read_image(bus, header.data_sz, 0x22000000, 0x8800)   the body
    dfu_header_verify(header, 0x22000000, 1)        verifies AND decrypts in place
    jump_to_image(BootContext[0x30] + 0x22000000)

The digest the leaf signature actually covers, read directly out of dfu_header_verify:

digest = SHA1(header[0:0x800] || body[0:round16(data_sz)])
signature = header.data_sz rounded up to 16, immediately after the body   (0x80 bytes, RSA-1024)

The certificate chain itself is standard DER, not a custom struct - dfu_x509_chain_and_sig_verify looks for 30 82 <len> (an ASN.1 SEQUENCE, long-form length) at the front of the chain blob and slices up to three back-to-back certificates by their own encoded length. The root anchor is a literal 20-byte SHA-1 digest baked into the ROM (root_public_key_blob+1088) - a cert is only accepted if it hashes to that exact value, which is mask ROM: no fuse, no field, nothing that changes it. Subject-name strings (/CN=Apple Secure Boot Certification Authority and similar) pick which chain slot is root/intermediate/leaf; they do not replace the RSA check on any of them.

Source offset 0x8800 and header at 0x8000 are exactly where the ONB lives in NOR - so the ROM's normal path loads the ONB to 0x22000000 and jumps to it.

Stage 3: the ONB is a real, small UEFI environment

This is the part that looks nothing like a typical embedded bootloader. The ONB's body is an EFI Firmware Volume - _FVH signature, a real block map, a real header checksum - carrying 42 FFS files: a SEC/SECURITY_CORE, a PEI_CORE, a DXE_CORE (its GUID matches Intel/EDK2's own DxeMainDxe exactly, byte for byte - this really is Apple's build of Tiano's reference DXE core for ARM), and 38 drivers, some stock EDK2 (DiskIoDxe, PartitionDxe), most Apple-authored.

graph LR
    SEC["SECURITY_CORE<br/>TE image"] --> PEI["PEI_CORE"]
    PEI --> DXE["DXE_CORE<br/>= DxeMainDxe"]
    DXE -->|dispatches| DRV["38 drivers:<br/>ATA, LCD, DiskIo, PartitionDxe,<br/>IpodFirmwareFS, the ROM/BDS policy…"]
    DRV --> BDSMOD["Bds.efi"]
    BDSMOD --> POLICY["onb_bds_boot_candidate_policy"]

Stage 4: BDS picks a boot candidate

onb_bds_boot_candidate_policy checks named variables in order - osos (RetailOS), osr3, diag, dfup, rsrc, bdhw among them - each resolved through the plain, standard EFI file API:

Find(name):
    LocateHandleBuffer(ByProtocol, EFI_SIMPLE_FILE_SYSTEM_PROTOCOL_GUID, &count, &buffer)
    for each handle in buffer:
        HandleProtocol(handle, &SFS_GUID, &sfs)
        sfs->OpenVolume(sfs, &root)
        root->Open(root, &file, name, EFI_FILE_MODE_READ, 0)
        if success: break
    return file (or NULL)

There is no drive-letter router and no custom index lookup at this layer - it is tried, unmodified, against every filesystem-capable volume in whatever order LocateHandleBuffer returns them.

The one filesystem BDS can actually see

Exactly one driver in the ONB produces EFI_SIMPLE_FILE_SYSTEM_PROTOCOL: IpodFirmwareFS.efi, over the Apple_MDFW/iPod_Firmware partition (not the FAT16 resources volume, and not the HFS+ data partition - neither has a driver present at this boot stage). Its on-disk format, traced end to end:

Offset Size Field
0x00 u32 per-entry magic, "flsh"
0x04 u32 4-byte ASCII name, big-endian packed ("osos" = 0x6f736f73)
0x08 u16 type/flags; 0xFFFF (erased NOR) = slot unused
0x10 u32 raw value; real byte offset = round_up(raw + 0x200, 64)

40 bytes per entry, a flat array at a fixed partition offset, gated by a "STOP screen" copyright banner at partition offset 0 that every reader checks before trusting the table. Open() here is a plain UCS-2 string compare against the decoded 4-character name - which is also why it can only ever resolve a 4-character request.

The genuine Apple image directory, and the read path

The flsh table above is the structure this project's patch_apm_osos.py writes at partition offset 0xffe00, and Open()'s name match resolves against it. But the candidate loader's actual file read walks a different, genuine Apple structure: an "!ATA" image directory at partition offset 0x5000, byte-identical between the built disk and the pristine ipsw bundle, so it is Apple's own firmware-partition layout, not anything this project synthesises. Measured 2026-09-08 from the parse code at IpodFirmwareFS's live base +0x714:

Offset Size Field
0x00 u32 per-entry magic, "!ATA"
0x04 u32 4-byte name, 16-bit-half-swapped ("osos" stored 73 6f 73 6f)
0x0c u32 start offset, partition-relative (INFERRED: offset-shaped, matches the real container, not watched being read)
0x10 u32 length in bytes (MEASURED: used in the read's covers check, and size-shaped)

40-byte stride, indexed with a bounds check against a live entry count. Apple's own entries:

Name start +0x0c length +0x10
rsrc does not fit start/length (its +0x0c is size-shaped, +0x10 offset-shaped) - unresolved
osos 0x4e07000 0xa06aa3
aupd 0x580f000 0x11c8d3
hash 0x592d000 0x1000

So Apple's directory already names osos and points it at a real enc_type 3 container in the partition. Nothing about the directory or the loader needs changing to boot osos.

Why the built disk lands on the restore screen

Not a missing image, not crypto, not the firmware. Our build damages the real partition. Comparing the built ipod_apm.img against the pristine bundle, the firmware partition diverges in two 10.5 MB regions, both ours:

  • around 0x100000: patch_apm_osos.py's flsh directory and its symmetric osos container - a scheme the read path never consults - overwriting 10 MB of real Apple content.
  • 0x4e07000 to 0x580f000: osos's own native slot rewritten, the write running past its end and zeroing aupd's container at 0x580f000. That zeroed region is exactly where the loader's osos read landed, so it read all zeros, osos never became a live file object, the "8702" magic compare failed, and BDS fell through to the restore screen (Stage 5).

The honest fix is build-side only: leave Apple's !ATA directory intact, stop writing the ignored flsh scheme, and replace osos in place at partition offset 0x4e07000 with a Chikuma-signed enc_type 3 container no longer than the 0xa06aa3 Apple's own entry declares, without overrunning into aupd. Faking a directory to make Find() succeed would only stage a boot that is not real.

Open, and the peer session's to close: whether Open()'s name match genuinely uses the flsh table while the read uses !ATA, or the earlier flsh trace and this !ATA one are the same lookup seen at two layers. The +0x0c start field has not been watched being consumed by the LBA computation, only matched by its value.

The unsigned debug path, and why it can never fire

Before any of the named candidates, onb_bds_boot_candidate_policy unconditionally runs a debug hook that tries to load C:/MicroShell.fv - an EFI-shell-style engineering tool, Apple's own internal name for it. Traced end to end:

graph TD
    A["LocateProtocol(MicroShell resolver GUID)"] -->|NULL| X["return, clean"]
    A --> B["resolver(this, L#quot;C:/MicroShell.fv#quot;)"]
    B -->|NULL| X
    B --> C["bounds check: file size vs FV window"]
    C -->|fail| DL["CpuDeadLoop(), hard hang"]
    C -->|ok| D["HandleProtocol"]
    D -->|status<0| DL
    D --> E["build MEDIA_PIWG_FW_FILE_DP path node"]
    E --> F["LoadImage(BootPolicy=TRUE, …)"]
    F --> G["StartImage(handle, NULL, NULL)"]
    G --> H["return, unconditionally, status never checked"]

No EFI_SECURITY_ARCH_PROTOCOL/EFI_SECURITY2_ARCH_PROTOCOL call exists anywhere in the 42-file volume, so this path has no authentication to fail even if reached. It is unreachable for a different reason: IpodFirmwareFS.efi - the only filesystem the ONB can see at this point in boot - matches names with a plain string compare against a 4-character decoded field. "C:/MicroShell.fv" is 17 characters. It cannot match any entry, on any partition, under any circumstance. Not a security gate - a structural mismatch left over from an engineering build that never had a way to fire once shipped.

Reassembling, and what replaced it

Chikuma never hex-patched Apple's compiled images. The first pass at changing the chain reassembled it: firmware/reasm re-emitted each module's own Ghidra listing, assembled it, and cmp'd the result against the real .efi, byte for byte. That gate did exactly one job, and did it well - a reassembly that matches to the byte proves the disassembly was read correctly - and it is history now. Both stages are C:

    chikuma/bootrom/   the mask ROM
    chikuma/onb/       all 40 modules of the NOR bootloader

C is not byte-identical to Apple's compiler output, by construction, and the gates that replaced the byte compare test behaviour instead: per-module host suites against the image's own bytes, QEMU integration payloads against the real device models, and the whole chain booting the original firmware. See The boot chain, in C for how it builds and what each gate covers.

The Firmware Volume is assembled by this tree's own parser/serialiser (chikuma/onb/tools/fv_bundler.py), which substitutes each module's C-built PE image into the volume by GUID and reserialises it:

cd chikuma
make onb-full-c-fv       # every non-SecCore module as an independently emitted C PE image
make secureboot          # ... plus the bootrom, signed with the Chikuma CA
make secureboot-disk     # ... planted on a fresh copy of the disk image

Signing a Chikuma build

Because the reassembled boot ROM swaps the root-of-trust hash for Chikuma's own, the whole X.509 chain can be Chikuma's rather than Apple's. firmware/chikuma_ca is a self-contained CA (its own git repo, kept out of the main tree): a root that mirrors Apple's RSA-2048, an RSA-1024 intermediate and leaf, all with SHA-1 signatures and the ROM-required 9-byte 01 FB 01 FB serials. bootstrap.sh regenerates the chain from the committed keys without ever replacing a key (the leaf key signs the image, so a new key would invalidate an already-signed build).

Two build steps turn that chain into a bootable image:

cd firmware/chikuma_ca
# sign an arbitrary OSOS body (e.g. Apple's own decrypted osos-dec.dfu[0x800:])
make chikuma-osos-x509 OSOS_BODY=../extracted/osos-dec.dfu.body
# or pack and sign OUR OWN build in one step: payload.elf -> OSOS two-view body -> signed container
make chikuma-osos-container            # CHIKUMA_ELF defaults to ../../chikuma/payload.elf

make_chikuma_osos.py packs the payload ELF into the OSOS two-view body the loader expects (body[0:0xAE5C] at 0x22000000, body[0xAE5C:] at 0x08000000) and wraps it in the signed enc_type 3 container, leaf certificate first. The result is planted in place at the !ATA directory's osos slot (partition offset 0x4e07000), no longer than the 0xa06aa3 Apple's own entry declares, without overrunning aupd.

Stage 5: what BDS does when nothing boots

Measured live on 2026-09-08 (Stall hooks over a whole boot, a call ladder in Bds, and QEMU's own MMIO trace), so this is the ONB's behaviour, not the emulator's:

  1. Every candidate has failed. BDS writes the -------- reboot reason.
  2. It reads the PMU status through DxePcf50635: bit 2 is PMU register 0x12 bit 2, the data-host detect (reports/usb_power_sources.md, source 3).
  3. No computer attached: it draws the bdsw picture from the flsh directory and polls that bit every 100 ms for 10 s, exiting early if a host appears and holds for two reads. Then Display(1), Power(0).
  4. Computer attached: it skips the wait, tries the disk candidate, draws bdhw, stalls 30 s, then the same Display(1), Power(0).

Power(0) is DxePcf50635's 0xbeec5aa: write PMU register 0x0c := 1 (PCF5063x OOCSHDWN.GOSTDBY, power off) and spin forever. The "terminal halt" at 0xbeec5d8 that every session ended on is the firmware waiting for the PMU to cut its power.

bdsw is the restore prompt:

The ONB's restore screen, captured from QEMU

The seven flsh pictures are ciphertext in the NOR, so this is the first time one has been seen: the ONB decrypts and decodes it itself (DrawJpeg.efi, JpegDecoder.efi) and writes it to the panel through the LCD controller's WDATA port, one pixel per write - a whole-panel white fill by DMA, then a 176x116 window for the text - after CASET/RASET set the window. RetailOS never does this; it streams frames through the display pipe, and the QEMU display used to show only the pipe, so this screen was drawn and invisible until devices/lcd.c learned the write window and devices/pipe.c learned to show the panel's memory when the pipe is idle.

Two consequences for the emulator: PMU register 0x0c bit 0 should end the machine (qemu_system_shutdown_request) instead of spinning, and the 10 s and 30 s waits are Stall() busy-loops on a timer read, which run at a fifth of real time under -icount shift=3; use --shift 7 --warp on this chain (qemu-ipod/USING.md).

Where this stands under QEMU

The chain is complete. From reset, on the emulated machine, with nothing of Apple's binary left in it:

  • boot ROM → SPI-NOR read → RSA/X.509 verify → AES decrypt → jump into the ONB: works, from the C boot ROM.
  • SEC → PEI (PreEfi) → DXE core → drivers → BDS: works, from the C volume, all 40 modules.
  • BDS finds osos, verifies it and jumps: works, and what it starts can be either Chikuma or Apple's own unmodified RetailOS, signed with the Chikuma CA. The second is the control: a bootloader that starts the original firmware is a bootloader that behaves.

Getting there needed real emulator work, and the distinction matters - none of these were firmware bugs, they were gaps in what the machine had to supply: SPI-NOR framing, SHA-1 padding (a missing start-of-hash reset on the DATAIN-fed path), an unmodelled NVRAM read, and the 0x3d000000 modexp engine below.

The modexp engine, and the gates that are not crypto

The last real gap was that BDS's candidate loader calls back into the boot ROM's own dfu_header_verify with a hardcoded mode=2 - the X.509/RSA branch - regardless of the container's own enc_type, so osos is routed through X.509 verification unconditionally.

  1. 0x3d000000 is modelled (qemu-ipod/devices/eccrsa.c). It is not "ECC" and not NAND: it is a hardware modular-multiply accelerator the RSA verify drives through ecc_decode_page's square-and-multiply ladder (the ROM's own software mpi_exptmod/mpi_modinv have zero callers). A per-command multiply ch[dest] = ch[a] * ch[b] mod N with the destination in the descriptor's low byte; a config-bit-1 command that loads the identity to seed the accumulator; and ecc_wait_channel's own inline trigger copying the result into the channel the read returns. The model reproduces a real intermediate-by-root RSA-1024 verify against Apple's own 2048-bit root modulus.
  2. The chain's non-crypto gates are recovered. Beyond the per-cert RSA-1024 PKCS#1v1.5 signature and the root-hash anchor, bootrom_verify_cert_chain requires every certificate's serial number to be exactly 9 bytes with the first four bytes 01 FB 01 FB - an Apple prefix the ROM hard-compares (an alternate 01 FB 00 FB is accepted only when a chip-ID fuse bit is clear). Certificate signatures must be SHA-1: the ROM's DigestInfo templates are MD5/SHA-1 only, so a SHA-256-signed cert fails the parse before verification.

Timing, and one open emulator question

The ONB's waits are Stall() busy-loops on a timer read, which run at a fifth of real time under -icount shift=3; use --shift 7 --warp on this chain (qemu-ipod/USING.md). PMU register 0x0c bit 0 ends the machine rather than spinning, so the ONB's own power-off path terminates QEMU.

Still open: the machine merges the two timer interrupt lines. The hardware splits them - IRQ 8 for the 16-bit blocks, IRQ 7 for the 32-bit ones, and ONB module 36 routes its own Timer F to 7 - while devices/timer.c ORs both pending sets onto line 8. Splitting them was tried and broke the ONB's timer path on the model's unverified TSTAT write-1-to-clear semantics, so it stands as written with the divergence recorded.

The full detail lives in reports/nor_contents.md and reports/bootrom_crypto.md.