Mar 23 2026

Building a Compact Cryptographic Secure Boot Chain on STM32F411


Author: Shafeeque Olassery Kunnikkal | Category: IoT, IoT Security, STM32F411 | Leave a Comment

This post is part of my STM32F411 Secure Boot Lab series, a hands-on embedded security project built around a Black Pill board, a compact cryptographic bootloader, and a reusable firmware analysis workflow covering authenticated boot, fail-closed validation, hidden trigger engineering, and reverse engineering preparation.

Project repository: This lab is part of my IoT_Projects GitHub repository, where I collect IoT, embedded security, and firmware research projects.

Most embedded projects are built around a simple assumption: if the board powers on and the firmware runs, the system is fine.

For embedded security, that assumption is not enough.

If a device will boot any image placed in flash, then firmware integrity and authenticity are largely being taken on faith. I wanted to build something better: a small STM32-based lab that would only boot an application if that application was well-formed, correctly placed, and cryptographically signed.

That became this project.

In this lab, I built a compact secure boot chain on the STM32F411CEU6 Black Pill using STM32CubeIDE, SHA-256, and ECDSA-P256. The bootloader validates the image header, checks the application vectors, hashes the application image, verifies the signature, and only then jumps to the application. If anything is wrong, it fails closed.

I chose this as my serious embedded security pilot because it is small enough to complete on inexpensive hardware, but rich enough to expose the kinds of problems that matter in real firmware work: flash layout, linker behavior, VTOR relocation, control transfer, and crypto integration on a microcontroller.

By the end of the project, the secure boot chain was working end to end.

STM32F411 Black Pill wired to a Tigard interface during secure boot bootloader and signing workflow testing
STM32F411 secure boot lab setup used during bootloader development, signing, and verified-boot testing.

Why I Started Here

I did not want my IoT or embedded security project to be flashy but shallow.

I wanted something that solved a real problem, forced me to deal with practical constraints, and could serve as a base for later work. Secure boot checked all three boxes.

At a high level, firmware trust sits underneath many other security claims. If a system cannot verify what it is about to execute, then update security, platform integrity, and even later reverse engineering exercises become less meaningful.

That is why I picked this use case.

I also chose the STM32F411CEU6 Black Pill very deliberately. It is affordable, widely available, well supported in STM32CubeIDE, and capable enough to run a compact public-key verification flow without making the project unreasonably heavy for this lab.

This was never meant to be a one-off demo. I wanted signed binaries, a clean bootloader/application split, visible UART evidence, and a structure I could reuse later for tamper tests, binary diffing, Ghidra work, and hardening experiments.

Project Goal

The goal was to implement a fail-closed secure boot chain on the STM32F411CEU6 Black Pill.

  • validate the image header
  • validate the application vector table
  • compute SHA-256 over the application image
  • verify an ECDSA-P256 signature
  • jump only if all checks passed
  • fail closed on invalid header, invalid vectors, invalid signature, or malformed image layout

I also wanted the application itself to be visible and testable. Instead of a hidden payload, I built a diagnostic app with UART output and LED behavior so I could confirm that the boot chain was really working.

Lab Environment

Hardware Used

  • STM32F411CEU6 Black Pill V2.0
  • ST-Link V2
  • Tigard / FTDI dual-channel UART adapter

Software Used

  • STM32CubeIDE
  • OpenSSL
  • ST-packaged Mbed TLS
  • Python helper scripts for packing and key conversion

I intentionally kept the setup simple and practical. I wanted a workflow that felt close to real embedded development rather than a heavily abstracted demo.

Final Memory Map

The final working flash layout was:

  • Bootloader: 0x08000000 .. 0x08007FFF (32 KB)
  • Image header: 0x08008000 .. 0x080081FF (512 bytes)
  • Application vectors + code: 0x08008200 and above

This layout made the trust boundaries very clear.

The bootloader lives in a fixed region. The signed header sits at a known address. The application vector table begins immediately after the header, followed by the rest of the application code.

That separation made the rest of the project easier to reason about, especially the linker configuration, vector checks, and host-side packing flow.

Bootloader Design

The bootloader project was named Project_1_1_Bootloader.

Its job was intentionally narrow:

  • validate the image header
  • validate the app vectors
  • calculate SHA-256 over the app image
  • verify the ECDSA-P256 signature using an embedded public key
  • jump to the app if valid
  • fail closed otherwise

That simplicity was a design choice. For secure boot, small and predictable is better than complicated.

Important Bootloader Files

  • mbedtls_config_boot.h
  • image_header.h
  • verify.c
  • updated main.c
  • pack_image.py
  • public_key_to_c.py

Mbed TLS Source Files Required

For the ST-packaged Mbed TLS copy I used, the minimum working set ended up being:

  • bignum.c
  • bignum_core.c
  • constant_time.c
  • ecp.c
  • ecp_curves.c
  • ecdsa.c
  • sha256.c
  • platform_util.c

One useful lesson here was that bignum.c alone was not enough. Because of the way the ST package was structured, I also had to add bignum_core.c and constant_time.c to resolve missing mbedtls_ct_* and mbedtls_mpi_core_* linker symbols.

Bootloader Size

text = 22136
data = 92
bss  = 1980

That kept total flash use at about 22.2 KB, safely inside the 32 KB bootloader region.

Application Design

The application project was named Project_1_2_App.

Its purpose was not stealth. Its purpose was observability.

The app was linked to run from 0x08008200 and included:

  • USART1 diagnostic console
  • LED heartbeat on PC13
  • simple commands:
    • STATUS
    • VERSION
    • LEDON
    • LEDOFF

Application Configuration

  • USART1 on PA9 / PA10
  • PC13 LED
  • 115200 8N1
  • VTOR relocation enabled
  • linked in flash, not RAM

Important Linker Fix

One of the early app failures came from the linker script behaving like a debug-in-RAM configuration. That placed vectors and code in RAM, which made the image invalid for this boot flow.

The corrected section behavior was:

  • .isr_vector -> FLASH
  • .text -> FLASH
  • .rodata -> FLASH
  • .data -> RAM with AT > FLASH
  • .bss -> RAM

VTOR Relocation

In system_stm32f4xx.c, the app uses:

#define USER_VECT_TAB_ADDRESS
#define VECT_TAB_OFFSET  0x00008200U

That relocation is required so the application uses its own vector table after the bootloader jumps to it.

Key Generation and Signing Workflow

The signing workflow was kept simple and repeatable.

First, I generated the keypair on the host:

  • private.pem
  • public.der

Then I converted the public key into a C array using public_key_to_c.py and embedded it into the bootloader verification code.

The application was built as a raw .bin, then packed and signed with:

python3 pack_image.py Project_1_2_App.bin app_packed.bin --key private.pem --version 1

This produced a packed image where:

  • the 512-byte signed header is written at 0x08008000
  • the application vectors and code start at 0x08008200

Flash Order

  1. Flash Bootloader.elf to 0x08000000
  2. Flash app_packed.bin to 0x08008000

That separation felt clean and correct. The PC signs the image. The MCU verifies it.

The Most Important Bug

The most important runtime problem was not in the application and not in flashing.

It was in the bootloader’s vector sanity check.

At one point, the bootloader kept printing:

[boot] invalid vectors
[boot] fail closed

That was confusing because the image header looked correct and the app vectors at 0x08008200 also looked valid.

A memory inspection showed:

  • SP = 0x20020000
  • Reset = 0x08008AF5

Those values were valid, but the bootloader still refused to continue.

Root Cause

The original stack pointer check used this logic:

if ((sp & 0x2FFE0000UL) != 0x20000000UL) return 0;

That rejected 0x20020000, even though it is a valid top-of-RAM initial MSP value for the STM32F411.

Fix

I replaced that brittle bitmask test with a proper range check:

if (sp < 0x20000000UL || sp > 0x20020000UL) return 0;
if ((sp & 0x3U) != 0U) return 0;
if ((rv & 1U) == 0U) return 0;
if ((rv & ~1U) < APP_VECTOR_ADDR || (rv & ~1U) >= FLASH_END_ADDR) return 0;

As soon as I made that change, secure boot worked.

This was a very useful lesson. In embedded security, a trust chain can fail even when the cryptography is correct. Low-level validation logic still has to be right.

Hardware and UART Notes

ST-Link Wiring

  • SWDIO -> DIO
  • SWCLK -> SCK
  • GND -> GND
  • 3.3V only if intentionally powering from ST-Link

UART Wiring

  • board PA9 / USART1_TX -> adapter RX
  • board PA10 / USART1_RX -> adapter TX
  • GND -> GND

On Kali Linux, the FTDI/Tigard adapter appeared as:

  • /dev/ttyUSB0
  • /dev/ttyUSB1

The correct console output was on:

  • /dev/ttyUSB0

Picocom Command

picocom -b 115200 /dev/ttyUSB0

or:

picocom -b 115200 --echo /dev/ttyUSB0

At the end of the session, application TX was confirmed working, but the command RX path still needed final validation. The command code exists, but interactive RX did not respond in the terminal during the last session.

The most likely remaining issue is UART RX wiring, FTDI channel selection, or final interrupt-path validation. This does not block the secure boot result, but it is the next thing to finish.

Final Result

The secure boot chain worked.

The bootloader successfully verified the signed application and jumped to it. The known-good UART output was:

[boot] secure boot starting
[boot] signature valid

=================================
[app] Application Boot Successful
[app] Diagnostic Console Enabled
Commands: STATUS, VERSION, LEDON, LEDOFF
=================================
[app] tick...

That output confirms several important things at once:

  • the bootloader is running
  • the header is valid
  • signature verification succeeds
  • the app vectors are acceptable
  • the jump to the application works
  • the application runtime is alive

That made this an end-to-end success, not just a cryptographic unit test.

What This Lab Taught Me

A few takeaways stood out.

First, memory layout is part of the security model. Flash addresses, linker behavior, and vector placement are not background details in a secure boot design.

Second, embedded crypto is practical, but integration still matters. Even a minimal library setup can involve missing pieces and linker surprises.

Third, a valid signature path is not enough by itself. Startup assumptions, vector checks, and jump conditions can make or break the trust chain.

And finally, visible diagnostics are worth it. A UART-speaking test app with an LED heartbeat is far more useful in an early lab than a silent payload.

Next Steps

There are several obvious follow-up tasks for this lab:

  • finalize the UART RX command path
  • tamper with the packed image and confirm signature failure
  • enable WRP
  • test RDP Level 1
  • create a clean app and a modified app for binary diffing
  • use the resulting binaries in Ghidra
  • explore secure boot bypass ideas from a defensive research angle

That is exactly why this project matters to me. It is not just a finished lab. It is a strong base for the next labs.

Conclusion

This project achieved its main goal: I built a compact cryptographic secure boot chain on the STM32F411CEU6 Black Pill that validates image structure, checks application vectors, computes a SHA-256 hash over the application, verifies an ECDSA-P256 signature, and transfers execution only when all checks pass.

If the image is invalid, it fails closed.

Just as importantly, the project forced me through the details that make embedded security real: flash layout, linker fixes, vector validation, boot-time assumptions, and practical crypto integration on a constrained MCU.

So while this article covers a single lab, I see it as the foundation for a larger embedded security track. It already supports tamper testing, binary comparison, reverse engineering, and future hardening work.

That makes it a strong pilot project and a useful platform for what comes next.

A secure boot chain is not just cryptography. It is memory layout, vector sanity, linker discipline, and a refusal to run code that has not earned trust.

Continue the series: Part 2 — Building a Secure Boot Chain on the STM32F411 (Part 2): Validation, Hidden Triggers, and Reverse Engineering

Series page: STM32F411 Secure Boot Lab Series

Related Posts


Building a Secure Boot Chain on the STM32F411 (Part 2): Validation, Hidden Triggers, and Reverse Engineering

Leave a Reply

Your email address will not be published. Required fields are marked *

Categories

Tags

Archives