Fixing Bugs In STM32’s Secure Flashing Tool

Many of our latest posts have been about embedded linux devices, but this one is switching gears to an MCU: The STM32 microcontroller. Summit Embedded rescently helped a client to secure their STM32 device by implementing various features like secure boot (STiRoT), JTAG/SWD Debug Authentication, and ARM TrustZone.

Debug Authentication requires the user to provide a secret key to unlock the JTAG debug port. Likewise, secure boot only allows firmware to run if it's signed by the right private key. There are many ways to configure this, but we'll simplify this post by referring to this as a "closed" device.

TrustZone uses special ARM Cortex-M hardware to separate the firmware into two isolated parts: the "secure world" and "non-secure world". The chip has separate memory regions, flash controllers, etc for both worlds. TrustZone removes the need for a separate TPM security chip. Instead, secret keys are stored in the secure world, and user applications (eg Ethernet connectivity) live in the non-secure world.

The Issue

These security features are enabled in a process called "provisioning". Provisioning takes less than a minute, but requires writing certain private key material to the device. The details of the private key material are beyond the scope of this post. The important detail is that many companies require the provisioning process to occur in a secure physical location by only a handful of trusted administrators. Thus, while the process takes only a few seconds, it's a huge headache to have to re-provision devices repeatedly, especially when your development team works remotely.

However, today's online resources & AI tools instruct users to do this painful re-provisioning process every time a developer wants to JTAG-flash a device to test a new firmware build. This is because a "closed" STM32 errors when you flash with ST's STM32_Programmer_CLI tool. It returns a Fail to read NVM Size error message.

We knew this would be an enormous impediment to the developer workflow and dove deeper into that Fail to read NVM size error. We determined this not a limitation of the ARM hardware itself but rather a series of bugs in the STM32_Programmer_CLI tool! We were able to craft a workaround to allow developers seamlessly flash an already-provisioned-and-closed device with VSCode, just like they would with an unsecured device.

Follow Along With Your Own ST Hardware

The below information is deeply technical. If you'd like to step through on your own hardware, you can purchase a NUCLEO-H533RE Evaluation Board for less than $30. We've shared our proof-of-concept code at github.com/summitembedded/stm32-secure-program-fix. You can pull that workspace, build in VSCode, and follow along. View the commit history of the main branch to test before & after the fix.

How We Knew It Was Possible

After flashing in an open state, closing again (PRODUCT_STATE=0x72), unlocking with the right debug auth key (eh permissions), we noticed that you could still stop at a breakpoint and read/write individual RAM addresses via JTAG. Thus there was no hardware limitation preventing the JTAG debugger from accessing the "secure world". So why did STM32_Programmer_CLI error when trying to write flash?

To understand exactly what the ST command was doing, we ran the flash command with the -vb 3 argument, which prints every single register write during the flash process. Here's where we found the root cause of the bug:

$ STM32_Programmer_CLI -c port=SWD mode=HotPlug ap=1 -vb 3 -w Secure/build/sec_flash_demo_s.elf -v

 Memory Programming ...
...
  Erase via Debugger!

  write 0x40022008 = 0x45670123      NSKEYR
  write 0x40022008 = 0xCDEF89AB      NSKEYR
  write 0x40022028 = 0x00000804      NSCR: SER | SNB | STRT
  read  0x40022020 = 0x00020000      NSSR
  write 0x50022028 = 0x00000001      SECCR
  write 0x0C000400 = 0x20010000      program word -> PGSERR
  read  0x50022024 = 0x00040000      SECSR: PGSERR

... Memory Erase via Debugger! ... Fail to read NVM Size ...

Notice the NS*** register writes. Recall that TrustZone has duplicate hardware for every Secure/NonSecure peripheral, including the flash memory contoller. Let's understand the address ranges of the STM32:

  • 0x40022*** - Non-Secure flash controller
  • 0x50022*** - Secure flash controller
  • 0x08****** - Non-Secure flash region (user Non-Secure application goes here)
  • 0x0C****** - Secure flash region (user's secure application goes here)

So from the above trace, we can see that the STM32_Programmer_CLI configures the Non-Secure flash controler for writing, then tries to write to a Secure address! Of course this will error.

To fix it, we simply need to configure the Secure flash controller for writing, instead of the Non-Secure controller.

Determining Which Registers To Write

After noticing the initial "configuring the wrong controller" issue, we dove deep into the verbose vb -3 trace of STM32_Programmer_CLI and used an AI tool to cross reference against ST's RM0481 Reference manual.

The below table explains every register write, what the bugs were doing wrong, and what we need to change to fix them:

# Step What STM32_Programmer_CLI does Should Be Note
1 set flash wait states ACR - Flash Access Control Register
2 disable instruction cache ICACHE_CR - Instruction Cache Control Register
3 unlock for erase NSKEYR - Non-Secure Key Register (flash unlock) SECKEYR - Secure Key Register (flash unlock) <-- BUG 1 these 5 erase functions run on the non-secure controller (should be secure)
4 clear error flags NSCCR - Non-Secure Clear-Control Register SECCCR - Secure Clear-Control Register
5 select sector erase (SER + sector) NSCR - Non-Secure Control Register SECCR - Secure Control Register
6 start the erase (SER + START) NSCR - Non-Secure Control Register → WRPERR (write-protect error) SECCR - Secure Control Register
7 wait + check status read NSSR - Non-Secure Status Register, ignores WRPERR poll SECSR - Secure Status Register, stop on error
8 deselect skips it; erase mode (SER) left on SECCR - Secure Control Register -> 0
9 unlock for program SECKEYR - Secure Key Register (flash unlock) still unlocked
10 clear flags SECSR - Secure Status Register (read-only, no-op) done already
11 mark blocks secure SECBB1R1 - Secure Block-Based Register, bank 1 (R2-R4 absent on H533) not needed; secure watermark already makes these sectors secure
12 enable programming SECCR - Secure Control Register -> LOCK bit SECCR - Secure Control Register -> PG bit <-- BUG 2 LOCK is bit 0; PG (program) is bit 1
13 write image data to 0x0C… → PGSERR (prog-sequence error) to 0x0C…
14 wait + check status read NSSR - Non-Secure Status Register poll SECSR - Secure Status Register <-- BUG 3 verify uses the non-secure status register
15 clear status NSSR - Non-Secure Status Register (read-only, no-op) not needed; SR is read-only, flags are cleared via SECCCR in step 4
16 finish skips it; flash left unlocked SECCR - Secure Control Register: clear PG, set LOCK, read back, NRST

The fix

To fix the above issues, we mainly just need to send each command to the Secure Flash Controller (0x50022***) instead of the Non-Secure Flash Controller (0x40022***). The STM32_Programmer_CLI is not an open source tool that we could modify, so we tested this using pyocd. pyocd as a whole doesn't fully support TrustZone on STM32 devices, but we can use it's low-level target.write32 function to write any address we want (assuming proper debug authentication access).

Here's the core snippet of our ROT_Provisioning/scripts/flash_closed.py script - the flash() function that resolves the above bugs:

CONTROLLERS = {
    "secure": {
        "flash_start": 0x0C000000,
        "unlock":      0x50022008,  # SECKEYR
        "status":      0x50022024,  # SECSR
        "control":     0x5002202C,  # SECCR
        "clear_flags": 0x50022034,  # SECCCR
    },
    "non-secure": {
        "flash_start": 0x08000000,
        "unlock":      0x40022004,  # NSKEYR
        "status":      0x40022020,  # NSSR
        "control":     0x40022028,  # NSCR
        "clear_flags": 0x40022030,  # NSCCR
    },
}

def flash(target, quads):
    # One ELF lives in one world, so its first address picks the controller
    first_address = min(quads)
    # ST: uses NSxxx registers, even for secure flash
    regs = which_flash_controller(first_address)
    base = regs["flash"].start
    target.write32(regs["clear_flags"], CLEAR_ALL_FLAGS)

    # ST: unlocks unconditionally, which bus-faults if already unlocked
    if target.read32(regs["control"]) & CR_LOCK:
        target.write32(regs["unlock"], FLASH_UNLOCK_WORD1)
        target.write32(regs["unlock"], FLASH_UNLOCK_WORD2)

    ## ERASE
    sectors_per_bank = regs["sectors_per_bank"]
    sectors = {(address - base) // regs["sector_size"] for address in quads}
    for sector in sorted(sectors):
        bank = CR_BKSEL if sector >= sectors_per_bank else 0
        erase_control = (CR_SER | bank
                         | ((sector % sectors_per_bank) << CR_SNB_POS))
        target.write32(regs["control"], erase_control)
        target.write32(regs["control"], erase_control | CR_START)
        # ST: ignores error flags and carries on
        wait_not_busy(target, regs, f"erase sector {sector}")
    target.write32(regs["control"], 0)  # ST: skips clearing SER

    ## PROGRAM
    # ST: writes 0x1, which is LOCK, not PROGRAM
    target.write32(regs["control"], CR_PROGRAM)
    for address in sorted(quads):
        target.write_memory_block8(address, quads[address])
    # ST: polls NSSR, so it never sees secure errors
    wait_not_busy(target, regs, "program")
    target.write32(regs["control"], 0)
    target.write32(regs["control"], CR_LOCK)  # ST: leaves it unlocked

This can flash our example image in about 1.9 seconds, a pretty similar speed to STM32_Programmer_CLI's flashing of non-secure images.

Hooking it into VS Code

The above proof of concept worked to flash our board in the CLOSED state. But to really be a useful workaround, this script must be seamlessly integrated into a developer's workflow. For this, we made a simple update to our existing .vscode/tasks.json file. This allows a developer to simply click that green "play" arrow in VSCode's debugger, and debug just like they would with ST's programming tool.

Previously, we were using the following preLaunchTask to write the image when the board was in the OPEN state:

"command": "STM32_Programmer_CLI -c port=SWD mode=HotPlug ap=1 -w Secure/build/stirot_appli_s.elf -w NonSecure/build/stirot_appli_ns.elf -rst",

To flash in the closed state, we just had to point this preLaunchTask to our new pyocd-based flashing script:

"command": "python ROT_Provisioning/scripts/flash_provisioned.py --target stm32h533retx --halt Secure/build/stirot_appli_s.elf NonSecure/build/stirot_appli_ns.elf"

Note: launch.json must also use request: attach (instead of request: launch). With launch, cortex-debug has ST-LINK_gdbserver flash the ELF. The gdbserver hands that job to STM32CubeProgrammer instead of our script.

Next
Next

Reverse Engineering Closed-Source GMSL Camera Drivers