#!/usr/bin/env python3 """The board's only way in, checked in the emitted image. This board has no reset line and no programming header. The single route to the bootloader is the running firmware's `bootloader` command, so a firmware that gets that route wrong is a board that cannot be reflashed - and the failure is silent, because everything else still works. It has been wrong before. The firmware this one replaces probed and jumped to `0x7800`, the base of a 2 KB boot section, while the board's loader sits at `0x7e00`; `check()` therefore read an erased byte, was false, and the command never arrived anywhere. Nothing about that is visible short of trying it on the hardware, which is what this replaces. Three properties, all read out of the disassembly rather than the source: 1. The image ends below the boot section. `hfuse d4` puts that at 0x7c00, so an application reaching into it would be overwritten by the loader - or worse, executed at reset, since BOOTRST points there. 2. The hand-over targets the loader base. A word address of 0x3f00 is byte 0x7e00; anything else is the 0x7800 bug again. 3. The hand-over does not arm the watchdog. pureboot hands straight back on WDRF by design, so a reset-based route reaches it and opens no window. The legacy firmware's route was exactly that, and it is the one change that cannot be walked back from the host. check_reachability.py --objdump avr-objdump --elf fantemp --image fantemp.bin """ from __future__ import annotations import argparse import pathlib import re import subprocess import sys BOOT_SECTION = 0x7C00 # hfuse d4: BOOTSZ 512 words LOADER_BASE = 0x7E00 # pureboot's 512-byte slot, at the top WDTCSR = 0x60 def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--objdump", required=True) parser.add_argument("--elf", type=pathlib.Path, required=True) parser.add_argument("--image", type=pathlib.Path, required=True) args = parser.parse_args() failures = [] size = args.image.stat().st_size if size >= BOOT_SECTION: failures.append(f"the image is {size} B and reaches 0x{size - 1:04x}, " f"into the boot section at 0x{BOOT_SECTION:04x}") else: print(f" ok image {size} B, ends 0x{size - 1:04x}, " f"{BOOT_SECTION - size} B clear of the boot section") text = subprocess.run([args.objdump, "-d", str(args.elf)], capture_output=True, text=True, check=True).stdout # The address the hand-over actually targets, read at its call sites - not # "does the image contain this byte somewhere", which proves nothing: 0x3f is # an ordinary constant that appears in the curve tables, so a check like that # passes just as happily on the 0x7800 bug it is supposed to catch. # # bootloader::call() takes the target as a function pointer, so each call site # loads the *word* address into a register pair immediately before it. lines = text.splitlines() helper = re.compile(r"\b(?:r?call)\b.*<_ZN3app10bootloader4call") sites = [] for index, line in enumerate(lines): if not helper.search(line): continue held: dict[str, int] = {} for back in lines[max(0, index - 8):index]: if m := re.search(r"\bldi\s+(r\d+),\s*0x([0-9A-Fa-f]{2})", back): held[m.group(1)] = int(m.group(2), 16) # The AVR ABI passes the pointer in r25:r24. if "r24" in held and "r25" in held: sites.append(held["r25"] << 8 | held["r24"]) want = LOADER_BASE // 2 if not sites: failures.append("no call to bootloader::call with a loaded target - the " "hand-over could not be read out of the image") elif wrong := [a for a in sites if a != want]: failures.append(f"the hand-over targets word {[hex(a) for a in wrong]} " f"(byte {[hex(a * 2) for a in wrong]}), not the loader at " f"0x{LOADER_BASE:04x}") else: print(f" ok all {len(sites)} hand-over site(s) target word 0x{want:04x} " f"(byte 0x{LOADER_BASE:04x})") # An icall/ijmp has to exist for that address to be jumped to indirectly. if not re.search(r"\b(icall|ijmp)\b", text): failures.append("no icall/ijmp - the hand-over cannot reach across flash") else: print(" ok an indirect call exists (a relative one cannot reach)") # What actually reaches WDTCSR, not what the image happens to load somewhere. # A timed disable writes WDCE|WDE (0x18) and then zero. Arming writes WDE # *without* WDCE - including 0x08, a 16 ms timeout with every prescaler bit # clear, which is precisely what the legacy route used and is why this cannot # be a check for "a prescaler is present". WDCE, WDE = 0x10, 0x08 values, held = [], {} for line in text.splitlines(): if m := re.search(r"\bldi\s+(r\d+),\s*0x([0-9A-Fa-f]{2})", line): held[m.group(1)] = int(m.group(2), 16) elif m := re.search(rf"\bsts\s+0x00{WDTCSR:02X},\s*(r\d+)", line, re.I): reg = m.group(1) values.append(0 if reg == "r1" else held.get(reg)) armed = [v for v in values if v is not None and (v & WDE) and not (v & WDCE)] if armed: failures.append(f"WDTCSR is written {[hex(v) for v in armed]} - WDE without " f"WDCE is arming the watchdog, and a reset-based hand-over " f"opens no pureboot window") elif not values: print(" ok the watchdog is never written") else: print(f" ok WDTCSR writes are {[hex(v) if v is not None else '?' for v in values]}" f" - unlock and clear, never an arm") for line in failures: print(f" FAIL {line}") return 1 if failures else 0 if __name__ == "__main__": sys.exit(main())