Signing the kernel with a FIT image, and the escape hatch that makes it theatre
You cannot sign a zImage. Why the signature goes on the FIT configuration rather than the kernel, the escape hatch that turns the whole thing into theatre, why the negative test needed two tampered images and not one, and the three ways the test lied to me.
Last time the board refused to boot a tampered bootloader. This time I wanted the same refusal for the kernel, and it turned out to need two tamper tests instead of one. Working out why is most of this post.
Where the chain had got to
Quick recap of the four links, because I use these numbers constantly:
| What checks what | Needs fuses? | |
|---|---|---|
| Link 1 | ST's boot ROM checks my first bootloader | yes — so never, on this board |
| Link 2 | That bootloader checks U-Boot and the secure monitor | no |
| Link 3 | U-Boot checks the Linux kernel | no |
| Link 4 | Linux checks the root filesystem | no |
Link 2 was done and proven. Link 3 was next, and like Link 2 it needs nothing burned into the chip — it's just software I build, reading a signature and stopping if it doesn't add up.
You can't sign a kernel
This was the first thing I had to get over.
A zImage is a self-extracting blob. There's no header with a spare field for a signature, no metadata section, nothing. And even if I bolted a signature onto the end, the kernel isn't the only thing that gets loaded — there's a device tree next to it, describing the hardware. Sign the kernel alone and an attacker just swaps the device tree, which is more than enough to redirect where the root filesystem comes from.
The answer is a FIT image — Flattened Image Tree. It's a container: kernel, device tree, and signatures, all in one file, described by a small source file you compile with mkimage. Mine ends up as /boot/kernel.itb, about 11 MB.
The part that took me longest to appreciate: the signature doesn't go on the kernel. It goes on the FIT's configuration node — a tiny piece of metadata that lists which kernel and which device tree belong together, and records a SHA-256 hash of each.
So it's two steps, not one:
- the signature attests the metadata, including the hashes it records
- the hashes attest the actual bytes of the kernel and the device tree
11 MB of kernel, protected by one RSA signature over a few hundred bytes of metadata. And because the configuration names both images, the kernel and its device tree are bound together as a pair. You can't mix and match.
That construction is elegant and it is also the reason the negative test needed two images. More on that below.
Where the public key lives, and why it matters
U-Boot needs my public key to check the signature. It keeps it in its own control device tree — the device tree U-Boot uses to describe its own hardware, not the one it hands to Linux. mkimage injects the key there as a side effect of signing.
That location is the entire point. U-Boot's device tree is bundled into the FIP, which sits in its own partition, and it is verified by Link 2. An attacker who has rewritten the root filesystem on the SD card can replace kernel.itb all they like — they cannot reach the key that judges it.
It does create an ordering problem that cost me a build. Signing the kernel modifies u-boot.dtb. But u-boot.dtb was packed into the FIP earlier in the build. So the first time through, the FIP contained a key-less device tree, and U-Boot had nothing to verify against. The build has to rebuild the FIP after signing the kernel, which reverses the order you'd naturally write it in.
The escape hatch
There's a U-Boot option called CONFIG_LEGACY_IMAGE_FORMAT. With it on, U-Boot will happily boot old-style unsigned images.
Leave that on and everything I've just described is theatre. The signature gets checked, and then if anything about it is unsatisfactory U-Boot shrugs and boots the thing anyway by another route. You get all the log lines that suggest security and none of the behaviour.
CONFIG_FIT_SIGNATURE=y
CONFIG_RSA=y
# CONFIG_LEGACY_IMAGE_FORMAT is not set
That third line is the one that turns the feature into a control. It's a comment. It looks like nothing.
It worked
Verifying Hash Integrity ... sha256,rsa2048:dev-fit-key+ OK
Twice — once for the kernel, once for the device tree.
That trailing + is the bit to read. It's U-Boot's marker for "signature checked and accepted". A rejected key prints - instead. There's a second line under each of those, sha256+ OK, which is the per-image hash check — that's integrity only, and it would pass just as happily on an image nobody ever signed. The line with the key name in it is the one that means something.
Now break it
Here's where I had to stop and think.
For Link 2 I flipped one bit and the board refused. Obvious test, obvious result. I assumed Link 3 would be the same: flip a bit in the kernel, watch it fail, write the post.
But Link 3 verifies in two steps, and flipping a bit in the kernel only exercises the second one. The metadata is untouched, so the signature still verifies perfectly — what breaks is the kernel's hash. That's a real refusal and it proves something, but it doesn't prove the signature is doing anything at all. I could have removed the signature entirely and that test would have looked identical.
So there are two tampered images now, each aimed at one step:
| Image | Signature | Kernel hash | Board must |
|---|---|---|---|
| good | OK | OK | boot |
| one kernel bit flipped | OK | mismatch | refuse |
| re-signed with someone else's key | refused | OK | refuse |
The second one is the realistic attack. It's not a corrupted file — it's what somebody who rebuilt the kernel from source would actually hand you. Every hash internally consistent, the whole thing signed, just signed with the wrong key.
Two details I was deliberate about:
The attacker's key uses the same name as mine. U-Boot looks keys up by a name hint stored in the image. If I'd given the attacker key a different name, U-Boot would reject it for not finding the name, which proves nothing about cryptography. Same name, different key material — now the only thing that can save it is the maths.
The attacker's public key does not go into U-Boot's device tree. mkimage will inject it for you if you pass the flag. Doing that would teach U-Boot to trust the attacker, which is the exact thing I'm testing. Sounds obvious written down. It is one flag.
Three ways this test lied to me
The build kept quietly repairing my sabotage. I'd tamper the kernel image, run the make target that repacks the root filesystem, and get a "tampered" firmware that booted perfectly. It took me a while to see it: that make target re-runs my post-build hook, which rebuilds the FIT and signs it again. I was carefully corrupting a file and then asking the build to fix it. The tamper has to go into the staging copy the packer actually reads — and the build deletes that directory after every pack, so it has to be recreated each time.
This is the failure mode I find genuinely frightening, because the test passes. Nothing errors. You just quietly prove nothing and write it down as evidence.
I read a tool's output through tail and drew the wrong conclusion. There's a checker called fit_check_sign. It prints its summary line first and the detail afterwards — so Signature check bad (error 1) can appear on line one, followed further down by a signature that passed and a hash that failed. I looked at the last few lines, concluded the tool ignores image hashes entirely, and wrote that down as a fact. It checks both. I'd cut the evidence off with my own command.
And a hex formatting detail marked my good image as broken. Reading a hash out of the device tree with fdtget prints each byte without zero padding, so 0e comes back as e. Compare that against a normal sha256sum and it doesn't match — on a perfectly good image. For about five minutes I thought I'd broken the working build.
There's a fourth, which is pure shell: don't pipe that checker into grep -q while pipefail is set. grep -q exits the moment it finds a match, the broken pipe fails the whole pipeline, and every image gets reported as refused — including the good one. My verification script did exactly this and gave me the right answer for entirely the wrong reason, which is worse than being wrong.
So the script now checks the two steps separately, keeps the tool's exit status as the verdict, and shouts if its own reading disagrees with the tool. A test that can only agree with itself isn't a test.
A bug that was hiding behind a log line
Unrelated, except it isn't.
I'd asked for a fix to a noisy logger warning. The fix changed which log handler was installed, and suddenly the console showed things it had been swallowing for weeks. One of them:
Using slot detection heuristic due to ops.fw error:
"Couldn't find applicable task 'status'."
The board runs two copies of its own firmware, A and B, so an update can be rolled back. To answer "which one am I running?", the Nerves runtime shells out to a little file on the device that knows how to read the bootloader's environment. That file was never being built for my system — only an older, narrower version of it that can revert but can't report.
So the runtime fell back to guessing. On a board whose entire update safety story rests on knowing which slot is live. It boots fine, nothing crashes, and the only symptom is one line in a log nobody was reading.
Writing the missing file took an afternoon, mostly because I trusted an API that reads like something it isn't. The Elixir side pattern-matches on {:warning, "a"}, so I wrote warning("a"). There is no warning function. You call info(), and it's the framing protocol underneath that tags the output as a warning. I only found that by hexdumping the output.
Then I built a fake 8 MB SD card with a real bootloader environment on it and drove it through all five states the device can be in, because I wasn't going to ship a slot-reporting fix that reports the wrong slot.
The build now refuses to produce firmware if that file is missing. That's the pattern I keep coming back to: this class of bug never announces itself, so the only defence is a check that runs whether you remember to look or not.
Both of them said no
I flashed all three. No SD card involved — all three images share the same bootloader, so I could send them over the network, which turned out to matter more than I expected. More on that in a second.
The flipped bit:
Verifying Hash Integrity ... sha256,rsa2048:dev-fit-key+ OK
Verifying Hash Integrity ... sha256 error!
Bad hash value for 'hash-1' hash node in 'kernel' image node
Bad Data Hash
ERROR -2: No such file or directory: can't get kernel image!
STM32MP>
Read the first two lines together, because that's the whole reason there are two tests. The signature passed. I changed one bit of kernel and the RSA check still says OK — because I didn't touch the metadata it covers. What caught it was the kernel's own hash, one step later.
The wrong key:
Verifying Hash Integrity ... sha256,rsa2048:dev-fit-key- error!
Verification failed for '<NULL>' hash node in 'conf' config node
Failed to verify required signature 'key-dev-fit-key'
A minus where the good image prints a plus. One character, and it's the entire result.
And look at what's missing from that second one: there's no Trying 'kernel' kernel subimage block. U-Boot rejected the configuration signature and never even looked at the kernel. The second check never ran.
One thing that nearly sent me down a wrong path: both failures end with ERROR -2: No such file or directory: can't get kernel image!. That reads like a missing file. It isn't — it's U-Boot's generic "couldn't load" line printed after the real reason. The cause is always the three lines above it.
The part I didn't plan
Both times, I typed reset expecting to have to go find the SD card. Instead:
Reverting firmware...
The board keeps two copies of its firmware. An update writes to the idle one and marks it unvalidated; the running system validates itself once it's up. A kernel that won't boot never gets that far — so on the next reset the bootloader sees an unvalidated slot, switches back to the previous one, and boots that.
Which means I deliberately installed a kernel that cannot boot, twice, on a board sitting on a different desk from the SD card reader, and both times it fixed itself in about fifteen seconds.
I've read that description of A/B updates many times. Watching it actually happen, after breaking the thing on purpose, is different. It's the first time this project has felt like it's building a product rather than a demo.
Where I actually am
Honest status, because the whole point of this series is not overclaiming:
- Link 2 — proven on hardware. Tampered bootloader, board halts.
- Link 3 — proven on hardware, both steps. Tampered kernel halts. Wrong key halts. Different errors, different stages, exactly as the design says.
- Link 1 — signed, offline-verified, and never enforced on this board. Every log above still says
ROTPK is not deployed on platform. That needs fuses, and I'm not burning fuses. It's the honest hole in all of this and I'd rather say it in every post than have someone find it. - Link 4 — doesn't exist. The root filesystem is still completely unverified.
That last one is the next post, and it's the uncomfortable one: I have a signed bootloader, a signed monitor, a signed U-Boot and a signed kernel, and I can still rewrite the entire application and the board will boot it without complaint.
And not one fuse burned to get this far.