
Secure embedded software development is not a checklist you run at the end of a project. It is a discipline applied to every function, every memory allocation and every build step from the first line of code. This article covers the complete practice: the six foundational security principles that shape good firmware design, memory safety rules with working C examples, input handling for every source of untrusted data, firmware integrity verification using hashes and digital signatures, hardening the build pipeline against supply chain compromise and managing secrets so they never leak through logs, memory dumps or firmware extraction. The material applies to bare-metal C on any ARM Cortex-M microcontroller and to embedded Linux firmware alike.
Secure embedded software development starts with six principles that apply regardless of architecture, RTOS or communication protocol. These are not abstract ideals. Each one translates directly into specific coding decisions that prevent entire classes of vulnerability.
Every component of your firmware should have exactly the access it needs and nothing more. In an RTOS (Real-Time Operating System) environment, this means tasks should not share memory regions unless explicitly required, peripheral access should be restricted to the tasks that own those peripherals, and interrupt service routines (ISRs) should do the minimum work needed and defer the rest to a task via a queue.
On microcontrollers with an MPU (Memory Protection Unit), least privilege becomes enforceable in hardware. Configure MPU regions so that the application task cannot write to the bootloader region, the stack of one task cannot overflow into the stack of another, and executable flash regions are not also writable. Without MPU configuration, all of this protection exists only in software conventions that a bug can violate instantly.
/* Configuring an MPU region on ARM Cortex-M4 using CMSIS.
This marks the bootloader region as read-execute only from all privilege levels.
The application task cannot write to addresses 0x08000000-0x08007FFF
even if a buffer overflow directs execution there. */
#include "core_cm4.h"
void configure_mpu_bootloader_region(void) {
/* Disable MPU before reconfiguring */
MPU->CTRL = 0;
/* Region 0: bootloader flash - read/execute, no write */
MPU->RNR = 0; /* Select region 0 */
MPU->RBAR = 0x08000000; /* Base address */
MPU->RASR =
MPU_RASR_ENABLE_Msk | /* Enable this region */
(0x0C << MPU_RASR_SIZE_Pos) | /* Size = 2^(12+1) = 32 KB */
(0x05 << MPU_RASR_AP_Pos) | /* AP=0b101: priv RO, unpriv RO */
MPU_RASR_C_Msk | /* Cacheable */
MPU_RASR_S_Msk; /* Shareable */
/* Re-enable MPU, keeping default memory map for regions not covered */
MPU->CTRL = MPU_CTRL_ENABLE_Msk | MPU_CTRL_PRIVDEFENA_Msk;
/* Ensure MPU settings take effect before next instruction */
__DSB();
__ISB();
}
No single control is sufficient. Defence in depth means layering controls so that a failure at one layer does not result in total compromise. In practice for embedded firmware this means: input validation at the protocol parser AND at the function that acts on the parsed data, encryption in transit AND read protection on flash, firmware signature verification at OTA delivery AND at boot time, and authentication in the cloud API AND on the device-side command handler.
The specific implementation of defence in depth that catches the most bugs is redundant validation: checking the same security condition in at least two distinct code paths that are separated enough that a single fault injection cannot skip both checks.
When an error occurs, your firmware must default to a safe state, never to an open one. A TLS connection that fails to establish should result in no data transmission, not fallback to plaintext. An authentication check that returns an unexpected error code should deny access, not grant it. A firmware update whose signature verification fails should leave the current firmware in place and log the event, not proceed with installation of the unsigned image.
The most common fail-insecure pattern in embedded firmware is the mistake of defaulting to success when an error return value is ignored:
/* FAIL-INSECURE: Ignoring the return value of the signature check.
If verify_signature() returns an error code due to a library initialisation
failure, corrupted memory or implementation bug, this code proceeds as though
verification succeeded. An attacker who can trigger the error condition
bypasses the entire check. */
bool firmware_is_valid = false;
verify_signature(image, image_len, sig, sig_len); /* Return value discarded */
firmware_is_valid = true; /* Always reached */
install_firmware(image);
/* FAIL-SECURE: Treat any non-success return as a verification failure.
The firmware is only installed if verify_signature() explicitly returns
VERIFY_OK. Any other return, including error codes, causes rejection. */
VerifyResult result = verify_signature(image, image_len, sig, sig_len);
if (result != VERIFY_OK) {
log_security_event(SEC_EVENT_FIRMWARE_VERIFY_FAILED, result);
/* Do NOT fall through. Halt or return to the update retry loop. */
enter_safe_recovery_mode();
return;
}
install_firmware(image);
Every byte that crosses a trust boundary is potentially attacker-controlled. Trust boundaries in embedded systems exist wherever data enters from: a network socket, a UART receive buffer, a BLE (Bluetooth Low Energy) GATT write, an SD card, a MQTT (Message Queuing Telemetry Transport) payload, an HTTP POST body, a configuration file read from flash, a sensor reading from an external I2C device, or environment variables on an embedded Linux system. All of it must be validated before use. This is covered in depth in the input handling section below.
Complex code contains more bugs. Every conditional branch, every callback chain, every state machine transition is a potential path to an unexpected state. Security-critical code paths should be as short and linear as possible. Avoid dynamic dispatch, function pointer tables and complex inheritance hierarchies in authentication, cryptography and firmware update logic. The code that verifies a firmware signature should be auditable in a single focused reading session.
Ship firmware with secure settings already active. Debug output: disabled. JTAG: locked. Default credentials: unique per device, not admin/admin. Unnecessary network services: not started. Flash read protection: enabled at the factory programming step. HTTPS: required, not optional. The user should have to take explicit action to reduce security, never to increase it.
Five categories of coding mistake account for the majority of firmware CVEs across all embedded platforms. Recognising these patterns by sight is a prerequisite for any firmware security review.
strcpy(), strcat(), sprintf(), gets() and unbounded scanf() do not check that the destination buffer is large enough for the data they write. They are the direct cause of stack buffer overflows in embedded firmware and should be treated as banned functions in any production codebase. Their safe replacements are discussed in the dedicated section below.
Every function that can fail returns an error indicator, either a return code, a NULL pointer or a negative value. Ignoring these return values means your firmware proceeds with uninitialised buffers, failed allocations, incomplete reads and unverified operations. In security-critical paths, an unchecked return value is a fail-insecure condition waiting to be triggered. Enable compiler warnings for discarded return values (-Wunused-result in GCC) and treat them as errors in your build system.
Arithmetic on unsigned integers wraps silently in C. A length calculation of user_len + HEADER_SIZE where both are uint16_t wraps to a small value when their sum exceeds 65,535, producing an undersized allocation that overflows on the next copy. Signed integer overflow is undefined behaviour in C, meaning the compiler is free to optimise away the overflow check entirely if it can prove the operation would overflow. Both require explicit pre-operation range checks before arithmetic, not after.
Credentials, keys and tokens compiled into firmware binaries are visible to anyone who extracts the image with binwalk or reads the source in a version control repository. This is covered fully in the sensitive data section below. It is listed here because it appears in every firmware security audit without exception.
On a device running for months or years, a heap leak of even a few bytes per operation compounds into eventual exhaustion. Embedded systems with no OS have no mechanism to recover leaked memory short of a reboot. Use static allocation where possible, audit every malloc() / free() pair, and run Valgrind or AddressSanitizer on a Linux simulation of your firmware logic to catch leaks before they ship.
70% of security vulnerabilities across all software domains are memory-related bugs. In embedded C, where there is no memory-safe language runtime, no garbage collector and often no MMU (Memory Management Unit), the developer is entirely responsible for memory safety. The six rules that cover the vast majority of memory safety bugs are:
Uninitialised local variables in C contain whatever bytes were previously on the stack in that location. Using them before assignment is undefined behaviour and in practice causes intermittent bugs that are extremely difficult to reproduce. Declare variables at their point of first use and initialise them there. For arrays and structs, use memset() or a compound literal initialiser.
/* WRONG: Uninitialised variables used before assignment.
'status' may contain any value. If the first branch of a conditional
does not execute, the unintialised value propagates as though it were
a valid status code. */
int status;
uint8_t recv_buf[128];
if (some_condition) {
status = process_packet(recv_buf, sizeof(recv_buf));
}
return status; /* Undefined if some_condition was false */
/* CORRECT: Initialise at declaration. Struct/array zeroed explicitly. */
int status = ERROR_NOT_EXECUTED;
uint8_t recv_buf[128];
memset(recv_buf, 0, sizeof(recv_buf));
if (some_condition) {
status = process_packet(recv_buf, sizeof(recv_buf));
}
return status; /* Always a defined value */
malloc() returns NULL when the heap is exhausted. Dereferencing a NULL pointer is undefined behaviour and on most embedded devices without an MMU causes a hard fault at address 0x00000000, typically resetting the device. On devices that have mapped memory at address zero (some microcontrollers put flash there), it silently reads and potentially executes wrong data. Always check for NULL before using a dynamically allocated pointer.
/* Pattern for safe dynamic allocation in embedded firmware.
Prefer static allocation where possible, but when dynamic allocation
is necessary, this pattern ensures NULL is always caught. */
typedef struct {
uint8_t data[MAX_PACKET_SIZE];
uint16_t length;
uint32_t timestamp;
} PacketBuffer;
PacketBuffer *alloc_packet_buffer(void) {
PacketBuffer *buf = (PacketBuffer *)malloc(sizeof(PacketBuffer));
if (buf == NULL) {
/* Log the allocation failure for diagnostics */
log_error(ERR_HEAP_EXHAUSTED, sizeof(PacketBuffer));
return NULL; /* Caller must check for NULL */
}
/* Zero-initialise: no uninitialised fields in the allocated struct */
memset(buf, 0, sizeof(PacketBuffer));
return buf;
}
/* Caller pattern: always check before use */
PacketBuffer *pkt = alloc_packet_buffer();
if (pkt == NULL) {
handle_allocation_failure();
return ERROR_OUT_OF_MEMORY;
}
/* Safe to use pkt here */
Use-after-free occurs when a pointer is used after the memory it points to has been freed. The freed memory may have been reallocated and filled with new content, causing the stale pointer to read or write the wrong data. After freeing a pointer, set it to NULL immediately. Attempting to dereference a NULL pointer causes a predictable fault, which is far easier to diagnose than a use-after-free corruption.
/* Safe free pattern: NULL the pointer immediately after freeing.
A second free of a NULL pointer is a no-op (defined behaviour in C).
A dereference of the NULL pointer causes a predictable hard fault
rather than silent memory corruption. */
void release_packet_buffer(PacketBuffer **buf_ptr) {
if (buf_ptr == NULL || *buf_ptr == NULL) {
return; /* Already freed or never allocated */
}
/* Overwrite sensitive fields before freeing (see memory wiping section) */
memset(*buf_ptr, 0, sizeof(PacketBuffer));
free(*buf_ptr);
*buf_ptr = NULL; /* Prevent use-after-free and double-free */
}
Calling free() twice on the same pointer is undefined behaviour. In practice it corrupts the heap allocator’s internal structures, which can be exploited as a heap overflow vulnerability on devices with sufficiently complex allocators. The NULL-after-free pattern above prevents double-free automatically: the second call to free(NULL) is a defined no-op.
Every array write must be bounded by the array’s actual size, not an assumed maximum. Use sizeof(array) for stack arrays, track the size alongside any pointer to a dynamically allocated buffer, and pass size parameters explicitly to every function that accepts a buffer pointer.
Cryptographic keys, passwords and authentication tokens that have been used must be explicitly overwritten before their memory is freed or reused. This is covered fully in the secure memory wiping section.
The following table lists the banned functions and their safe replacements. Apply this as a linting rule in your code review process. Any occurrence of a banned function in a pull request should block merge until it is replaced.
| Unsafe Function | Risk | Safe Replacement | Key Difference |
|---|---|---|---|
strcpy(dst, src) |
No destination size limit | strncpy(dst, src, n) |
Limits copy to n bytes; ensure null termination manually |
strcat(dst, src) |
No destination remaining space check | strncat(dst, src, n) |
n = remaining space in dst, not total size |
sprintf(buf, fmt, ...) |
Output length unbounded | snprintf(buf, size, fmt, ...) |
Returns number of bytes that would have been written; check for truncation |
gets(buf) |
No length limit at all; removed from C11 | fgets(buf, size, stream) |
Includes the newline in the buffer; strip it explicitly |
scanf("%s", buf) |
Reads until whitespace, no length limit | scanf("%255s", buf) |
Field width must be one less than buffer size |
atoi(str) |
No error detection, undefined on overflow | strtol(str, &end, 10) |
Sets errno on overflow; end points past valid digits |
memcpy(dst, src, n) |
Undefined if src and dst overlap | memmove(dst, src, n) |
Handles overlapping regions correctly |
A note on strncpy(): it does not guarantee null termination if the source string is longer than n. Always add an explicit null terminator after calling it:
/* Safe string copy pattern with guaranteed null termination.
dst_size is the total size of the destination buffer in bytes. */
void safe_strcpy(char *dst, const char *src, size_t dst_size) {
if (dst == NULL || src == NULL || dst_size == 0) return;
strncpy(dst, src, dst_size - 1); /* Copy at most dst_size-1 bytes */
dst[dst_size - 1] = '\0'; /* Guarantee null termination */
}
/* Usage: */
char device_name[32];
safe_strcpy(device_name, received_name, sizeof(device_name));
Secure embedded software development treats every source of external data as untrusted until validated. The five input sources that appear in almost every embedded design are handled differently, but the validation requirement is identical for all of them.
Messages arriving over MQTT, CoAP (Constrained Application Protocol), HTTP, Ethernet or a serial link must be validated before being dispatched to application logic. The parser is the first line of defence. A well-designed embedded protocol parser follows this pattern: check packet length against the declared length field (and against the maximum possible packet size), validate the message type is within the known range, validate each field’s type, length and range independently, and only then pass the validated message to the handler function.
/* Minimal safe MQTT message dispatcher.
All validation happens before the payload bytes touch application logic.
MAX_TOPIC_LEN and MAX_PAYLOAD_LEN are defined in your protocol spec. */
#define MAX_TOPIC_LEN 128
#define MAX_PAYLOAD_LEN 512
typedef struct {
char topic[MAX_TOPIC_LEN];
uint8_t payload[MAX_PAYLOAD_LEN];
uint16_t payload_len;
} MqttMessage;
typedef enum {
MSG_DISPATCH_OK,
MSG_DISPATCH_ERR_NULL,
MSG_DISPATCH_ERR_TOPIC_TOO_LONG,
MSG_DISPATCH_ERR_PAYLOAD_TOO_LONG,
MSG_DISPATCH_ERR_UNKNOWN_TOPIC
} MsgDispatchResult;
MsgDispatchResult dispatch_mqtt_message(const char *topic,
const uint8_t *payload,
uint16_t payload_len) {
if (topic == NULL || payload == NULL) {
return MSG_DISPATCH_ERR_NULL;
}
/* Length checks before any copy or string comparison */
if (strnlen(topic, MAX_TOPIC_LEN + 1) > MAX_TOPIC_LEN) {
log_security_event(SEC_EVENT_OVERSIZED_TOPIC);
return MSG_DISPATCH_ERR_TOPIC_TOO_LONG;
}
if (payload_len > MAX_PAYLOAD_LEN) {
log_security_event(SEC_EVENT_OVERSIZED_PAYLOAD);
return MSG_DISPATCH_ERR_PAYLOAD_TOO_LONG;
}
/* Route to the correct handler using exact topic matching */
if (strcmp(topic, "device/setpoint") == 0) {
return handle_setpoint_command(payload, payload_len);
} else if (strcmp(topic, "device/config") == 0) {
return handle_config_update(payload, payload_len);
}
return MSG_DISPATCH_ERR_UNKNOWN_TOPIC;
}
Commands arriving over UART are typically line-oriented. Validate length before storing (as shown in the previous article), validate the command verb against an explicit allowlist rather than passing it to a shell or a function pointer table without checking it, and validate each argument independently before use.
Sensor readings are rarely considered as an attack vector, but a compromised or spoofed sensor can inject values that cause the application to behave incorrectly. A temperature reading of 32,767 degrees passed directly to a heating controller is a safety incident. Apply physical plausibility checks: reject readings outside the sensor’s specified operating range, apply rate-of-change limits (a temperature that changes by 100 degrees in one second is not a valid reading), and use multiple sensors cross-validated against each other for safety-critical measurements.
Configuration read from flash or an SD card at boot is treated as trusted by most firmware. It should not be. A device that has been physically tampered with may have had its configuration modified. Apply an HMAC (Hash-based Message Authentication Code) over the configuration data using a device-unique key at write time, and verify it at read time before applying any configuration value. A failed HMAC check should result in loading safe defaults and logging a tamper event, not silently applying potentially malicious configuration.
On embedded Linux firmware, daemon processes may read configuration from environment variables. An attacker who can modify the process environment through a parent process injection or configuration file manipulation can alter behaviour without touching the main application binary. Treat environment variables as untrusted input, validate them with the same rigour as network data, and prefer reading configuration from a protected file with verified ownership and permissions.
Firmware integrity verification answers the question: has this firmware been modified since the authorised developer compiled and signed it? The answer is provided by cryptographic mechanisms applied at two points: at build time (signing the image) and at runtime (verifying the signature before execution or installation).
A hash function (SHA-256) produces a fixed-length fingerprint of the firmware image. It detects accidental corruption. It does not authenticate the source: anyone can compute a SHA-256 hash of a modified image and distribute the new hash alongside the tampered firmware. A hash alone is only sufficient when the reference hash is stored somewhere the attacker cannot modify it, typically OTP (One-Time Programmable) memory or a separately signed manifest.
A digital signature (ECDSA (Elliptic Curve Digital Signature Algorithm) or RSA-PSS (Probabilistic Signature Scheme)) uses asymmetric cryptography to bind the image hash to the manufacturer’s private key. Only the party who holds the private key can produce a valid signature. The device verifies it using the corresponding public key, which is stored in OTP memory at factory time. This provides both integrity and authenticity: confirming the image has not been modified and confirming it came from the authorised manufacturer.
CRC (Cyclic Redundancy Check) detects accidental bit errors but provides no security against deliberate modification. An attacker can always compute a CRC-matching modified image. CRC is appropriate for detecting flash corruption due to power loss or wear. SHA-256 is required for security-relevant integrity checking. The computational cost difference is acceptable even on constrained hardware: SHA-256 over a 256 KB firmware image on a 72 MHz Cortex-M3 takes roughly 150 milliseconds in software, under 5 milliseconds with a hardware hash accelerator.
At minimum, verify firmware integrity at two points: before installing an OTA update (reject unsigned or incorrectly signed images before writing a single byte to flash) and at every boot (verify the current firmware image before passing execution to the application layer). The boot-time check protects against physical flash modification between boots.
/* Boot-time firmware integrity check using SHA-256 (software implementation).
This runs in the first-stage bootloader before jumping to the application.
FIRMWARE_START, FIRMWARE_LENGTH and STORED_HASH are defined for your
specific flash layout. The stored hash must itself be in a protected region
that the application cannot overwrite (separate flash sector with write lock). */
#include "sha256.h" /* Lightweight SHA-256 implementation for bare-metal */
/* Reference hash stored in write-protected flash sector at factory time.
In production, this is written during factory programming, not hardcoded. */
extern const uint8_t STORED_FIRMWARE_HASH[32];
typedef enum {
BOOT_INTEGRITY_OK,
BOOT_INTEGRITY_HASH_MISMATCH,
BOOT_INTEGRITY_COMPUTATION_FAILED
} BootIntegrityResult;
BootIntegrityResult verify_firmware_at_boot(void) {
uint8_t computed_hash[32];
SHA256_CTX ctx;
/* Compute SHA-256 over the application firmware region in flash */
sha256_init(&ctx);
sha256_update(&ctx,
(const uint8_t *)FIRMWARE_START_ADDR,
FIRMWARE_LENGTH_BYTES);
sha256_final(&ctx, computed_hash);
/* Constant-time comparison to prevent timing side-channel attack.
memcmp() is NOT constant-time; use a dedicated function. */
if (!constant_time_memcmp(computed_hash, STORED_FIRMWARE_HASH, 32)) {
log_security_event(SEC_EVENT_BOOT_INTEGRITY_FAIL);
return BOOT_INTEGRITY_HASH_MISMATCH;
}
return BOOT_INTEGRITY_OK;
}
/* Constant-time byte comparison: always examines all bytes,
does not short-circuit on first mismatch.
Returns true if the two buffers are equal, false otherwise. */
bool constant_time_memcmp(const uint8_t *a, const uint8_t *b, size_t len) {
uint8_t diff = 0;
for (size_t i = 0; i < len; i++) {
diff |= (a[i] ^ b[i]);
}
return (diff == 0);
}
The constant-time comparison is not optional. A timing-based comparison that returns early on the first mismatched byte leaks information about how many bytes of the correct hash the attacker has guessed correctly, enabling a byte-at-a-time oracle attack.
A build process that produces correctly verified firmware is worthless if the build process itself has been compromised. The SolarWinds attack in 2020 and the ASUS Live Update attack in 2019 both demonstrated that an attacker who controls the build pipeline can distribute malicious firmware signed with the manufacturer's own legitimate key to millions of devices. Build process security is not optional infrastructure work: it is a core part of secure embedded software development.
Production firmware should be built in a dedicated, isolated environment that is not used for general development work, web browsing or email. A containerised build environment (Docker, Podman) with a pinned base image provides reproducibility and isolation. The container image itself should be version-controlled, its hash stored alongside the firmware release, and rebuilt from scratch on every major release rather than accumulated with successive apt install commands.
Every library, SDK, compiler and tool used in the build should have its version pinned and its checksum verified at build time. Pulling the latest version of any dependency at build time means your firmware binary may differ from the last build even though your source code has not changed, and the difference may include a malicious dependency update.
# CMake dependency management with hash verification.
# This downloads mbedTLS at a pinned commit hash and verifies it before use.
# If the download or hash check fails, the build fails with an error.
# In CMakeLists.txt or a dedicated FetchContent block:
include(FetchContent)
FetchContent_Declare(
mbedtls
GIT_REPOSITORY https://github.com/Mbed-TLS/mbedtls.git
GIT_TAG v3.5.1 # Pin to a specific release tag
GIT_SHALLOW TRUE
)
# After fetching, verify the commit hash matches the expected value.
# This prevents tag-moving attacks where a tag is reassigned to a different commit.
# Get the expected hash with: git rev-parse v3.5.1
# Then check it in CI:
# cd _deps/mbedtls-src && git rev-parse HEAD
# Expected: a1083e28c061a870bffd4e4dfad9fca4b74ee19c
FetchContent_MakeAvailable(mbedtls)
The build server is one of the highest-value targets in your infrastructure because it produces the artefacts that are cryptographically signed and distributed to devices. Restrict access to: engineers who directly maintain the build configuration, the CI/CD service account used by automated pipelines, and no one else. Log all access. Require MFA (Multi-Factor Authentication). Separate production signing keys from development signing keys, and ensure the production signing step is a distinct job that requires explicit approval.
The firmware signing private key must never exist in plaintext on a general-purpose computer or a build server filesystem. Use a Hardware Security Module (HSM) for production signing. The private key is generated inside the HSM, never exported, and all signing operations are performed by the HSM with the plaintext key never leaving the device. Cloud-based HSMs (AWS CloudHSM, Azure Dedicated HSM) provide this capability without requiring on-premises hardware.
Apply this checklist to every production firmware release:
GCC and Clang provide compiler-level mitigations that detect or prevent entire vulnerability classes at compile time or runtime. These are disabled by default on most embedded toolchains because they add code size or runtime overhead. In secure embedded software development, enable them explicitly and measure the cost rather than assuming it is unacceptable.
# GCC security flags for ARM embedded targets (arm-none-eabi-gcc).
# Add these to your CMakeLists.txt or Makefile CFLAGS variable.
# Measure code size and timing impact on your specific target before shipping.
# Error on discarded return values from security-relevant functions
-Werror=unused-result
# Warn on implicit function declarations (prevents calling wrong function)
-Wimplicit-function-declaration
# Stack canary: inserts a random value below the return address on each
# function entry and checks it before return. Detects stack buffer overflows.
# -fstack-protector-strong covers functions with arrays or address-taken locals.
-fstack-protector-strong
# Treat format string mismatches as errors (prevents format string attacks)
-Werror=format-security
# Warn when a pointer is cast to remove a const or volatile qualifier
-Wcast-qual
# Enable all common warnings and treat them as errors in security-critical modules
-Wall -Wextra -Werror
# For embedded Linux targets (not bare-metal), also add:
# Position-independent executable (makes ROP attacks harder)
-fPIE -pie
# Read-only relocations (prevents GOT overwrite attacks)
-Wl,-z,relro -Wl,-z,now
Stack canaries deserve specific attention. On a Cortex-M device without hardware stack overflow detection, a buffer overflow that overwrites the return address on the stack causes the processor to jump to an attacker-controlled address with no warning. With -fstack-protector-strong enabled, the canary check fires before the corrupted return address is used, turning a code execution vulnerability into a detectable crash. The overhead is one random value push and one comparison per protected function: typically 4 to 8 bytes of stack and 2 to 6 instructions.
Sensitive data in embedded firmware includes: encryption keys, HMAC keys, digital signature private keys, WiFi passwords, MQTT credentials, cloud API tokens, OAuth bearer tokens, X.509 private keys, device unique identifiers and any personal data the device collects. Every category requires the same set of protections: encrypted at rest, minimal time in RAM, explicitly cleared after use and never written to logs.
The shorter the window during which a secret exists in RAM, the smaller the attack surface. Decrypt a key immediately before use and clear it immediately after. Do not cache decrypted keys across function calls unless the performance cost of re-decryption is genuinely prohibitive. If caching is unavoidable, store the cached key in a memory region covered by an MPU restriction so only the key management code can access it.
Clearing sensitive data from memory after use is a requirement of any secure embedded software development process, and it has a well-known implementation pitfall: the compiler will optimise away a memset() call that writes to memory the compiler determines is never read again after the write. This is called a dead store elimination and it is the reason that straightforward memset(buf, 0, len); free(buf); sequences frequently result in no actual zeroing of the buffer in the optimised binary.
/* WRONG: Compiler optimises this memset away entirely.
The compiler's dataflow analysis determines that 'key_buf' is never
read after the memset, so under -O2 optimisation the memset
generates no machine code. The key remains in the freed heap block. */
uint8_t *key_buf = malloc(32);
perform_aes_operation(key_buf);
memset(key_buf, 0, 32); /* Optimised away: dead store */
free(key_buf);
/* CORRECT OPTION 1: Explicitly_bzero / memset_s (C11).
memset_s is guaranteed not to be optimised away by conforming compilers.
Available in C11 and later. Check your toolchain supports it. */
#include
uint8_t *key_buf = malloc(32);
perform_aes_operation(key_buf);
memset_s(key_buf, 32, 0, 32); /* Cannot be optimised away */
free(key_buf);
key_buf = NULL;
/* CORRECT OPTION 2: Volatile pointer trick for toolchains without memset_s.
Casting to volatile uint8_t* forces the compiler to treat each write
as a side-effectful operation that cannot be eliminated. */
static void secure_zero(void *ptr, size_t len) {
volatile uint8_t *p = (volatile uint8_t *)ptr;
while (len--) {
*p++ = 0;
}
}
uint8_t *key_buf = malloc(32);
perform_aes_operation(key_buf);
secure_zero(key_buf, 32); /* Volatile writes are not optimised away */
free(key_buf);
key_buf = NULL;
/* CORRECT OPTION 3: explicit_bzero (GNU/POSIX extension).
Available on most embedded Linux distributions and recent newlib versions. */
#include
explicit_bzero(key_buf, 32);
The wiping rule applies to: symmetric encryption and HMAC keys after the operation completes, password buffers after authentication, private key material after signing, session tokens after logout or session expiry, and any intermediate cryptographic state (AES key schedules, SHA internal state) after the operation completes.
This checklist operationalises everything in this article into a set of verifiable conditions that should all be met before a firmware version is released to production.
strcpy, sprintf, gets, strcat, atoi) in any source file. Enforced by static analysis.-Werror=unused-result enabled.-fstack-protector-strong, -Werror=format-security) enabled.Secure embedded software development is practised at every stage of the firmware lifecycle, not added at the end. The six core principles shape architecture decisions before a line of code is written. Memory safety rules and the replacement of banned functions with safe alternatives prevent the 70% of vulnerabilities that are memory-related. Input validation at every trust boundary stops injection attacks at the parser before they reach application logic. Firmware integrity checks using SHA-256 hashes and ECDSA signatures ensure that what runs on the device is exactly what the developer compiled and signed. Build pipeline security and dependency pinning ensure that what the developer compiled was not tampered with before it was signed. And correct secret management, anchored in hardware-backed storage and compiler-safe memory wiping, ensures that the keys protecting everything else do not leak through logs, memory dumps or firmware extraction. Each practice is individually straightforward. Applied together, they produce firmware that is defensible, auditable and maintainable across the full device lifetime.






