Protect nRF SD OTA authorization RAM from source-only stores

This commit is contained in:
mikecarper committed 2026-10-17 15:35:34 -07:00
1 parent 9c3df94f99
commit 5ee9ceeb20
14 files changed
+556 -9

No files matched your search

+5 -1
View File
@@ -40,7 +40,11 @@ def nrf52_lora_details(application):
"Artifact inspection verifies compiled support; it does not verify the bootloader installed on a physical device."]
if layout.auto_store:
notes.append("One application detects RAK external NOR and chooses QSPI only with the exact matching OTAFIX bootloader; otherwise it uses internal flash where safe.")
notes.append("External QSPI accepts full images and in-place deltas; internal flash accepts in-place deltas only and needs the retained-RAM OTAFIX profile.")
if layout.hybrid_ram:
notes.append("External QSPI accepts full images and in-place deltas; internal flash accepts in-place deltas only and needs the retained-RAM OTAFIX profile.")
else:
notes.append("External QSPI accepts full images and in-place deltas; this legacy application's internal staging is flash-only and accepts in-place deltas only.")
notes.append("Retained-RAM staging and bootloader self-update are not compiled into this legacy application.")
notes.append("OTAFIX 2.4.11 provides normal RAK3401/RAK4631 *_auto bootloaders with standard board identities and internal signed bootloader-update staging. Historical *_AUTO_DFU or dedicated external identities need the documented local identity-matching migration, not a cross-profile LoRa update.")
elif layout.hybrid_ram:
notes.append("Requires the exact-board retained-RAM handoff bootloader profile; a transfer cannot resume after the receiver restarts.")
@@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""Check linked ARM Companion OTA queue ownership without touching hardware.
Requires unicorn==2.1.4. Executes the actual acquire/release callbacks starting
from the ELF's initialized globals and empty queue. Watch the fixed SD handoff
RAM through repeated construction/destruction: source-only archive stores must
never write it, even though ordinary linkers put application globals there.
This checks memory ownership, not radio/USB/SD hardware behavior.
"""
import argparse
import hashlib
import json
import struct
from firmware_elf import FirmwareElf
from unicorn import (Uc, UcError, UC_ARCH_ARM, UC_MODE_THUMB, UC_MODE_MCLASS,
UC_HOOK_MEM_WRITE)
from unicorn import arm_const as arm
AUTH_START, AUTH_END = 0x20006008, 0x20006050
def check(path):
elf = FirmwareElf(path)
if elf.unpack("H", 18)[0] != 40:
raise ValueError("expected an ARM firmware ELF")
def callback(fragment):
matches = [name for name in elf.symbols if fragment in name]
if len(matches) != 1:
raise ValueError(f"expected one linked {fragment} callback, found {len(matches)}")
return matches[0]
acquire = callback("acquireOfflineQueueForOta")
release = callback("releaseOfflineQueueFromOta")
owner = elf.address("the_mesh")
machine = Uc(UC_ARCH_ARM, UC_MODE_THUMB | UC_MODE_MCLASS)
machine.mem_map(0, 0x100000)
machine.mem_map(0x20000000, 0x40000)
machine.mem_map(0xE000E000, 0x1000)
for section in elf.sections:
if section[1] != 8 and section[2] & 2 and section[5]:
machine.mem_write(section[3], elf.section_bytes(section))
# Full Companion callbacks execute softfp instructions while constructing
# the real OtaContext. Enable Cortex-M4F CP10/CP11 just as startup does.
machine.mem_write(0xE000ED88, struct.pack("<I", 0x00F00000))
machine.reg_write(arm.UC_ARM_REG_FPEXC, 0x40000000)
stack_top, stop = 0x2003F000, 0x1000
machine.mem_write(stop, b"\x00\xbe")
before = bytes(machine.mem_read(AUTH_START, AUTH_END - AUTH_START))
violations = []
def write_hook(uc, _access, address, size, _value, _context):
if address < AUTH_END and address + size > AUTH_START:
violations.append((uc.reg_read(arm.UC_ARM_REG_PC), address, size))
uc.emu_stop()
machine.hook_add(UC_HOOK_MEM_WRITE, write_hook)
def call(name):
machine.reg_write(arm.UC_ARM_REG_SP, stack_top)
machine.reg_write(arm.UC_ARM_REG_LR, stop | 1)
machine.reg_write(arm.UC_ARM_REG_R0, owner)
try:
machine.emu_start(elf.address(name) | 1, stop, timeout=10000000, count=2000000)
except UcError as error:
raise ValueError(f"{name}: ARM execution failed: {error}") from error
if violations:
pc, address, size = violations[0]
raise ValueError(f"{name}: write to SD auth/application RAM at {address:#x} "
f"({size} bytes), instruction {pc:#x}")
if machine.reg_read(arm.UC_ARM_REG_PC) != stop:
raise ValueError(name + " did not return within the instruction/time limit")
if machine.reg_read(arm.UC_ARM_REG_SP) != stack_top:
raise ValueError(name + " did not restore the stack")
if bytes(machine.mem_read(AUTH_START, AUTH_END - AUTH_START)) != before:
raise ValueError(name + " changed SD auth/application globals")
return machine.reg_read(arm.UC_ARM_REG_R0)
workspace = None
for _cycle in range(4):
pointer = call(acquire)
if not 0x20006050 <= pointer < stack_top:
raise ValueError(f"OTA workspace pointer outside application RAM: {pointer:#x}")
if workspace is not None and workspace != pointer:
raise ValueError("shared OTA workspace address changed across release/acquire")
workspace = pointer
if call(acquire) != workspace:
raise ValueError("already-acquired OTA workspace is not idempotent")
call(release)
call(release)
return {
"passed": True,
"elf_sha256": hashlib.sha256(elf.data).hexdigest(),
"queue_lifetimes": 4,
"workspace_address": hex(workspace),
"protected_sd_auth_range": [hex(AUTH_START), hex(AUTH_END)],
"scope": "Linked ARM empty-queue OTA acquisition/release; no hardware qualification.",
}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("elf")
args = parser.parse_args()
try:
print(json.dumps(check(args.elf), indent=2))
except (ValueError, OSError) as error:
parser.exit(1, f"FAILED: {error}\n")
if __name__ == "__main__":
main()
+23 -1
View File
@@ -9,6 +9,7 @@ closed if a target does not use the normal S140 v6/v7 1 MiB application layout.
Import("env")
import importlib.util
import os
from pathlib import Path
@@ -65,7 +66,28 @@ if requested:
f"linker={current or 'none'}"
)
if legacy_flash == "1":
print("nRF52 legacy flash OTA: ordinary linker; no hybrid RAM or bootloader self-update")
# Unified RAK receivers can select SD at runtime even in legacy flash
# mode. Reserve its 72-byte authorization ABI without a hybrid arena.
policy_path = Path(env["PROJECT_DIR"]) / "scripts/nrf52_flash_trim.py"
spec = importlib.util.spec_from_file_location("nrf52_legacy_sd_flags", policy_path)
policy = importlib.util.module_from_spec(spec)
spec.loader.exec_module(policy)
definitions = policy.flag_defines(env.get("BUILD_FLAGS", ()),
env.get("CPPDEFINES", ()))
sd_receiver = (definitions.get("OTA_SD_STORE") is not None and
definitions.get("OTA_SEEDER_ONLY") is None)
if sd_receiver:
sd_linker = Path(env["PROJECT_DIR"]) / "boards" / (
"nrf52840_s140_%s_sd_ota.ld" % suffix
)
if not sd_linker.is_file():
raise RuntimeError("missing legacy SD authorization linker: %s" % sd_linker)
board.update("build.ldscript", str(sd_linker))
env.Replace(LDSCRIPT_PATH=str(sd_linker))
print("nRF52 legacy flash OTA: %s; reserved SD authorization; no hybrid RAM or bootloader self-update"
% sd_linker.name)
else:
print("nRF52 legacy flash OTA: ordinary linker; no hybrid RAM or bootloader self-update")
else:
mota_linker = Path(env["PROJECT_DIR"]) / "boards" / (
"nrf52840_s140_%s_mota64.ld" % suffix