Fix nRF52 Bluetooth DFU stack handoff

This commit is contained in:
mikecarper committed 2026-10-01 16:15:59 -07:00
1 parent 3e3c4bdd4e
commit 65a8cfd721
5 files changed
+483

No files matched your search

+115
View File
@@ -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("<HH", code, offset)
if first & 0xF800 != 0xF000 or second & 0xD000 not in (0x9000, 0xD000):
continue
sign = (first >> 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("<HH", code, offset)
if first & 0xFFF0 == 0xF380 and second == 0x8814:
return True
return False
def check_firmware(path):
elf = FirmwareElf(path)
callbacks = [name for name in elf.symbols
if "bledfu_control_wr_authorize_cb" in name]
if not callbacks and HELPER_NAME not in elf.symbols:
return False # A firmware without the DFU service has no handoff to gate.
if HELPER_NAME not in elf.symbols:
raise ValueError("Bluetooth DFU callback lacks the safe stack-transition helper")
address, size, _ = elf.symbols[HELPER_NAME]
address &= ~1 # ELF Thumb function values carry the low bit.
if size != len(HELPER_CODE) or elf.read(address, size) != HELPER_CODE:
raise ValueError("Bluetooth DFU helper differs from the qualified assembly")
if not callbacks:
raise ValueError("Bluetooth DFU helper is present without its control callback")
for name in callbacks:
start, size, _ = elf.symbols[name]
start &= ~1
if not size:
raise ValueError("Bluetooth DFU callback has no inspectable machine code")
code = elf.read(start, size)
if writes_control(code):
raise ValueError("Bluetooth DFU callback changes CONTROL inside a C stack frame")
if address not in branch_targets(code, start):
raise ValueError("Bluetooth DFU callback does not branch to the safe helper")
return True
def register_platformio(env):
def check(source, target, env):
path = Path(env.subst("$BUILD_DIR/${PROGNAME}.elf"))
try:
enabled = check_firmware(path)
except (OSError, ValueError, KeyError, struct.error) as error:
print(f"nRF52 Bluetooth DFU qualification failed: {error}", file=sys.stderr)
return 2
print("nRF52 Bluetooth DFU: " + ("safe compiled handoff PASS" if enabled
else "service not linked"))
return 0
env.AddPostAction("$BUILD_DIR/${PROGNAME}.elf", check)
for alias in ("checkprogsize", "upload"):
env.AddPreAction(env.Alias(alias), check)
for suffix in ("bin", "hex", "uf2", "zip"):
env.AddPreAction("$BUILD_DIR/${PROGNAME}." + suffix, check)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("elf", type=Path)
args = parser.parse_args()
try:
enabled = check_firmware(args.elf)
except (OSError, ValueError, KeyError, struct.error) as error:
parser.exit(1, f"nRF52 Bluetooth DFU qualification failed: {error}\n")
print("nRF52 Bluetooth DFU: " + ("safe compiled handoff PASS" if enabled
else "service not linked"))
try:
Import("env") # noqa: F821 -- PlatformIO/SCons
except NameError:
if __name__ == "__main__":
main()
else:
register_platformio(env) # noqa: F821
+119
View File
@@ -0,0 +1,119 @@
"""Give the pinned Bluefruit DFU service a safe FreeRTOS bootloader handoff.
The original callback selects MSP while its C frame is still on PSP. GCC 14
then emits a stack restore on MSP before reaching the bootloader jump, outside
the nRF52840 SRAM boundary. Keep the legacy B1/retained-peer protocol, but move
the stack transition into a naked, nonreturning assembly helper. Patch a private
build copy; never modify PlatformIO's shared framework cache.
"""
from pathlib import Path
OLD_HELPER = 'extern "C" void bootloader_util_app_start(uint32_t start_addr);\n'
HELPER_NAME = "mesh_nrf52_dfu_jump"
HELPER = r'''// Keep all stack operations inside this assembly-only transition. In particular,
// the callback must return neither through its PSP frame nor through a C helper
// after CONTROL selects MSP. Preserve handler-mode entry supported by BLEDfu.
extern "C" __attribute__((naked, noinline, noreturn, used))
void mesh_nrf52_dfu_jump(uint32_t start_addr)
{
__asm volatile (
"cpsid i\n"
"mrs r3, ipsr\n"
"ldr r1, [r0]\n"
"ldr r2, [r0, #4]\n"
"msr msp, r1\n"
"movs r0, #0\n"
"msr control, r0\n"
"msr basepri, r0\n"
"msr faultmask, r0\n"
"dsb\n"
"isb\n"
"cmp r3, #0\n"
"bne 1f\n"
"mvn lr, #0\n"
"cpsie i\n"
"bx r2\n"
"1:\n"
"sub sp, sp, #32\n"
"str r0, [sp, #0]\n"
"str r0, [sp, #4]\n"
"str r0, [sp, #8]\n"
"str r0, [sp, #12]\n"
"str r0, [sp, #16]\n"
"mvn r0, #0\n"
"str r0, [sp, #20]\n"
"str r2, [sp, #24]\n"
"mov r0, #0x21000000\n"
"str r0, [sp, #28]\n"
"mvn lr, #6\n"
"cpsie i\n"
"bx lr\n"
);
}
'''
OLD_INTERRUPTS = ''' // Disable all interrupts
NVIC->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")