Secure Software Development for Embedded Devices

MuhammadMuhammadEmbedded Security6 days ago8 Views

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.

The Six Core Secure Coding Principles

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.

1. Least Privilege

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();
}

2. Defense in Depth

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.

3. Fail Securely

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);

4. Trust Nothing: Validate All Inputs

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.

5. Keep It Simple

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.

6. Secure by Default

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.

Common Coding Mistakes That Create Vulnerabilities

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.

Using Unsafe String and Memory Functions

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.

Ignoring Return Values

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.

Integer Overflow and Underflow

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.

Hardcoded Secrets

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.

Memory Leaks

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.

Memory Safety in Embedded C

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:

Rule 1: Initialise All Variables at Declaration

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 */

Rule 2: Check Every Allocation

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 */

Rule 3: Free After Use, Never Before

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 */
}

Rule 4: Prevent 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.

Rule 5: Know and Enforce Buffer Boundaries

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.

Rule 6: Clear Sensitive Data from Memory After Use

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.

Safe vs Unsafe Standard Library Functions

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));

Input Handling: Every Source of Untrusted Data

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.

Network Data

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;
}

User Commands over Serial

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 Data

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 Files

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.

Environment Variables on Embedded Linux

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 Checks

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).

Cryptographic Hash Functions vs Digital Signatures

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 vs SHA-256

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.

Implementing Runtime Integrity Verification

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.

Build Process Security

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.

Isolated Build Environment

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.

Pinned and Verified Dependencies

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)

Access Control on the Build Server

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.

Code Signing with an HSM

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.

Build Security Checklist

Apply this checklist to every production firmware release:

  • Clean build environment: no artefacts from previous builds present.
  • All tool and library versions verified against pinned hashes.
  • Static analysis run and all high-severity findings resolved.
  • Compiler security flags enabled (see next section).
  • Debug symbols and assert strings stripped from the release binary.
  • Firmware image hash computed and recorded in the release manifest.
  • Firmware signed using the HSM-protected production signing key.
  • Signature verified locally against the public key before distribution.
  • Anti-rollback version counter incremented if deploying to field devices.

Compiler Security Flags for Embedded C

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.

Managing Sensitive Data in Firmware

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.

Where Not to Store Secrets

  • Source code: Visible in version control history, build outputs, developer laptops and CI/CD logs. Any secret committed to source control should be treated as compromised immediately.
  • Plaintext configuration files in flash: Readable by anyone who dumps the flash chip. If configuration must be in flash, encrypt it with a device-unique key derived from hardware entropy.
  • Log files: Debug logs that print "connecting with password: Xk9#mP2$" expose credentials to anyone with log access. Scrub sensitive values from all log output using a sanitisation step.
  • Volatile RAM without clearing: Keys left in RAM after use survive until that memory region is reused. On a device with JTAG accessible, a snapshot of RAM taken during an active session contains all currently loaded keys.

Where to Store Secrets

  • Secure element (ATECC608A, SE050, OPTIGA Trust M): A dedicated security IC that stores keys in tamper-resistant hardware, performs cryptographic operations internally and never exposes key material to the host processor. The highest protection level available to embedded designs without a full HSM.
  • Encrypted flash partition: On ESP32 with flash encryption enabled, NVS (Non-Volatile Storage) data is encrypted transparently by the hardware AES engine. The encryption key is stored in eFuse and inaccessible after initial programming.
  • Protected flash sector with hardware read lock: On STM32, a flash sector can be write-protected and, with RDP Level 1, made unreadable via the debug interface. This is weaker than a secure element but better than unprotected storage.
  • Secure provisioning at factory: Credentials are injected into protected storage during the factory programming step using a dedicated provisioning fixture, never carried in the firmware binary distributed to the factory floor.

Limiting Time in Memory

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.

Secure Memory Wiping

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.

Putting It Together: A Pre-Release Security Checklist

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.

Coding Practices

  • No banned functions (strcpy, sprintf, gets, strcat, atoi) in any source file. Enforced by static analysis.
  • All return values from security-relevant functions checked. Compiler flag -Werror=unused-result enabled.
  • No integer arithmetic on user-controlled values without overflow pre-check.
  • All dynamic allocations checked for NULL before use.
  • All dynamically allocated pointers set to NULL after free.
  • All sensitive data buffers cleared with a compiler-safe zeroing function before free.
  • No hardcoded credentials, keys or tokens in any source file or build script.

Build Process

  • All dependency versions pinned and checksums verified in the build system.
  • Compiler security flags (-fstack-protector-strong, -Werror=format-security) enabled.
  • Static analysis (Cppcheck, Clang Static Analyzer or Coverity) run with zero high-severity open findings.
  • Debug symbols and assert strings stripped from the release binary.
  • Firmware image hash recorded in the release manifest.
  • Firmware signed with the HSM-protected production signing key.
  • Signature verified against public key before distributing the release.

Runtime Protections

  • MPU configured for all task memory regions before the scheduler starts.
  • Stack canaries enabled and monitored (hook the canary failure handler to log and reset).
  • Firmware integrity verified at boot before jumping to the application.
  • All inputs validated at protocol parser level before dispatch to handlers.
  • All authentication checks fail-secure: any non-success code denies access.

Production Hardware

  • Flash read protection set to the appropriate level for the threat model.
  • JTAG and SWD disabled in OTP/eFuse configuration.
  • Debug UART output disabled in production firmware build.
  • Unique device credentials provisioned at factory, not hardcoded.
  • Flash encryption enabled where the hardware supports it.

Conclusion

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.

Leave a reply

Loading Next Post...
Follow
Search Trending
Popular Now
Loading

Signing-in 3 seconds...

Signing-up 3 seconds...