Embedded Systems · Firmware Updates

Image Headers and Integrity Validation Before Execution

How a bootloader decides an image is complete and well-formed, which fields belong in the header, and why a checksum over the wrong range is worse than none.

The instant between “the image is in flash” and “the image may run” is where most bootloader engineering effort goes. Flash contents are not a program until something asserts that they are: that every byte arrived, that the image is the one the header describes, and that the fields the bootloader reads are at the offsets it expects. That assertion is the header plus a check over the image body, and its design determines which failures are caught at boot and which become silent corruption.

The header exists because flash is written in pieces

A firmware image reaches flash through several layers that each deliver a fragment. A transport moves a byte stream, a protocol splits it into records, a flash driver writes pages of whatever size the part provides, and a memory protection scheme may block writes to regions the running code occupies. At no point does the whole image exist in one place. The last write is the moment the image becomes complete, and the bootloader runs at some later, unknown time.

So the header is a manifest: it says how large the image is, which bytes are covered by the check, what version produced it, and what hardware it is for. The bootloader reads a fixed number of bytes at a fixed offset, and if those bytes make sense it can decide whether to spend time hashing the body.

Fixed offsets are the whole point. A header that begins with a length field at offset 0 cannot be found without knowing where it starts, and if the length field is corrupt the bootloader reads a length of 0xFFFFFFFF and either hashes the entire flash or wraps. The usual fix is a magic value at offset 0: a constant with enough bits that random flash content will not produce it. Erased flash is all ones, so a magic of 0xFFFFFFFF is worthless, and a magic of 0x00000000 is nearly as bad on parts that fail towards zeros. A word like 0x96F3B83D is chosen for no reason other than that neither erased state nor a plausible instruction encoding matches it.

The magic does not prove anything. It selects: a header whose first word is the magic is treated as a header, and the remaining fields are then validated for internal consistency. Every field is untrusted until checked, which means the length must be compared against the slot size before it is used to bound a hash, and the version must be compared against whatever the boot policy allows before it is used to make a decision.

Which fields earn their place

A minimal header carries the image length, a checksum or hash of the body, a version, and an identifier for the hardware. Each has a specific failure it prevents.

The length field makes the check bounded. Without it, a bootloader must hash the whole slot, which is correct but slow and does not detect an image that was written short and left the tail erased. With it, the check covers exactly the bytes the image claims and a mismatch between the written region and the claimed length is caught.

The version field exists so that the bootloader can compare an incoming image with what is already installed. That comparison is not a simple greater-than: it is a policy question that involves rollback protection, which is the subject of anti-rollback counters and version monotonicity. The header only supplies the number.

The hardware identifier prevents the most embarrassing class of bricking: an image built for a different board revision, or a different part in the same family, that validates perfectly and then drives the wrong pins. A single word compared against a value compiled into the bootloader catches it, at the cost of requiring a rebuild whenever the hardware changes. Some schemes use a range or a bitmask of compatible targets so that one image can serve several boards.

A fifth field is worth considering even though it is not strictly needed: an offset to where the actual entry point is, or a flag word describing image properties. On parts where the vector table is not at the image base, the bootloader needs to know where it is, and hardcoding that offset in the bootloader means a layout change requires a bootloader update. Encoding it in the header keeps the bootloader generic.

/* A header as it sits at the start of a slot, read with a plain
   byte-wise copy. Fields are naturally aligned, so the struct maps
   onto the flash layout without padding on a 32-bit target. */
struct image_header {
    uint32_t magic;          /* must equal IMAGE_MAGIC */
    uint32_t load_address;   /* where the image was linked to run */
    uint32_t image_length;   /* bytes of body following this header */
    uint32_t flags;          /* bit 0: body is encrypted */
    uint32_t version;        /* monotonic; compared against metadata */
    uint32_t reserved;
    uint8_t  digest[32];     /* SHA-256 over header-with-zeroed-digest and body */
};

_Static_assert(sizeof(struct image_header) == 56, "header must stay 56 bytes");

The self-referential problem in that layout is the digest field. The digest cannot cover itself, so the convention is to zero the digest field, compute the hash over the header with the zero in place, then over the body, and finally store the result. Any implementation that hashes the header as stored will fail to reproduce the value, which is a mistake that shows up as an image that validates when written and fails to validate after a reboot.

Checksums, CRCs, and hashes are different tools

Three mechanisms are commonly confused, and the choice between them is about what failure is being detected.

A simple additive checksum, of the kind described in RFC 1071 for internet headers, detects single-bit errors and most multi-bit errors but is blind to byte reordering within a 16-bit word. It costs one addition per word and is the cheapest option. On a microcontroller where the image is a few hundred kilobytes and boot time is measured in milliseconds, the cost of a stronger check is negligible, which is why checksums are rare in firmware headers and common in protocol frames.

A CRC detects all single-bit errors, all burst errors up to its own width, and a known fraction of longer bursts. CRC-32 has a probability of roughly 2^-32 of accepting a corrupted block, which is adequate for detecting transmission errors and inadequate for detecting deliberate modification, because a CRC is linear and an attacker can adjust bytes to preserve it. It is also the right tool for a per-block check during transport, where a block is retried rather than rejected.

A cryptographic hash such as SHA-256 is what a header should carry if the bootloader is going to make a trust decision, and it is mandatory if the image is signed, because the signature is over the hash. Its cost is real: SHA-256 processes 64-byte blocks through 64 rounds each, so a 512 KiB image means 8192 block compressions. On a core without a hardware hash accelerator that is a measurable boot delay, and on parts with one it is nearly free. The decision to hash every boot rather than once at install time is exactly the trade described in trial boot, confirmation, and rollback.

The check range is where implementations most often go wrong. The digest must cover the header fields that matter and the body, but it cannot cover the field where it is stored. It must also cover the bytes between the end of the header and the start of the body, if any, and it must not cover the region beyond the declared length. An image whose check covers the whole slot fails when the slot is larger than the image and the tail is erased, because the erased bytes are not part of what was written. An image whose check covers only the body passes even if the length field was altered, because the length is outside the covered range and the altered length changes which bytes were hashed. Both errors are common and both are silent until an update actually fails.

When validation runs, and what it costs

There are two places to validate and they answer different questions. Validating at install time answers “did the transfer succeed”, and its result can be recorded so that boot does not repeat the work. Validating at boot answers “is this image still intact”, which matters because flash can be partially erased by a power loss during a later operation, because a debugger or a second core can write the region, and because the metadata that records a successful install can itself be lost.

Validating only at install is the faster design and the weaker one. Validating only at boot re-does work on every reset and cannot distinguish a failed transfer from later damage. The safer arrangement is both, with the boot-time check skipped for the running image and applied to any image the bootloader is about to promote.

Time is the constraint that shapes this. A bootloader’s budget is often a few hundred milliseconds, and a hash over a slot that is several times the image size is the dominant term. Two techniques keep it bounded. Hashing only the declared length rather than the whole slot is the first and it is free. Caching the result is the second: a small record in a separate flash region notes the digest of the last validated image, and boot compares the recorded value against the header rather than recomputing. The cache is only sound if the record cannot be stale, which means it must be invalidated by anything that can write the slot, and on a part where the application can write its own slot it cannot be trusted at all.

Failures that validation cannot catch

A validated image is a complete, unmodified image. It is not a correct image, and it is not a compatible one. Three failure classes pass every check.

An image built against the wrong linker script has a correct digest and a stack pointer in the wrong region. It validates and then corrupts memory. The header’s load address field catches this only if the bootloader compares it against the slot address, which is a check worth adding precisely because the digest will never catch it.

An image that is complete but functionally broken, signed by the same key and carrying the same hardware identifier, is indistinguishable from a good one. That is what trial boot and rollback exist for: the only test that matters is whether the image runs, and the bootloader cannot perform it.

An image that is intact but truncated at a record boundary in a way that leaves the declared length consistent with what arrived passes the check. This happens when a transport reports success for a transfer that delivered fewer records than the header claimed, and the header was written from the transport’s own count rather than from the actual image size. The digest then covers a short body and matches, because the digest was computed over the same short body. Nothing in the header can detect a consistent lie; only a signature produced by the build system can, and only if the signature covers the length.

References

  1. MCUboot documentation: Design — MCUboot project
  2. RFC 1071: Computing the Internet Checksum — IETF
  3. A Painless Guide to CRC Error Detection Algorithms — Ross N. Williams