diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index ee783317..f95e677e 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -194,10 +194,13 @@ jobs: - name: Verify nRF52 SoftDevice LTO and persistent BLE subscriptions env: MESHCORE_REQUIRE_ARM_COMPILER: '1' + MESHCORE_REQUIRE_DFU_EMULATOR: '1' run: | pio pkg install --global --tool 'platformio/toolchain-gccarmnoneeabi@1.140201.0' python3 -B test/test_nrf52_svc_compat.py -v python3 -B test/test_nrf52_ble_cccd_fix.py -v + pip install unicorn==2.1.4 + python3 -B test/test_nrf52_ble_dfu_fix.py -v - name: Verify WiFi, ESP-NOW and combined wireless controls run: | diff --git a/platformio.ini b/platformio.ini index e31d8daa..21089c48 100644 --- a/platformio.ini +++ b/platformio.ini @@ -156,10 +156,12 @@ extra_scripts = create-uf2.py pre:scripts/nrf52_usb_power_fix.py pre:scripts/nrf52_internal_flash_fix.py pre:scripts/nrf52_ble_cccd_fix.py + pre:scripts/nrf52_ble_dfu_fix.py pre:scripts/nrf52_internal_bootloader_link.py ; Nordic's builder otherwise overwrites the LTO-aware archive tools. pre:scripts/nrf52_lto_toolchain.py post:scripts/check_firmware_ram.py + post:scripts/check_nrf52_ble_dfu_handoff.py ; Nordic's framework defaults to -Ofast. Prefer the size-oriented profile for ; nRF52 images, where a smaller binary preserves OTA and filesystem headroom. build_unflags = diff --git a/scripts/check_nrf52_ble_dfu_handoff.py b/scripts/check_nrf52_ble_dfu_handoff.py new file mode 100644 index 00000000..dba7a3b9 --- /dev/null +++ b/scripts/check_nrf52_ble_dfu_handoff.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""Reject nRF52 ELFs with an unsafe compiled buttonless Bluetooth DFU handoff.""" + +import argparse +from pathlib import Path +import struct +import sys + + +SCRIPT_DIR = Path(__file__).resolve().parent if "__file__" in globals() else Path("scripts").resolve() +sys.path.insert(0, str(SCRIPT_DIR)) +from firmware_elf import FirmwareElf + + +HELPER_NAME = "mesh_nrf52_dfu_jump" +# Assembled Cortex-M4/Thumb helper. These instructions are entirely local: no +# relocation or compiler-generated C stack access can change with image layout. +# The only post-transition stack writes construct the explicit handler-mode +# exception frame. The normal FreeRTOS thread path performs no stack access. +HELPER_CODE = bytes.fromhex( + "72b6eff305830168426881f30888002080f3148880f3118880f31388" + "bff34f8fbff36f8f002b03d16ff0000e62b6104788b0009001900290" + "039004906ff00000059006924ff0045007906ff0060e62b67047" +) + + +def branch_targets(code, start): + """Decode direct Thumb-2 BL/B.W targets without depending on objdump.""" + for offset in range(0, len(code) - 3, 2): + first, second = struct.unpack_from("> 10) & 1 + i1 = 1 ^ ((second >> 13) & 1) ^ sign + i2 = 1 ^ ((second >> 11) & 1) ^ sign + immediate = (sign << 24) | (i1 << 23) | (i2 << 22) + immediate |= (first & 0x3FF) << 12 | (second & 0x7FF) << 1 + if sign: + immediate -= 1 << 25 + yield start + offset + 4 + immediate + + +def writes_control(code): + for offset in range(0, len(code) - 3, 2): + first, second = struct.unpack_from("ICER[0]=0xFFFFFFFF; + NVIC->ICPR[0]=0xFFFFFFFF; +#if defined(__NRF_NVIC_ISER_COUNT) && __NRF_NVIC_ISER_COUNT == 2 + NVIC->ICER[1]=0xFFFFFFFF; + NVIC->ICPR[1]=0xFFFFFFFF; +#endif +''' +INTERRUPTS = ''' // nRF52 has external IRQs in both banks. FreeRTOS system exceptions + // need separate cleanup: they are not covered by NVIC->ICER/ICPR. + SysTick->CTRL = 0; + SysTick->LOAD = 0; + SysTick->VAL = 0; + SCB->ICSR = SCB_ICSR_PENDSTCLR_Msk | SCB_ICSR_PENDSVCLR_Msk; + NVIC->ICER[0] = 0xFFFFFFFF; + NVIC->ICER[1] = 0xFFFFFFFF; + NVIC->ICPR[0] = 0xFFFFFFFF; + NVIC->ICPR[1] = 0xFFFFFFFF; + __DSB(); + __ISB(); +''' +OLD_JUMP = ''' __set_CONTROL(0); // switch to MSP, required if using FreeRTOS + bootloader_util_app_start(NRF_UICR->NRFFW[0]); +''' +JUMP = ''' // The vector-table SVC above must complete before the helper masks + // interrupts. B1 and the retained peer record preserve legacy DFU entry. + mesh_nrf52_dfu_jump(NRF_UICR->NRFFW[0]); +''' + + +def patched_source(source): + source = source.replace("\r\n", "\n") + replacements = ((OLD_HELPER, HELPER), (OLD_INTERRUPTS, INTERRUPTS), + (OLD_JUMP, JUMP)) + if all(new in source and old not in source for old, new in replacements): + return source + if any(source.count(old) != 1 for old, _ in replacements): + raise RuntimeError( + "nRF52 BLE DFU handoff: unrecognized framework source; " + "review the framework update before building" + ) + for old, new in replacements: + source = source.replace(old, new) + return source + + +def replace_framework_source(build_env, node): + source = Path(node.srcnode().get_abspath()) + if source.name != "BLEDfu.cpp" or source.parent.name != "services": + return node + patched = patched_source(source.read_text(encoding="utf-8")) + build_env.AppendUnique(CPPPATH=[str(source.parent), str(source.parent.parent)]) + destination = Path(build_env.subst("$BUILD_DIR")) / "patched-nrf52-ble" / source.name + destination.parent.mkdir(parents=True, exist_ok=True) + if not destination.exists() or destination.read_text(encoding="utf-8") != patched: + destination.write_text(patched, encoding="utf-8") + print("nRF52 BLE: private FreeRTOS-safe DFU handoff") + return build_env.File(str(destination)) + + +if "Import" in globals(): + Import("env") + env.AddBuildMiddleware(replace_framework_source, "*BLEDfu.cpp") diff --git a/test/test_nrf52_ble_dfu_fix.py b/test/test_nrf52_ble_dfu_fix.py new file mode 100644 index 00000000..70d41b39 --- /dev/null +++ b/test/test_nrf52_ble_dfu_fix.py @@ -0,0 +1,244 @@ +"""Compile and execute the Bluetooth DFU stack-transition regression.""" + +from importlib.util import module_from_spec, spec_from_file_location +import os +from pathlib import Path +import shutil +import struct +import subprocess +import sys +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) +from check_nrf52_ble_dfu_handoff import (HELPER_CODE, HELPER_NAME, check_firmware, + writes_control) +from firmware_elf import FirmwareElf + +spec = spec_from_file_location("nrf52_ble_dfu_fix", ROOT / "scripts/nrf52_ble_dfu_fix.py") +patch = module_from_spec(spec) +spec.loader.exec_module(patch) + +try: + import unicorn + from unicorn import arm_const +except ImportError: + unicorn = None + + +FIXTURE_BODY = r''' +extern "C" void bledfu_control_wr_authorize_cb_fixture(uint32_t address) { + volatile uint32_t frame[26]; + // The real callback saves these registers while preparing peer/bond data. + // Keep that C epilogue so the old path reproduces its invalid MSP pop. + __asm volatile ("" ::: "r4", "r5", "r6", "r7", "r8"); + frame[0] = address; + uint32_t bootloader = frame[0]; + HANDOFF +} +''' +BROKEN_HELPER = r''' +extern "C" __attribute__((noinline, used)) +void bootloader_util_app_start(uint32_t address) { + __asm volatile ("ldr r1, [%0]\n" + "msr msp, r1\n" + "ldr r1, [%0, #4]\n" + "bx r1\n" :: "r" (address) : "r1"); +} +''' + + +def arm_compiler(): + core = Path(os.environ.get("PLATFORMIO_CORE_DIR", Path.home() / ".platformio")) + candidates = [os.environ.get("MESHCORE_ARM_GXX", ""), + str(core / "packages/toolchain-gccarmnoneeabi/bin/arm-none-eabi-g++"), + str(Path.home() / ".local/opt/arm-gnu-toolchain-14.2.rel1-x86_64-arm-none-eabi/bin/arm-none-eabi-g++"), + shutil.which("arm-none-eabi-g++") or ""] + return next((candidate for candidate in candidates if candidate and Path(candidate).is_file()), None) + + +class DfuSourcePatchTest(unittest.TestCase): + def fixture(self): + return (patch.OLD_HELPER + "// retained peer preparation and B1 stay here\n" + + patch.OLD_INTERRUPTS + + " VERIFY_STATUS( sd_softdevice_vector_table_base_set(NRF_UICR->NRFFW[0]), );\n" + + patch.OLD_JUMP) + + def test_patch_is_idempotent_and_preserves_handoff_protocol(self): + fixed = patch.patched_source(self.fixture()) + self.assertEqual(patch.patched_source(fixed), fixed) + self.assertIn("retained peer preparation and B1 stay here", fixed) + self.assertNotIn("__set_CONTROL(0)", fixed) + self.assertIn("sd_softdevice_vector_table_base_set", fixed) + self.assertLess(fixed.index("NVIC->ICER[1]"), + fixed.index("sd_softdevice_vector_table_base_set")) + self.assertLess(fixed.index("SCB_ICSR_PENDSVCLR_Msk"), + fixed.index("sd_softdevice_vector_table_base_set")) + self.assertLess(fixed.index("sd_softdevice_vector_table_base_set"), + fixed.index("mesh_nrf52_dfu_jump(NRF_UICR")) + + def test_unknown_or_partially_patched_framework_fails_closed(self): + for original in ("unrecognized framework", self.fixture().replace( + patch.OLD_JUMP, patch.JUMP), self.fixture().replace( + "NVIC->ICPR[0]=0xFFFFFFFF;", "NVIC->ICPR[0]=0;")): + with self.subTest(original=original), self.assertRaises(RuntimeError): + patch.patched_source(original) + + def test_every_nrf52_build_uses_patch_and_machine_gate(self): + ini = (ROOT / "platformio.ini").read_text() + base = ini.split("[nrf52_base]", 1)[1].split("\n[", 1)[0] + self.assertIn("pre:scripts/nrf52_ble_dfu_fix.py", base) + self.assertIn("post:scripts/check_nrf52_ble_dfu_handoff.py", base) + + +class DfuCompiledHandoffTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.compiler = arm_compiler() + if cls.compiler is None: + if os.environ.get("MESHCORE_REQUIRE_ARM_COMPILER") == "1": + raise RuntimeError("ARM compiler required for the DFU regression") + raise unittest.SkipTest("ARM compiler required for the DFU regression") + cls.folder = tempfile.TemporaryDirectory(prefix="meshcore-dfu-handoff-") + cls.path = Path(cls.folder.name) + cls.images = {} + for optimize, lto in (("-Os", False), ("-Oz", False), ("-Oz", True)): + for fixed in (False, True): + name = f"{'fixed' if fixed else 'broken'}-{optimize[1:]}-{'lto' if lto else 'plain'}" + source, elf = cls.path / (name + ".cpp"), cls.path / (name + ".elf") + handoff = ("mesh_nrf52_dfu_jump(bootloader);" if fixed else + '__asm volatile ("movs r1, #0\\nmsr control, r1\\n" ::: "r1");\n' + " bootloader_util_app_start(bootloader);") + source.write_text("#include \n" + (patch.HELPER if fixed else BROKEN_HELPER) + + FIXTURE_BODY.replace("HANDOFF", handoff)) + command = [cls.compiler, "-mcpu=cortex-m4", "-mthumb", optimize, + "-ffunction-sections", "-fdata-sections", "-nostdlib", + "-Wl,--entry=bledfu_control_wr_authorize_cb_fixture,-Ttext=0x26000", + str(source), "-o", str(elf)] + if lto: + command[1:1] = ["-flto", "-fipa-pta", "-fno-inline-small-functions"] + subprocess.run(command, check=True, capture_output=True, text=True) + cls.images[(optimize, lto, fixed)] = elf + + @classmethod + def tearDownClass(cls): + cls.folder.cleanup() + + def test_machine_gate_accepts_fixed_and_rejects_old_release_sequence(self): + for key, path in self.images.items(): + with self.subTest(build=key): + if key[2]: + self.assertTrue(check_firmware(path)) + else: + with self.assertRaisesRegex(ValueError, "safe stack-transition helper"): + check_firmware(path) + elf = FirmwareElf(path) + address, size, _ = elf.symbols["bledfu_control_wr_authorize_cb_fixture"] + self.assertTrue(writes_control(elf.read(address & ~1, size))) + + def test_machine_gate_rejects_helper_prologue_or_modified_instruction(self): + path = self.images[("-Oz", True, True)] + original = path.read_bytes() + self.assertEqual(original.count(HELPER_CODE), 1) + damaged = self.path / "changed-helper.elf" + damaged.write_bytes(original.replace(HELPER_CODE, b"\x00\xb5" + HELPER_CODE[2:])) + with self.assertRaisesRegex(ValueError, "qualified assembly"): + check_firmware(damaged) + + def test_machine_gate_rejects_control_write_in_callback_with_valid_helper(self): + path = self.images[("-Oz", True, True)] + elf = FirmwareElf(path) + address, size, _ = elf.symbols["bledfu_control_wr_authorize_cb_fixture"] + code = elf.read(address & ~1, size) + original = path.read_bytes() + self.assertEqual(original.count(code), 1) + damaged = self.path / "changed-callback.elf" + damaged.write_bytes(original.replace(code, b"\x80\xf3\x14\x88" + code[4:])) + with self.assertRaisesRegex(ValueError, "inside a C stack frame"): + check_firmware(damaged) + + def test_machine_gate_rejects_callback_which_bypasses_valid_helper(self): + path = self.images[("-Oz", True, True)] + elf = FirmwareElf(path) + address, size, _ = elf.symbols["bledfu_control_wr_authorize_cb_fixture"] + code = elf.read(address & ~1, size) + original = path.read_bytes() + self.assertEqual(original.count(code), 1) + damaged = self.path / "bypassed-helper.elf" + # BL to the following instruction replaces the final nonreturning call. + damaged.write_bytes(original.replace(code, code[:-4] + b"\x00\xf0\x00\xf8")) + with self.assertRaisesRegex(ValueError, "does not branch"): + check_firmware(damaged) + + def emulate(self, path, psp=True, handler=False): + if unicorn is None: + if os.environ.get("MESHCORE_REQUIRE_DFU_EMULATOR") == "1": + self.fail("Unicorn required for the Cortex-M DFU regression") + self.skipTest("Unicorn required for the Cortex-M DFU regression") + elf = FirmwareElf(path) + machine = unicorn.Uc(unicorn.UC_ARCH_ARM, unicorn.UC_MODE_THUMB | unicorn.UC_MODE_MCLASS) + machine.mem_map(0x20000, 0xE0000) + machine.mem_map(0x20000000, 0x40000) + for section in elf.sections: + if section[1] != 8 and section[2] & 4 and section[5]: + machine.mem_write(section[3], elf.section_bytes(section)) + machine.mem_write(0xF4000, struct.pack("