Series Secure boot on an i.MX8M gateway Part 9 of 10 · all parts

Signing the kernel, and a bug the chip caught

Last post ended with the bootloader itself signed, flashed, and proven — a built-in status check on the chip confirmed it accepts the signed version and…

Last post ended with the bootloader itself signed, flashed, and proven — a built-in status check on the chip confirmed it accepts the signed version and genuinely rejects a deliberately unsigned one. But that only covers the very first thing that runs. Once the bootloader hands off control, it was still loading a completely unsigned Linux kernel, no questions asked. This post is about closing that gap.

Pulling the real kernel off the board

The kernel this board actually boots — the exact file, not a fresh rebuild of one — lives on a small storage partition inside the chip's own eMMC (the built-in storage soldered to the board, a step up from a plain SD card). I plugged the board's second USB port in after it had already reached its boot menu (plugging it in any earlier makes the chip sit in a silent programming mode instead of booting normally — a gotcha from earlier in this series), typed one command, and the chip exposed that partition as an ordinary external disk on my computer. Copied the kernel file and its device tree (a separate file describing exactly which chips and pins this specific board has) straight off it, plus a full backup of that whole partition while I was there, since it cost nothing extra.

One small, satisfying discovery along the way: the kernel file is actually a symlink — a pointer to another file with the real kernel version number in its name. Checking that name against a note I'd made months earlier, from logging into this exact board over SSH the very first time, it matched exactly. Small thing, but a nice, independent confirmation that I really was about to sign the actual, currently-running kernel and not some unrelated file that merely shared a name.

Building the signed version

The signing shape here is a little different from the bootloader. The bootloader's signature data lives in space that was deliberately reserved for it ahead of time, baked into the build. A plain Linux kernel image doesn't reserve any such space — so the chip vendor's documented approach is to pad the kernel out to a fixed size, glue a small structure onto the end that points at where the signature data will go, and then append the signature data itself as a separate block after that. Three pieces stacked one after another, not something threaded through the original file.

Reading the real file's own header (the very first bytes of any Linux kernel image describe, among other things, exactly how large the kernel claims to be) turned up a genuine, non-trivial gap: the file on disk was noticeably smaller than its own declared size — nearly three quarters of a megabyte of difference. That's not an error, just how this kernel build happens to be laid out, but it meant the padding step was doing real, necessary work, not just going through the motions. Padded it out, built the small pointer structure by hand from the chip's real load address (a number I'd already recorded from this exact board, not copied from an unrelated example), wrote out the signing instructions, ran them through the signing tool, and got a signed kernel file.

The bug: a real cryptographic failure, and a real lesson

Loaded the signed kernel onto the board and asked the same "is this validly signed" question I'd already asked successfully about the bootloader. This time, it said no — a real, decoded cryptographic failure, not a typo or a missing file.

Rather than guess, I read the failure's own raw output byte by byte. Buried in there were two numbers: an address, and a length of exactly 32 bytes. Both pointed at the exact same place — the little pointer structure appended right after the padded kernel. That was the clue: my signing instructions had told the chip to check everything up to that structure, but not the structure itself. The chip vendor's own diagram of this process reads, at a glance, like the checked region stops right there — but reading it again, more carefully, the checked region is actually meant to reach through that structure too, all the way to where the real signature data begins. I'd stopped exactly 32 bytes short.

I didn't just trust that re-reading, either — I went back to the bootloader signing from the last post, which I already knew for a fact works on real hardware, and checked its own numbers the same way. Same shape, same conclusion: the region it actually signs already extends through its own version of that same pointer structure. That was enough independent evidence to be confident in the fix, not just a hopeful guess. Extended the checked region by those exact 32 bytes, re-signed, and tried again.

Clean pass. And to make sure this wasn't a coincidence, I went one step further and actually booted it — loaded the signed kernel and its device tree into memory by hand and told the board to boot from them directly. It came up all the way to a completely normal login prompt, the exact same one this board shows every single day. The signed file isn't just cryptographically valid; it's genuinely the same working kernel, provably so.

Where this leaves things

Nothing about any of this is wired into the board's actual startup sequence yet — everything above was typed by hand, one command at a time, and none of it survives a power cycle. That's deliberate. The next real step is teaching the board's normal startup sequence to do this signature check automatically, with a real "refuse to continue if it fails" branch — and along the way I found something worth its own honest mention: on this particular board, in its current unlocked state, that specific kind of check-and-refuse can be built correctly but can't quite be proven on the hardware itself. Why that is, and what it would actually take to change it, is exactly what the next post is about.