Part 3 · Firmware Systems33 min read
Firmware Security
A connected device is a small computer that sits in someone else’s building, runs for years without supervision, and talks to the network. That makes it a target: for people who want to copy your product, steal the data it handles, use it as a foothold in a network, or make it do something unsafe. This lesson gives you the working knowledge a firmware engineer needs: how to think about threats, how secure boot and signed updates actually work, where keys live, what readout protection really protects, what TrustZone-M adds, and the C coding habits that close the most common holes. It does not make you a cryptographer. The first rule of embedded crypto is to use a vetted library and spend your effort on using it correctly.
Threat model first
Section titled “Threat model first”Security without a threat model is guesswork. Before choosing any mechanism, write down three things: what you are protecting (assets), who might attack (adversaries and their budget), and how they can reach the device (attack surfaces).
Assets on a typical device:
- Firmware IP: your code and algorithms, which a competitor could clone.
- Keys and credentials: signing keys, device identity keys, cloud credentials, Wi-Fi passwords.
- User data: measurements, location, audio, anything personal.
- Safety and function: the device must keep doing its job. An attacker who can make a heater ignore its thermostat has done more damage than one who reads a log.
Attack surfaces:
| Surface | Example attack | Typical mitigation |
|---|---|---|
| Physical access | Desolder flash, probe buses, glitch the supply | Readout protection, encrypted external flash, tamper detection |
| Debug port (SWD/JTAG) | Connect a probe, dump flash, change memory | RDP, debug lock, disable in production |
| Update channel | Push a malicious or old image | Signed images, anti-rollback |
| Network / radio | Malformed packet overflows a buffer | Careful parsers, least privilege, fuzzing |
| Supply chain | Tampered toolchain, compromised dependency, cloned chips at the factory | Pinned toolchain, reproducible builds, provisioning in a trusted step |
Adversaries range from a curious user with a USB-serial adapter, through a competitor with a lab and a few thousand dollars, to a well-funded team with fault-injection rigs. A smart bulb and an insulin pump face very different adversaries. Match the defence to the realistic attacker: a defence that costs the attacker more than the asset is worth is enough.
The chain of trust
Section titled “The chain of trust”Secure boot means every stage of software is verified by the stage before it, back to something that cannot be changed:
immutable verified by stage 1 verified by stage 2+-----------+ check +-----------------+ check +------------------+| ROM or | --------> | bootloader | --------> | application || locked | signature | (holds public | signature | (runs only if || stage 1 | | key) | | verified) |+-----------+ +-----------------+ +------------------+The first link is the root of trust. On parts with secure ROM boot (STM32H5, NXP LPC55, many application processors), the vendor ROM checks the first-stage bootloader against a key hash burned into fuses. On the STM32F407 there is no such ROM feature, so the root of trust is your own bootloader in sectors 0 and 1, made immutable with write protection (the nWRP bits in the option bytes) and readout protection. Each later stage carries a signature that the previous stage checks before transferring control.
Why a CRC is not security
Section titled “Why a CRC is not security”The bootloader from the previous lesson checks a CRC-32. That detects accidental corruption perfectly well, but CRC is a public, keyless function. An attacker who modifies the image simply recomputes the CRC and writes the new value into the header. The same is true of a plain SHA-256 in the header: it proves the image matches the hash, but anyone can compute a new hash. Integrity against an attacker requires something the attacker cannot compute, which means a secret key.
There are two ways to bind an image to a key:
- MAC (HMAC-SHA256): a shared secret key. Fast and small, but every device that verifies must hold the same secret that signs. Extract the key from one device and you can produce valid images for all of them.
- Digital signature (ECDSA, Ed25519, RSA): a private key signs, a public key verifies. The device holds only the public key, which does not need to be secret, only unmodifiable. Extracting it gains the attacker nothing.
For firmware updates, use signatures. The private key never touches a device.
Choosing a signature algorithm
Section titled “Choosing a signature algorithm”| Algorithm | Public key | Signature | Verify time on an M4 at 168 MHz | Notes |
|---|---|---|---|---|
| RSA-2048 | 256 B (modulus) | 256 B | a few to tens of ms | Fast verify with e = 65537, big keys and signatures |
| ECDSA P-256 | 64 B | 64 B (raw r,s) | tens to hundreds of ms | NIST curve, widely required, supported by secure elements |
| Ed25519 | 32 B | 64 B | tens of ms | Simple, deterministic, hard to misuse |
The times are orders of magnitude, not promises: they vary several-fold with library, optimisation level, and whether the code runs from flash with wait states. Measure on your board with the DWT cycle counter. What matters for a bootloader is that all three are fast enough to run once per boot, and that code size differs: an ECC verify-only build of a small library is a few KB to around 10 KB, a full mbedTLS configuration can be many tens of KB unless you trim it.
Libraries you will meet: mbedTLS (comprehensive, configurable, the default in many SDKs and in MCUboot), TinyCrypt (small, P-256 and SHA-256), micro-ecc (tiny ECDSA), Monocypher (small, Ed25519/X25519/ChaCha20). Pick one with a maintenance history and test vectors, configure only the algorithms you need, and do not write your own primitive.
Verifying a signed image
Section titled “Verifying a signed image”Extend the image format from the previous lesson with a signature. A common choice is to sign the SHA-256 digest of the header plus the image, so the bootloader can hash the image in flash in a streaming fashion and then do one signature check on 32 bytes. MCUboot works this way.
#include <stdbool.h>#include <stddef.h>#include <stdint.h>
#include "boot_shared.h" /* image_header_t, APP_VTOR from the bootloader lesson */
/* Thin wrappers over your crypto library (mbedTLS, TinyCrypt, Monocypher, ...). */typedef struct { uint32_t state[8]; uint64_t bit_count; uint8_t block[64]; size_t used;} sha256_ctx_t;void sha256_init(sha256_ctx_t *ctx);void sha256_update(sha256_ctx_t *ctx, const void *data, size_t len);void sha256_final(sha256_ctx_t *ctx, uint8_t digest[32]);bool ed25519_verify(const uint8_t sig[64], const uint8_t *msg, size_t len, const uint8_t pubkey[32]);
typedef struct { image_header_t hdr; /* 32 bytes, as in the bootloader lesson */ uint8_t signature[64]; /* Ed25519 over SHA-256(hdr || image) */} signed_header_t;
/* Lives in the bootloader's own write-protected sector. Generated at build time from the public half of the signing key; the private half never leaves the HSM. */static const uint8_t ROOT_PUBKEY[32] = {#include "root_pubkey.inc"};
bool image_is_authentic(const signed_header_t *sh){ uint8_t digest[32]; sha256_ctx_t ctx;
sha256_init(&ctx); sha256_update(&ctx, &sh->hdr, sizeof sh->hdr); sha256_update(&ctx, (const void *)APP_VTOR, sh->hdr.image_size); sha256_final(&ctx, digest);
return ed25519_verify(sh->signature, digest, sizeof digest, ROOT_PUBKEY);}The sha256_ctx_t layout and the wrapper names are whatever your library and wrapper define; what matters is the shape. The header is hashed along with the image, so an attacker cannot take a validly signed image and change its version or size field. image_size must already have passed the bounds checks from the previous lesson before it is used to hash flash, otherwise a malicious header makes the bootloader read past the end of the slot.
root_pubkey.inc is a generated file containing 32 comma-separated byte values. Generating it in the build from the public key file avoids copy-paste mistakes, and keeps the private key out of the repository entirely.
The public key’s security depends on nobody being able to replace it. On the F407 that means: it lives in sector 0 or 1, those sectors are write-protected through the option bytes, readout protection prevents a debugger from rewriting the option bytes back without a mass erase, and the bootloader never offers a command that writes its own sectors. On parts with OTP fuses, store a hash of the public key in fuses and check the key against it.
The signing pipeline
Section titled “The signing pipeline”The private key is the most valuable secret your company owns for this product. Anyone holding it can ship firmware to every device. Treat it accordingly:
- Generate it inside a hardware security module (HSM) or a cloud key management service (KMS), marked non-exportable.
- Sign release images in CI by sending the digest to the HSM/KMS. The build machine never sees the key.
- Use separate development and production keys. Development boards trust the development key, which engineers can use freely. Production devices trust only the production key, which only the release pipeline can use.
- Plan for rotation and revocation before you ship: reserve space for more than one public key, and an OTP bit per key that marks it revoked. If a key leaks and the device can trust only that one key forever, you cannot recover.
Integrity, confidentiality, and randomness
Section titled “Integrity, confidentiality, and randomness”Three different jobs, three different tools:
- Integrity and authenticity: signatures for anything that comes from your servers (images, configuration bundles, commands). HMAC for messages between two parties that already share a per-device key.
- Confidentiality: AES-GCM or ChaCha20-Poly1305. Both are AEAD ciphers: they encrypt and authenticate in one operation, so a tampered ciphertext is rejected rather than decrypted into garbage. Never use AES in ECB mode, and never reuse a nonce with the same key in GCM or ChaCha20-Poly1305: a single reuse can expose the authentication key and the XOR of the two plaintexts.
- Randomness: keys, nonces, challenge values, and ECDSA signing all need unpredictable numbers.
rand() is not a random number generator in any security sense. In newlib it is a linear congruential or similar small-state generator: its whole future output follows from a few observed values, and if you seed it from a constant or from a timer that starts at zero, every device produces the same sequence after every reset. Nonces from rand() repeat across devices, and keys derived from it can be guessed.
The STM32F407 has a hardware true random number generator (RNG) on AHB2, fed by analog noise. It needs the 48 MHz clock from the PLL’s Q output, which you configured in Clocks, reset, and timing. The F407 has no AES or SHA accelerator; its sibling the F417 adds CRYP and HASH peripherals, and many newer parts have both plus public-key accelerators.
#include <stdbool.h>#include <stdint.h>
#include "stm32f4xx.h"
void rng_init(void){ RCC->AHB2ENR |= RCC_AHB2ENR_RNGEN; /* requires the 48 MHz PLL48CLK */ (void)RCC->AHB2ENR; RNG->CR |= RNG_CR_RNGEN;}
bool rng_read(uint32_t *out){ for (uint32_t tries = 0; tries < 1000u; tries++) { uint32_t sr = RNG->SR; if (sr & (RNG_SR_SECS | RNG_SR_CECS)) { return false; /* seed or clock error: output unusable */ } if (sr & RNG_SR_DRDY) { *out = RNG->DR; return true; } } return false; /* never became ready: treat as failure */}The two error bits matter. SECS reports a bad seed (the noise source got stuck), CECS reports that the RNG clock is too slow. When either is set, the output must not be used; the reference manual describes the recovery sequence (clear the interrupt flag, disable and re-enable the RNG). A security function that silently continues with a failed RNG is worse than one that stops. In practice you feed TRNG output into a deterministic random bit generator from your crypto library (for example mbedTLS’s CTR_DRBG) rather than using raw words directly.
Where keys live
Section titled “Where keys live”Public keys need integrity. Private and symmetric keys need integrity and secrecy. The options, from weakest to strongest:
- In the firmware image. Anyone who reads the flash has it. Acceptable only for public keys, and only if the image region is write-protected.
- In a protected flash sector behind readout protection. Better, but a successful RDP bypass (see below) exposes it.
- In OTP memory. The F407 has 512 bytes of one-time-programmable flash at
0x1FFF7800, organised as 16 blocks of 32 bytes, with 16 lock bytes at0x1FFF7A00that can freeze a block permanently. Good for things that must never change: a public key hash, a device certificate’s fingerprint, an anti-rollback counter. Not secret against a chip-level attacker. - In a secure element, such as Microchip’s ATECC608 on I2C. The private key is generated inside the chip and never leaves it; the MCU sends a digest and receives a signature. Hardened against physical attacks, cheap (on the order of a dollar), and the right answer when a device needs a strong identity for cloud authentication.
- In a TrustZone secure partition or an on-chip secure enclave on parts that have one.
Never put a fleet-wide secret on devices: a shared AES key or HMAC key on every unit means one extraction compromises every unit. Use per-device keys instead. A common pattern: at the factory, an HSM computes device_key = HMAC-SHA256(master_key, device_uid) using the 96-bit unique ID at 0x1FFF7A10, injects the result into the device, and the master key stays in the HSM. Your servers can recompute any device’s key from its UID, and extracting one device’s key reveals nothing about any other.
An anti-rollback counter in OTP
Section titled “An anti-rollback counter in OTP”OTP bits can only go from 1 to 0, which is exactly the property a version counter that must never decrease needs. Spend one OTP byte per step: an erased byte (0xFF) is a step not yet taken, a programmed byte (0x00) is a step taken.
#include <stdint.h>
#define OTP_BASE 0x1FFF7800u#define OTP_ARB_BLOCK 15u /* reserve the last 32-byte block */
/* Number of security-version steps burned so far (0..32). */uint32_t security_version(void){ const volatile uint8_t *blk = (const volatile uint8_t *)(OTP_BASE + 32u * OTP_ARB_BLOCK); uint32_t v = 0; for (uint32_t i = 0; i < 32u; i++) { if (blk[i] != 0xFFu) { v++; } } return v;}The bootloader refuses any image whose header carries a security version lower than security_version(). When a release fixes a vulnerability, the release image’s security version goes up by one, and after the new image has booted and confirmed itself, the bootloader programs the next OTP byte (with the same flash programming sequence as main flash, using byte-sized PSIZE). Thirty-two steps over a product’s life is plenty if you raise the counter only for security fixes. Do not set this block’s lock byte, or you can never advance the counter.
Debug port protection
Section titled “Debug port protection”The SWD port is the most powerful attack surface on the device: halt the core, read every byte of flash and RAM, write anything. Production devices must close it.
On STM32 this is Read-Out Protection (RDP), set by an option byte:
| Level | Option byte value | Effect | Reversible? |
|---|---|---|---|
| 0 | 0xAA | No protection | n/a |
| 1 | any other value (commonly 0xBB) | Flash is blocked while a debugger is connected, or when booting from SRAM or the ROM bootloader. Code running from flash still reads its own flash normally. | Yes, but going back to level 0 mass-erases flash |
| 2 | 0xCC | Debug port permanently disabled, booting from SRAM and system memory disabled, option bytes frozen | No, permanently |
You set it with the option-byte sequence in the FLASH registers (unlock with OPTKEYR, write the RDP field of OPTCR, start with OPTSTRT), or with tools: STM32CubeProgrammer’s command line (-ob RDP=0xBB) or OpenOCD’s stm32f2x lock 0 command (OpenOCD drives the F4 with its F2 flash driver). In production this is the last step of provisioning, covered in Production, provisioning, and the field.
What does RDP actually protect against? Level 1 stops anyone with a probe and an afternoon from dumping your firmware. It does not stop a determined lab: public research has demonstrated RDP bypasses on several STM32 families, using voltage glitching during the option-byte load at reset or weaknesses in the debug logic. Treat RDP as raising the cost of extraction, not as making secrets safe. That is why per-device keys matter: if extraction is possible, make sure extracting one device gets the attacker one device.
Other vendors have equivalents: Nordic’s APPROTECT, eFuses on ESP32 that disable JTAG and enable flash encryption, NXP’s debug authentication with signed unlock certificates. The last is worth knowing as a pattern: the debug port stays closed unless the host presents a challenge response signed by your key, which keeps failure analysis possible without leaving the door open.
TrustZone-M and PSA
Section titled “TrustZone-M and PSA”The Cortex-M33 (and M23, M55, M85), the ARMv8-M architecture, adds TrustZone-M: the core runs in either a secure or a non-secure state, and every address is attributed as secure, non-secure, or non-secure callable.
- The SAU (Security Attribution Unit), programmed by secure code at boot, and the vendor-fixed IDAU together decide the security attribute of every address.
- Non-secure code cannot read or write secure memory or secure peripherals at all. The hardware enforces it, with a SecureFault if violated.
- Non-secure code enters secure code only through NSC (non-secure callable) regions that contain veneers: short stubs that begin with the
SG(secure gateway) instruction. Branching anywhere else in secure memory faults. - Secure code is compiled with
-mcmse; functions exported to the non-secure side carry__attribute__((cmse_nonsecure_entry)), which makes the compiler clear registers on return so secure data does not leak.
A sketch of a secure-side service, compiled for an M33 with -mcpu=cortex-m33 -mcmse (it will not build with this course’s M4 flags):
#include <arm_cmse.h>#include <stddef.h>#include <stdint.h>
int32_t sign_digest_internal(const uint8_t digest[32], uint8_t sig[64]);
/* Callable from the non-secure application through an NSC veneer. */__attribute__((cmse_nonsecure_entry))int32_t secure_sign_digest(const uint8_t *digest, uint8_t *sig){ /* Never trust a pointer from the non-secure side: it might point at secure RAM. */ if (cmse_check_address_range((void *)(uintptr_t)digest, 32, CMSE_NONSECURE | CMSE_MPU_READ) == NULL || cmse_check_address_range(sig, 64, CMSE_NONSECURE | CMSE_MPU_READWRITE) == NULL) { return -1; } return sign_digest_internal(digest, sig);}The pointer checks are the whole point. Without them, non-secure code passes a pointer to the secure key store as sig, and the secure function happily writes over its own secrets, or passes a secure address as digest and gets a signature over secret data. This is the “confused deputy” bug, and it is the most common TrustZone vulnerability.
What goes where: the secure side holds the boot verification, keys, crypto, secure storage, and the update logic. The non-secure side runs the RTOS, drivers, network stack, and application, which is where almost all the bugs are. A remote code execution bug in the network stack then gets the attacker the non-secure world, but not the keys.
PSA Certified is Arm’s security framework and certification scheme for IoT devices, with levels from a questionnaire-based level 1 to lab-tested resistance against physical attacks at level 3. TF-M (Trusted Firmware-M) is the open-source reference implementation of the secure side: secure boot (based on MCUboot), crypto through the PSA Crypto API, internal trusted storage, and attestation, with Zephyr and other RTOSes running in the non-secure world. If you start a new design that needs serious security, an M33 part with TF-M support gives you most of this lesson already integrated.
Secure updates
Section titled “Secure updates”Signed images are the foundation. A complete secure update adds:
- Anti-rollback, as shown above, so old signed images with known holes are rejected.
- Encrypted images, if the firmware itself is an asset. The image is encrypted with a random AES key per release, and that key is wrapped for the device (with a device-held key, or with ECIES so each device can unwrap it with its private key). MCUboot supports encrypted images this way. Encryption protects IP in transit and on external flash; it does not replace signing, because encryption without authentication lets an attacker flip bits in the decrypted code.
- Authenticated commands. “Erase”, “factory reset”, “unlock debug”, “set config”: any command that changes the device’s state must be authenticated, not just the image. Use a challenge-response: the device sends a fresh random nonce, the server signs
command || nonce, and the device checks the signature and that the nonce is the one it just issued. The nonce stops replay, where an attacker records a valid signed command and sends it again later.
Secure coding in C
Section titled “Secure coding in C”The cryptography is usually not what breaks. The parser in front of it is. Every function that reads bytes from a UART, radio, USB endpoint, file, or the network is an attack surface, and in C a single missing length check turns into arbitrary code execution, because a microcontroller has no MMU, no ASLR, and usually executable RAM.
Trusting a length field
Section titled “Trusting a length field”#include <stddef.h>#include <stdint.h>#include <string.h>
typedef struct { uint8_t type; uint16_t len; uint8_t data[64];} msg_t;
void handle_packet_bad(const uint8_t *pkt, size_t pkt_len, msg_t *out){ (void)pkt_len; /* never checked: that is the bug */ out->type = pkt[0]; out->len = (uint16_t)(pkt[1] | (pkt[2] << 8)); memcpy(out->data, &pkt[3], out->len); /* attacker chooses the length */}The length comes from the packet, so the attacker controls it. Send len = 300 and memcpy writes 236 bytes past data, over whatever follows out in memory. If out is on the stack, that includes the saved lr, and the function returns to an address the attacker chose. It also reads past the end of the received packet if pkt_len is short.
#include <stddef.h>#include <stdint.h>#include <string.h>
typedef struct { uint8_t type; uint16_t len; uint8_t data[64];} msg_t;
int handle_packet(const uint8_t *pkt, size_t pkt_len, msg_t *out){ if (pkt_len < 3u) { return -1; /* too short for the header */ } size_t len = (size_t)pkt[1] | ((size_t)pkt[2] << 8); if (len > sizeof out->data || len > pkt_len - 3u) { return -1; /* would overflow dst or overread src */ } out->type = pkt[0]; out->len = (uint16_t)len; memcpy(out->data, &pkt[3], len); return 0;}Two checks, one for each buffer: the length must fit the destination, and it must not exceed what was actually received. pkt_len - 3u is safe because pkt_len < 3 was rejected first. Return an error and let the caller count it; never “clamp and continue”, which hides attacks and bugs alike.
Integer overflow in size math
Section titled “Integer overflow in size math”#include <stdbool.h>#include <stdint.h>
static uint8_t pool[4096];
bool fits_bad(uint32_t count, uint32_t rec_size){ return count * rec_size <= sizeof pool; /* 0x10000 * 0x10000 wraps to 0 */}
bool fits(uint32_t count, uint32_t rec_size){ return rec_size != 0u && count <= sizeof pool / rec_size;}Unsigned multiplication wraps modulo 2^32, so two large values multiply to something small and the check passes. The same trap applies to offset + len > size, which you saw handled correctly in the bootloader as offset > size || len > size - offset. The rule: when either operand comes from outside, rearrange the comparison so no intermediate can wrap, using division or subtraction on values already known to be in range.
Format strings
Section titled “Format strings”#include <stdio.h>
void greet(const char *name) /* name arrives from the network */{ printf(name); /* "%x%x%x%x" dumps the stack; "%n" writes memory */ printf("%s", name); /* correct: name is data, never a format */}If a string from outside reaches the format argument of a printf-family function, the attacker can read the stack and, where %n is supported, write to memory. -Wformat=2 includes -Wformat-security, which warns when the format is not a string literal. Treat that warning as an error.
Stack canaries
Section titled “Stack canaries”-fstack-protector-strong makes GCC place a guard value between local arrays and the saved registers in every function that has a local array or takes the address of a local. Before returning, the function checks the guard; if an overflow changed it, the function calls __stack_chk_fail instead of returning to a corrupted lr.
On bare metal you provide both symbols and randomise the guard at boot:
#include <stdint.h>
#include "stm32f4xx.h"
uintptr_t __stack_chk_guard = 0x595E9FBDu; /* overwritten from the TRNG at boot */
__attribute__((noreturn)) void __stack_chk_fail(void){ __disable_irq(); /* stack is corrupt: do not return into it */ NVIC_SystemReset(); for (;;) { }}Set __stack_chk_guard from rng_read early in Reset_Handler, before calling any instrumented function, and never return from the function that changed it (Reset_Handler never returns, so it is the right place). The cost is a load, a store, a compare and a branch per protected call, plus four bytes of stack. A canary does not stop overflows that skip over it or that corrupt data rather than return addresses, so it is a backstop, not a substitute for length checks. In a real handler, record the crash in .noinit before resetting, as in Watchdogs, faults, and recovering from them.
Constant-time comparison
Section titled “Constant-time comparison”#include <stdbool.h>#include <stddef.h>#include <stdint.h>
bool ct_equal(const uint8_t *a, const uint8_t *b, size_t n){ uint8_t diff = 0; for (size_t i = 0; i < n; i++) { diff |= (uint8_t)(a[i] ^ b[i]); } return diff == 0u;}memcmp returns at the first differing byte, so it takes longer the more leading bytes match. An attacker who can submit MACs or passwords and time the response (over a fast UART, that is easy) guesses the first byte, then the second, turning a 2^128 search into a few thousand tries. ct_equal always touches all n bytes and has no data-dependent branch. Use it for every comparison involving a secret: MACs, tags, password hashes, unlock tokens. Check the disassembly once; an aggressive optimiser is allowed to add an early exit, and crypto libraries ship their own audited version for that reason.
Glitching and side channels
Section titled “Glitching and side channels”Physical attacks are worth knowing at an awareness level, because some defences cost almost nothing.
- Fault injection (glitching): a brief dip in the supply voltage, a too-short clock cycle, or an electromagnetic pulse makes the CPU skip or corrupt an instruction. The classic target is the single branch that decides “signature valid, boot it”.
- Side channels: power consumption, electromagnetic emissions, and timing leak information about secret-dependent operations. Simple and differential power analysis can recover AES or ECC keys from an unprotected implementation with a scope and some statistics.
Cheap mitigations against glitching:
#include <stdbool.h>#include <stdint.h>
#include "stm32f4xx.h"
#define AUTH_OK 0x3CA5965Au /* many bits set, not 1 */
bool image_is_authentic_wrapper(void); /* runs the signature check */void random_delay(void); /* a few microseconds, TRNG-driven */void jump_to_app(uint32_t vtor);
__attribute__((noreturn)) static void secure_halt(void){ __disable_irq(); for (;;) { }}
void boot_decision(uint32_t vtor){ volatile uint32_t auth = 0u; if (image_is_authentic_wrapper()) { auth = AUTH_OK; } random_delay(); if (auth != AUTH_OK) { secure_halt(); } random_delay(); if (auth != AUTH_OK) { /* a second, independent check */ secure_halt(); } jump_to_app(vtor);}A bool is “true” for any non-zero value, so a glitch that corrupts it in almost any way reads as success. A 32-bit constant with mixed bits can only be produced by the intended assignment. volatile forces the compiler to keep both checks rather than merging them. The random delays make the timing of the second check unpredictable, so an attacker must land two glitches at an unknown interval. Add enabling the brown-out reset at the highest level your supply allows, so supply glitches below the threshold reset the chip instead of corrupting it. None of this stops a skilled lab, but it turns a one-afternoon attack into a research project.
Against side channels: use crypto libraries that claim constant-time implementations, keep long-term private keys in a secure element, and do not implement key-handling algorithms yourself.
Shipping checklist
Section titled “Shipping checklist”Before a product leaves the factory:
- Secure boot enabled: every image signature-checked by an immutable stage, public key write-protected or anchored in OTP.
- Production signing key in an HSM/KMS, development key not trusted by production devices, rotation and revocation planned.
- Anti-rollback counter in place and the process for raising it documented.
- Debug port locked (RDP level 1 at minimum, level 2 or authenticated debug if the threat model demands it), option bytes set as the last provisioning step.
- Per-device keys and identities; no fleet-wide secrets in flash.
- TRNG used for all keys and nonces, with its error flags checked.
- Every external input parser reviewed for length and integer-overflow checks, fuzzed on the host with sanitizers.
-Wformat=2 -Werror,-fstack-protector-strong, and the other hardening flags in the release build.- Debug features (semihosting, test commands, verbose logs with secrets) compiled out of production builds.
- A way to ship a security fix to the fleet, tested end to end, including rollback.
Common mistakes
Section titled “Common mistakes”- Treating a CRC or plain hash as authentication. Anyone can recompute both. Sign images with a private key that never leaves your HSM.
- One AES or HMAC key on every device. One extraction compromises the fleet. Derive per-device keys at provisioning.
- Seeding
rand()and calling it random. Its output is predictable and often identical across devices. Use the TRNG, check its error bits, feed a DRBG. - Checking the length against the destination but not the source (or the reverse). Check both:
len <= sizeof dstandlen <= received - header. - Writing
a + b > limitwith values from the wire. It wraps. Rearrange toa > limit || b > limit - a. - Setting RDP level 2 on the first prototype run. The boards can never be debugged again. Validate the whole flow at level 1.
- Exported TrustZone functions that trust non-secure pointers. Check every pointer with
cmse_check_address_rangebefore using it.
Exercises
Section titled “Exercises”- On your host, compile
handle_packet_badand amainthat calls it with a 10-byte packet whose length field is 200, usinggcc -std=c11 -g -fsanitize=address. Read the AddressSanitizer report: it should name astack-buffer-overflow(orheap-buffer-overflow, depending on where you putmsg_t) insidememcpy. Switch tohandle_packetand confirm it returns-1with no report. - Write a function with a local
char buf[32]that copies a string into it, compile it witharm-none-eabi-gcc -mcpu=cortex-m4 -mthumb -O2 -fstack-protector-strong -S, and find the guard: a load of__stack_chk_guardin the prologue, a compare before the return, and abl __stack_chk_fail. Then compile a function with no arrays and confirm it has no guard. - Write the threat model for the bootloader from the previous lesson as a table: list at least four attacks it currently allows (for example, flashing an unsigned image over UART, downgrading to an old version, dumping flash over SWD, sending a malformed DATA frame) and the mitigation from this lesson for each.
- With Python’s
cryptographypackage, generate an Ed25519 key pair, sign the SHA-256 digest of a test.bin, and write the public key out as the comma-separated bytes of aroot_pubkey.incfile. Flip one byte of the.binand show that verification fails. - Hard: add signature verification to the bootloader from the previous lesson using a small library (Monocypher or TinyCrypt), store the public key in sector 0, extend
send_image.pyto append the signature, and measure the verification time at 16 MHz and at 168 MHz withDWT->CYCCNT. Report the code size increase from the map file.
Check yourself
Section titled “Check yourself”Question 1:The bootloader checks a SHA-256 hash stored in the image header before booting. What can an attacker who controls the update channel do?
A hash has no key, so anyone can compute it. Only a signature (or a MAC with a secret key) binds the image to someone who holds a key the attacker does not have.
Question 2:Every device in a fleet verifies updates with HMAC-SHA256 using the same key stored in flash. Why is this weak even with RDP level 1 enabled?
With a symmetric scheme the verifying key is also the signing key. RDP raises the cost of extraction but has been bypassed in research, and one success compromises the whole fleet. Signatures avoid this because devices hold only the public key.
Question 3:Which check is correct when off and len both come from a received packet and size is the buffer size?
off + len can wrap to a small value and pass. Checking off first makes size - off safe, so the second comparison cannot wrap.
Question 4:A token check uses memcmp(received, expected, 16) == 0. What is the security problem?
memcmp stops at the first difference. Measuring response time reveals the length of the matching prefix, which reduces the search to 16 times 256 guesses. Use a constant-time comparison.
Question 5:Why does the hardened boot decision store 0x3CA5965A in a volatile uint32_t instead of using a bool?
Glitch attacks corrupt values or skip instructions. Requiring an exact 32-bit pattern, checked twice with a random delay between, means a single fault rarely turns failure into success.
Key takeaways
Section titled “Key takeaways”- Start with a threat model: assets, adversaries, attack surfaces. Defend against the realistic attacker, not every attacker.
- Secure boot is a chain of signature checks anchored in something immutable; a CRC or hash alone protects only against accidents.
- Devices hold public keys (which need integrity) and per-device secrets (which need secrecy); fleet-wide secrets and private signing keys never go on a device.
- RDP level 1 stops casual readout but not a determined lab; level 2 is permanent. Plan for extraction to be possible.
- Use the hardware TRNG, never
rand(), and use AEAD ciphers (AES-GCM, ChaCha20-Poly1305) for confidentiality. - Most real exploits are parser bugs: check every length against both buffers and arrange size arithmetic so it cannot wrap.
Next: Debugging and tracing on real hardware, where you learn to see inside a running chip with SWD, gdb, SWO and RTT.