- 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.
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.
| Offset | Size | Field | Value in sample | Notes |
|---|---|---|---|---|
0x00 | 4 | Magic | FWIM | ASCII, not NUL-terminated |
0x04 | 4 | Version | 1.0.2.4 → 2.4.11 | Byte-per-component; the fourth byte is a build counter |
0x08 | 4 | Field count | 11 | Documented as “header length”; actually a loop bound — see §2.1 |
0x0c | 4 | Total length | 0x00700000 | 7,340,032 — matches the file size |
0x10 | 4 | Kernel offset | 0x00004000 | Page-aligned |
0x14 | 4 | Kernel length | 0x00120000 | 1,179,648 bytes |
0x18 | 4 | Rootfs offset | 0x00124000 | Immediately follows the kernel, aligned |
0x1c | 4 | Rootfs length | 0x005c0000 | 6,029,312 bytes |
0x20 | 4 | Header CRC-32 | 0xc4129f3a | Over bytes 0x00–0x1f only (a fixed range) |
0x24 | 4 | Reserved | 0 | |
0x28 | 4 | Payload checksum | 0xd4c3b2a1 | Additive 32-bit sum, not CRC — see §3 |
0x30 | 16 | Model string | MODEL-A100 | NUL-padded; compared with a string in the bootloader environment |
0x40 | 4 | Signature block CRC | 0x56190527 | Over 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:
- Compare the magic. Abort on mismatch.
- Compare the model string against the
board_idenvironment variable. Abort on mismatch. - Compute CRC-32 over the header and compare with
0x20. Abort on mismatch. - Compute the additive sum over kernel + rootfs and compare with
0x28. Log a warning and continue on mismatch. - 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.

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
-
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. ↩
-
“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. ↩