The README says ctest reads all three hand-over properties out of the image, and the UCSR0B clear was the one it never read. Every hand-over site must now store zero to UCSR0B before the call; red with the write removed, green with it. The docstring states the properties rather than the legacy bug's story. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
143 lines
6.3 KiB
Python
143 lines
6.3 KiB
Python
#!/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.
|
|
|
|
Four 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; 0x7800, a 2 KB boot section's base, reads erased on this board.
|
|
3. The hand-over releases the USART first. While TXEN0 is set the peripheral
|
|
owns PD1, so a loader that bit-bangs the pin answers into nothing.
|
|
4. 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.
|
|
|
|
check_reachability.py --objdump avr-objdump --elf fantemp --image fantemp.bin \
|
|
--loader-base 0x7e00
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import pathlib
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
|
|
BOOT_SECTION = 0x7C00 # hfuse d4: BOOTSZ 512 words - a fuse fact, not a loader one
|
|
UCSR0B = 0xC1
|
|
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)
|
|
# pureboot's own geometry, from the pinned submodule that exports it, so the
|
|
# slot's base is stated once for the loader this firmware is deployed with.
|
|
parser.add_argument("--loader-base", required=True,
|
|
type=lambda v: int(v, 0), metavar="ADDR")
|
|
args = parser.parse_args()
|
|
loader_base = args.loader_base
|
|
|
|
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")
|
|
release = re.compile(rf"\bsts\s+0x00{UCSR0B:02X},\s*r1\b", re.I)
|
|
sites, unreleased = [], 0
|
|
for index, line in enumerate(lines):
|
|
if not helper.search(line):
|
|
continue
|
|
held: dict[str, int] = {}
|
|
window = lines[max(0, index - 8):index]
|
|
for back in window:
|
|
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"])
|
|
if not any(release.search(back) for back in window):
|
|
unreleased += 1
|
|
|
|
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})")
|
|
|
|
if unreleased:
|
|
failures.append(f"{unreleased} hand-over site(s) do not clear UCSR0B first - "
|
|
f"the USART keeps PD1 and the loader answers into nothing")
|
|
elif sites:
|
|
print(" ok every hand-over site clears UCSR0B before the jump")
|
|
|
|
# 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())
|