The previous tutorials in this series attacked embedded systems: extracting firmware, auditing attack surfaces, exploiting services, and accessing serial consoles. This tutorial flips perspective. You’ll build the defenses, implementing a verified boot chain that rejects tampered firmware, then put on the attacker hat again and try to break them.
Secure boot is the foundation of firmware integrity. Without it, an attacker with physical access (or a firmware update vulnerability) can replace the device’s software entirely. With it, each stage of the boot process cryptographically verifies the next stage before executing it, forming a chain of trust from hardware to application.
Chain of trust:
┌─────────────┐ verifies ┌─────────────┐ verifies ┌─────────────┐
│ Boot ROM │ ───────────→ │ U-Boot │ ───────────→ │ Linux │
│ (immutable) │ signature │ (bootloader)│ signature │ Kernel │
└─────────────┘ └─────────────┘ └──────┬──────┘
│ verifies
┌──────▼──────┐
│ Root FS / │
│ Init System │
└─────────────┘We use U-Boot’s FIT (Flattened Image Tree) format with RSA signatures for the verification mechanism. FIT images bundle the kernel, device tree, and optional ramdisk into a single signed package that U-Boot can verify before booting.
Understanding the boot trust chain
What each stage verifies
| Stage | Stored in | Verifies | Trust anchor |
|---|---|---|---|
| Boot ROM | SoC (read-only silicon) | U-Boot SPL | OTP fuses (hash or key) |
| U-Boot SPL | Flash (first stage) | U-Boot proper | Public key embedded in SPL |
| U-Boot | Flash (second stage) | FIT image (kernel + DTB) | Public key in U-Boot binary |
| Kernel | FIT image | Root filesystem (dm-verity) | dm-verity root hash |
In QEMU, we don’t have a real Boot ROM or OTP fuses. We’ll focus on the U-Boot → kernel stage: signing FIT images and configuring U-Boot to reject unsigned or tampered images.
FIT image structure
A FIT image is a device tree blob (.itb file) that contains:
FIT image (.itb):
/images
/kernel
data = <compressed kernel binary>
type = "kernel"
arch = "arm"
compression = "gzip"
hash { algo = "sha256"; value = <hash>; }
/fdt
data = <device tree blob>
type = "flat_dt"
hash { algo = "sha256"; value = <hash>; }
/configurations
/conf-1
kernel = "kernel"
fdt = "fdt"
signature {
algo = "sha256,rsa4096"
key-name-hint = "dev-key"
sign-images = "kernel", "fdt"
value = <RSA signature>
}U-Boot verifies the RSA signature over the configuration’s hash, which covers the kernel and device tree hashes. If any component is modified, the signature is invalid and U-Boot refuses to boot.
Generating signing keys
Create an RSA key pair for signing FIT images.
mkdir -p ~/secure-boot-lab/keys
cd ~/secure-boot-lab/keys
# Generate an RSA 4096-bit private key. U-Boot's mkimage derives the
# filename from key-name-hint, so this must match "dev-key" in the ITS.
openssl genpkey -algorithm RSA -out dev-key.key \
-pkeyopt rsa_keygen_bits:4096 \
-pkeyopt rsa_keygen_pubexp:65537
# Create a self-signed certificate containing the public key.
# mkimage uses this when it writes the public key parameters into U-Boot's DTB.
openssl req -batch -new -x509 -key dev-key.key -subj "/CN=dev-key" -out dev-key.crt
# Optional: view the public key in PEM format
openssl rsa -in dev-key.key -pubout -out dev-key-pub.pem
# View key details
openssl rsa -in dev-key.key -text -noout | head -5Private-Key: (4096 bit, 2 primes)
modulus:
00:b3:4f:...
publicExponent: 65537 (0x10001)Warning
Key management In production, the private signing key must be stored in a Hardware Security Module (HSM) or at minimum an air-gapped machine. Anyone with the private key can sign arbitrary firmware that the device will accept. For this tutorial, a local key is fine.
Creating a signed FIT image
Writing the FIT image description
Create an Image Tree Source (.its) file that describes the FIT image contents:
cat > ~/secure-boot-lab/fit-image.its << 'EOF'
/dts-v1/;
/ {
description = "Signed ARM kernel + DTB";
#address-cells = <1>;
images {
kernel {
description = "Linux kernel";
data = /incbin/("zImage");
type = "kernel";
arch = "arm";
os = "linux";
compression = "none";
load = <0x60000000>;
entry = <0x60000000>;
hash-1 {
algo = "sha256";
};
};
fdt-1 {
description = "Device tree";
data = /incbin/("vexpress-v2p-ca9.dtb");
type = "flat_dt";
arch = "arm";
compression = "none";
hash-1 {
algo = "sha256";
};
};
};
configurations {
default = "conf-1";
conf-1 {
description = "Signed boot configuration";
kernel = "kernel";
fdt = "fdt-1";
signature-1 {
algo = "sha256,rsa4096";
key-name-hint = "dev-key";
sign-images = "kernel", "fdt";
};
};
};
};
EOFBuilding the FIT image
Copy the kernel and DTB to the working directory, then build and sign:
cd ~/secure-boot-lab
# Copy build outputs
cp ~/buildroot/output/images/zImage .
cp ~/buildroot/output/images/vexpress-v2p-ca9.dtb .
UBOOT_BUILD="$(find ~/buildroot/output/build -maxdepth 1 -type d -name 'uboot-*' | head -1)"
cp "$UBOOT_BUILD/u-boot.dtb" .
mkdir -p tftp
# Create the FIT image (unsigned first)
mkimage -f fit-image.its fit-image.itb
# Sign the FIT image with our key
# This also adds the public key to the U-Boot device tree
mkimage -F -k keys/ -K u-boot.dtb -r fit-image.itb
# -F: sign an existing FIT image (don't recreate)
# -k keys/: directory containing dev-key.key and dev-key.crt
# -K u-boot.dtb: U-Boot's *control* DTB (the one U-Boot itself runs against),
# NOT the kernel DTB you might also have on disk. mkimage rewrites
# this file in place to add /signature/key-dev-key. If you point
# -K at the kernel DTB by mistake, the bootloader still has no
# trust anchor and signature verification will silently fail.
# -r: mark the key as "required" (U-Boot must verify)
# Put the signed artifact somewhere the QEMU guest can fetch it
cp fit-image.itb tftp/The -r flag is critical. Without it, U-Boot treats the signature as optional; it will warn on failure but boot anyway. With -r, a failed signature check prevents booting entirely.
Verifying the signature
# Inspect the FIT image
mkimage -l fit-image.itbFIT description: Signed ARM kernel + DTB
Image 0 (kernel)
Description: Linux kernel
Type: Kernel Image
...
Hash algo: sha256
Hash value: a3b4c5d6...
Image 1 (fdt-1)
Description: Device tree
...
Configuration 0 (conf-1)
Description: Signed boot configuration
Kernel: kernel
FDT: fdt-1
Sign algo: sha256,rsa4096:dev-key
Sign value: (RSA signature present)Configuring U-Boot for verified boot
U-Boot must be compiled with verified boot support and the public key embedded.
U-Boot configuration
In Buildroot’s U-Boot configuration (or standalone U-Boot menuconfig):
cd ~/buildroot
make uboot-menuconfigEnable:
CONFIG_FIT=y, FIT image supportCONFIG_FIT_SIGNATURE=y, FIT signature verificationCONFIG_RSA=y, RSA algorithm supportCONFIG_FIT_VERBOSE=y, verbose signature checking output (for debugging)
Embedding the public key
The public key is embedded in U-Boot’s control device tree (the .dtb that U-Boot itself uses). The mkimage -K u-boot.dtb invocation from the previous step modified that DTB in place, adding a /signature/key-dev-key node containing the public key parameters. We now need U-Boot’s binary to ship with that updated DTB.
arch/arm/dts/ contains the DTS source, not compiled DTBs, so dropping a .dtb there has no effect, the build will recompile from source and overwrite our changes. The supported flow is to re-link U-Boot against an external DTB using EXT_DTB:
# Copy the modified DTB into U-Boot's build directory
UBOOT_BUILD="$(find ~/buildroot/output/build -maxdepth 1 -type d -name 'uboot-*' | head -1)"
cp ~/secure-boot-lab/u-boot.dtb "$UBOOT_BUILD/u-boot-with-pubkey.dtb"
# Re-link U-Boot using the external DTB containing the public key.
# EXT_DTB tells U-Boot's makefile to skip DTS compilation and append
# the supplied DTB to the final binary.
CROSS_COMPILE="$(find ~/buildroot/output/host/bin -maxdepth 1 -type f -name '*-gcc' | head -1 | sed 's/gcc$//')"
cd "$UBOOT_BUILD"
make EXT_DTB=u-boot-with-pubkey.dtb \
CROSS_COMPILE="$CROSS_COMPILE"
# Replace the Buildroot output artifact with the re-linked binary
cp u-boot ~/buildroot/output/images/u-bootNote
Verifying the key landed in the binary Confirm the public key is actually present in the rebuilt U-Boot before testing:
fdtget -l "$UBOOT_BUILD/u-boot-with-pubkey.dtb" /signature # Expected output: key-dev-keyThen confirm the booted U-Boot sees the same control FDT:
U-Boot> fdt addr $fdtcontroladdr U-Boot> fdt list /signatureIf the
/signaturenode is missing from the control FDT U-Boot is actually using, U-Boot will behave as if no key were configured, and signed images will fail with “no signature node found.”
Testing verified boot
Boot QEMU with the signed FIT image:
qemu-system-arm \
-M vexpress-a9 \
-m 256M \
-kernel output/images/u-boot \
-drive file=output/images/rootfs.ext2,if=sd,format=raw \
-net nic -net user,tftp=$HOME/secure-boot-lab/tftp \
-nographicIn U-Boot, fetch and boot the signed FIT image over TFTP:
U-Boot> dhcp
U-Boot> tftpboot 0x62000000 fit-image.itb
U-Boot> bootm 0x62000000## Loading kernel from FIT Image at 62000000 ...
Using 'conf-1' configuration
Verifying Hash Integrity ... sha256,rsa4096:dev-key+ OK
Verifying Hash Integrity ... sha256+ OK
## Loading fdt from FIT Image at 62000000 ...
Verifying Hash Integrity ... sha256+ OK
## Booting kernel from FIT Image at 62000000 ...
Starting kernel ...The sha256,rsa4096:dev-key+ indicates the signature was verified successfully.
Attacking the verified boot
Now switch to the attacker’s perspective. Try to bypass the verified boot chain.
Attack 1: Tampered kernel
Modify a single byte in the kernel and try to boot it.
cp fit-image.itb fit-image-tampered.itb
# Locate the kernel data inside the FIT and flip one byte.
# zImage carries a magic word (0x016f2818) at offset 0x24 from its start,
# so we can find the kernel's location inside the FIT without parsing
# the FDT structure manually.
python3 << 'EOF'
with open('fit-image-tampered.itb', 'r+b') as f:
blob = f.read()
magic = b'\x18\x28\x6f\x01' # little-endian 0x016f2818
idx = blob.find(magic)
if idx < 0:
raise SystemExit('zImage magic not found in FIT — check arch/compression')
kernel_start = idx - 0x24
target = kernel_start + 0x100 # well inside the kernel payload
f.seek(target)
original = f.read(1)
f.seek(target)
f.write(bytes([original[0] ^ 0xFF]))
print(f'Flipped byte at offset {hex(target)} ({hex(original[0])} -> {hex(original[0] ^ 0xFF)})')
EOFCopy the tampered image into the TFTP directory, then load it in U-Boot:
cp fit-image-tampered.itb tftp/
U-Boot> tftpboot 0x62000000 fit-image-tampered.itb
U-Boot> bootm 0x62000000## Loading kernel from FIT Image at 62000000 ...
Using 'conf-1' configuration
Verifying Hash Integrity ... sha256,rsa4096:dev-key-
ERROR: RSA signature verification failed
Bad FIT kernel image format!The - after dev-key indicates verification failure. U-Boot refuses to boot. The secure boot chain correctly detected the tampered kernel.
Attack 2: Unsigned image
Create a FIT image without a signature:
# Create .its without the signature node
cat > fit-unsigned.its << 'EOF'
/dts-v1/;
/ {
description = "Unsigned kernel";
images {
kernel {
data = /incbin/("zImage");
type = "kernel";
arch = "arm";
os = "linux";
compression = "none";
load = <0x60000000>;
entry = <0x60000000>;
};
fdt-1 {
data = /incbin/("vexpress-v2p-ca9.dtb");
type = "flat_dt";
arch = "arm";
};
};
configurations {
default = "conf-1";
conf-1 {
kernel = "kernel";
fdt = "fdt-1";
};
};
};
EOF
mkimage -f fit-unsigned.its fit-unsigned.itbcp fit-unsigned.itb tftp/
U-Boot> tftpboot 0x62000000 fit-unsigned.itb
U-Boot> bootm 0x62000000## Loading kernel from FIT Image at 62000000 ...
Using 'conf-1' configuration
Signature is required but not found
ERROR: FIT image not signedBecause we used -r (required) when embedding the key, U-Boot won’t boot unsigned images at all.
Attack 3: Downgrade attack
A downgrade attack replaces the current firmware with an older, vulnerable version that was legitimately signed. The old signature is still valid because the signing key hasn’t changed.
# An old FIT image, signed with the same key
cp fit-image-v1.0.itb tftp/
U-Boot> tftpboot 0x62000000 fit-image-v1.0.itb
U-Boot> bootm 0x62000000## Loading kernel from FIT Image at 62000000 ...
Verifying Hash Integrity ... sha256,rsa4096:dev-key+ OK
## Booting kernel ...It boots. The secure boot chain has no version awareness; it only checks the signature, and the old image’s signature is valid.
Defending against downgrade attacks
Rollback protection requires a monotonic counter: a value stored in non-volatile, tamper-resistant storage that only increases. Each signed image includes a version number, and the boot policy refuses to boot images with a version lower than the current counter value.
Rollback protection flow:
FIT image version: 3
Stored counter: 3 → 3 >= 3 → BOOT ALLOWED
FIT image version: 2 (old image)
Stored counter: 3 → 2 < 3 → BOOT REJECTEDOn real hardware, the counter is stored in OTP (One-Time Programmable) fuses or a Trusted Platform Module (TPM). In QEMU, you can simulate this with a file-backed counter.
U-Boot itself doesn’t ship a single, portable rollback-protection switch, the mechanism is platform-specific because it depends on where the monotonic counter lives. In practice you’ll see one of:
- OP-TEE-backed counters on ARM TrustZone-capable SoCs, exposed to U-Boot via the
teeuclass and queried beforebootm. - Vendor secure-storage drivers such as NXP’s CAAM/SNVS (
CONFIG_FSL_CAAM), TI’sCONFIG_TI_SECURE_DEVICE, or i.MX’s HAB. - TPM 2.0 NV counters on platforms with a discrete TPM, accessed through U-Boot’s
tpm2command set.
Whichever backend you use, the implementation pattern is the same, but the comparison is done by board-specific code, a protected boot script, or a vendor secure-boot integration:
- Add a
rollback-indexproperty to each FIT configuration - Store the current minimum version in secure storage
- Compare the image’s rollback index against the stored minimum before
bootmand refuse to boot lower-versioned images
Attack 4: Bootloader replacement
If the attacker can replace U-Boot itself (via flash access), the entire chain of trust collapses: a modified U-Boot can skip signature verification.
Defense: The boot ROM (immutable code in silicon) must verify U-Boot before executing it. This is the hardware root of trust. On real SoCs (NXP i.MX, TI AM335x, STM32MP1), the boot ROM checks a hash or signature of the first-stage bootloader against a value burned into OTP fuses.
In QEMU, there’s no hardware root of trust. This is a fundamental limitation of software-only testing: the first link in the chain must be anchored in hardware.
Attack 5: Glitching
Fault injection (voltage glitching, clock glitching, electromagnetic pulses) can cause the processor to skip instructions, including the signature verification check. A well-timed glitch during the if (verify_signature() == FAIL) { halt(); } branch can cause U-Boot to fall through to the boot path.
This is a hardware attack beyond the scope of this tutorial, but it’s the most practical bypass against properly implemented secure boot on real devices. Defenses include:
- Double-checking verification results
- Adding timing randomization before checks
- Using redundant comparison loops
- Hardware-level glitch detection circuits
Extending the chain: dm-verity for the root filesystem
The FIT signature covers the kernel and device tree, but not the root filesystem. An attacker who modifies /etc/shadow or plants a backdoor binary in the rootfs bypasses kernel-level verification.
dm-verity extends the chain of trust to the filesystem. It’s a Linux device-mapper target that verifies each block of the filesystem against a pre-computed hash tree (Merkle tree) at read time.
# Generate the dm-verity hash tree
veritysetup format /dev/mmcblk0p2 /dev/mmcblk0p3
# Writes hash tree to partition 3
# Outputs: Root hash: a1b2c3d4...
# The root hash must be protected by the verified boot chain. One common
# approach is to embed it in bootargs inside the signed FIT or signed DTB.
# Modern kernels (>= 5.4 with CONFIG_DM_INIT) accept dm-mod.create on the cmdline:
# bootargs=... root=/dev/dm-0 dm-mod.create="vroot,,,ro,0 <data_sectors>
# verity 1 /dev/mmcblk0p2 /dev/mmcblk0p3 4096 4096 <data_blocks>
# <hash_start_block> sha256 <root-hash> <salt>"
# See Documentation/admin-guide/device-mapper/verity.rst for the full
# parameter list and the older dmsetup-style syntax.With dm-verity:
- The FIT signature verifies the kernel and the signed configuration data that carries the dm-verity root hash
- dm-verity verifies every filesystem block at read time against the hash tree
- Any modification to any file on the rootfs causes a hash mismatch → I/O error
This only holds if the kernel command line cannot be changed by an attacker. If U-Boot still loads mutable environment variables or accepts interactive setenv bootargs changes before bootm, the root hash is not protected. Production systems usually disable or lock down the U-Boot console and keep boot arguments in signed FIT configuration data, a signed device tree, or another authenticated source.
Complete chain:
Boot ROM → U-Boot → FIT (kernel + DTB + bootargs) → dm-verity → rootfs
hardware signed signed (RSA) hash tree verified
trust binary (Merkle) per-blockPractical exercise
- Generate an RSA 4096-bit key pair and create a signed FIT image containing the Buildroot kernel and device tree
- Configure U-Boot with
CONFIG_FIT_SIGNATUREand embed the public key with the required flag - Boot the signed image in QEMU and verify that signature checking passes
- Tamper with one byte of the kernel in the FIT image and confirm U-Boot rejects it
- Create an unsigned FIT image and confirm U-Boot rejects it
- Sign an “old version” image with the same key and confirm it boots (demonstrating the downgrade attack)
- Discuss: how would you defend against the downgrade attack in a production device?
This builder-and-breaker exercise develops the dual perspective essential for embedded security work, understanding both how defenses are implemented and where they fail.