Date
Ref
PC-260814-7Z7
Length
5 min read
Tags

Anatomy of a vendor firmware image: header layout, checksums and what the updater actually verifies

A walk through the update container used by a family of consumer network devices: how the header is laid out, which of its three checksums are enforced, and a small parser to reproduce the findings.

Diagram of a firmware image: a 64-byte header followed by kernel, root filesystem and a trailing signature block, with byte offsets marked.

This is a sample post. The device, vendor and figures below are invented so the layout can be reviewed with realistic material; nothing here describes a real product.

Background

Firmware update files for embedded devices are usually a simple container: a fixed-size header, one or more payload sections, and some form of integrity check. What varies is how much of that structure the device’s updater enforces versus merely documents. The distinction matters for anyone maintaining these devices past their support window, because a checksum that is never checked is not a constraint.1

The image examined here is fw-2.4.11.bin, 7,340,032 bytes, downloaded from the vendor’s support page. Everything below was derived from the file itself and from running the stock updater under an emulator; no hardware was modified.

Header layout

The first 64 bytes are the header. A hex dump of the start of the file is the quickest way to see the structure:

00000000  46 57 49 4d 01 00 02 04  0b 00 00 00 00 00 70 00  |FWIM..........p.|
00000010  00 40 00 00 00 00 12 00  00 40 12 00 00 00 5c 00  |.@.......@....\.|
00000020  3a 9f 12 c4 00 00 00 00  a1 b2 c3 d4 00 00 00 00  |:...............|
00000030  4d 4f 44 45 4c 2d 41 31  30 30 00 00 00 00 00 00  |MODEL-A100......|
00000040  27 05 19 56 00 00 00 00  00 00 00 00 00 00 00 00  |'..V............|

Reading it as little-endian fields gives the layout in the table below. Offsets are from the start of the file; the table is deliberately wider than a phone screen so horizontal scrolling can be checked.

OffsetSizeFieldValue in sampleNotes
0x004MagicFWIMASCII, not NUL-terminated
0x044Version1.0.2.4 → 2.4.11Byte-per-component; the fourth byte is a build counter
0x084Field count11Documented as “header length”; actually a loop bound — see §2.1
0x0c4Total length0x007000007,340,032 — matches the file size
0x104Kernel offset0x00004000Page-aligned
0x144Kernel length0x001200001,179,648 bytes
0x184Rootfs offset0x00124000Immediately follows the kernel, aligned
0x1c4Rootfs length0x005c00006,029,312 bytes
0x204Header CRC-320xc4129f3aOver bytes 0x00–0x1f only (a fixed range)
0x244Reserved0
0x284Payload checksum0xd4c3b2a1Additive 32-bit sum, not CRC — see §3
0x3016Model stringMODEL-A100NUL-padded; compared with a string in the bootloader environment
0x404Signature block CRC0x56190527Over the trailing 256-byte block

Two things stand out. The field at 0x08 is not a header length at all, and the payload checksum is a plain additive sum rather than the CRC the documentation implies.

The field at 0x08

Every image in the sample set had 11 here regardless of size, and the updater reads it into a loop bound: it expects exactly eleven 4-byte fields after the magic and version. Changing it to 12 makes the updater read one word past the model string and fail with a header CRC mismatch — which tells us the header CRC is computed over a fixed range, not one derived from this count.

What is actually verified

Running the stock fwupd binary under emulation with a breakpoint on each comparison gives a clear answer. The sequence is:

  1. Compare the magic. Abort on mismatch.
  2. Compare the model string against the board_id environment variable. Abort on mismatch.
  3. Compute CRC-32 over the header and compare with 0x20. Abort on mismatch.
  4. Compute the additive sum over kernel + rootfs and compare with 0x28. Log a warning and continue on mismatch.
  5. Never touch the signature block at all.

Step 4 is the important one. The relevant fragment of the decompiled updater, lightly cleaned up, reads:

static int verify_payload(const struct fw_header *h, const uint8_t *img)
{
    uint32_t sum = 0;
    size_t start = le32toh(h->kernel_off);
    size_t end   = le32toh(h->rootfs_off) + le32toh(h->rootfs_len);

    for (size_t i = start; i < end; i += 4)
        sum += le32toh(*(const uint32_t *)(img + i));

    if (sum != le32toh(h->payload_sum)) {
        log_warn("payload checksum mismatch (got %08x, want %08x)",
                 sum, le32toh(h->payload_sum));
        /* NOTE: intentionally non-fatal for field-recovery images */
        return 0;
    }
    return 0;
}

The comment is the vendor’s own. Whatever the original rationale, the effect is that only the header is protected by anything the device will refuse.2

The purpose of a checksum is to detect accidental corruption. If your threat model includes anyone deliberately modifying the image, a checksum is the wrong tool, and a checksum that is not enforced is not even that.

Field note, from the project log

Signature block

The last 256 bytes of every image are a block that begins with SIG1 and is otherwise high-entropy. It is plausibly an RSA-2048 signature over the payload. The updater on firmware 2.4.11 does not read it; it may be consumed by the bootloader on a factory-reset path, which was out of scope for this note.

Reproducing the parse

The parser below is enough to check the claims above against your own copy of an image. It depends only on the standard library.

#!/usr/bin/env python3
"""Parse a FWIM firmware header and recompute both checksums."""
import struct
import sys
import zlib

HEADER = struct.Struct("<4s4sIIIIIIIIII16sI")

def additive_sum(buf: bytes) -> int:
    total = 0
    for (word,) in struct.iter_unpack("<I", buf[: len(buf) & ~3]):
        total = (total + word) & 0xFFFFFFFF
    return total

def main(path: str) -> int:
    data = open(path, "rb").read()
    (magic, ver, nfields, total, k_off, k_len, r_off, r_len,
     hdr_crc, _res, pay_sum, _pad, model, sig_crc) = HEADER.unpack_from(data)

    if magic != b"FWIM":
        sys.exit(f"not a FWIM image: {magic!r}")

    calc_hdr = zlib.crc32(data[:0x20]) & 0xFFFFFFFF          # fixed range, see the table
    calc_pay = additive_sum(data[k_off : r_off + r_len])     # kernel + rootfs, contiguous

    print(f"version      {'.'.join(str(b) for b in ver)}")
    print(f"model        {model.rstrip(b'\\0').decode()}")
    print(f"total length {total:#010x} ({'ok' if total == len(data) else 'MISMATCH'})")
    print(f"header crc   {hdr_crc:#010x} calc {calc_hdr:#010x} {'ok' if hdr_crc == calc_hdr else 'MISMATCH'}")
    print(f"payload sum  {pay_sum:#010x} calc {calc_pay:#010x} {'ok' if pay_sum == calc_pay else 'MISMATCH'}")
    return 0

if __name__ == "__main__":
    sys.exit(main(sys.argv[1]))

Running it against the sample image:

$ python3 fwim.py fw-2.4.11.bin
version      2.4.11
model        MODEL-A100
total length 0x00700000 (ok)
header crc   0xc4129f3a calc 0xc4129f3a ok
payload sum  0xd4c3b2a1 calc 0xd4c3b2a1 ok

And after flipping a single byte in the root filesystem with dd:

$ printf '\x00' | dd of=fw-2.4.11.bin bs=1 seek=$((0x200000)) conv=notrunc status=none
$ python3 fwim.py fw-2.4.11.bin
version      2.4.11
model        MODEL-A100
total length 0x00700000 (ok)
header crc   0xc4129f3a calc 0xc4129f3a ok
payload sum  0xd4c3b2a1 calc 0xd4c3b260 MISMATCH

The modified image was accepted by the emulated updater with only the warning shown earlier in the log.

Byte layout of the firmware image showing the header, kernel, root filesystem and trailing signature block
Layout of the 7 MiB image. Only the shaded header region is protected by an enforced check.

Limits of this note

  • Only one device family and three firmware versions (2.4.9–2.4.11) were examined.
  • The bootloader’s handling of the signature block was not tested. It is possible that a factory-reset or recovery path enforces it.
  • Emulation can differ from hardware in ways that matter for timing but should not for the control flow described here.

A follow-up will look at whether the additive sum is enforced by older updaters, which would suggest the non-fatal path was introduced deliberately.

Footnotes

  1. The same pattern — a documented integrity mechanism that the shipping code treats as advisory — appears often enough in consumer devices that it is worth checking before assuming any protection exists. ↩

  2. “Refuse” here means the updater exits non-zero and leaves the flash untouched. It does not roll back a partially written image, which is a separate weakness outside the scope of this note. ↩