Back to Blog
high SEVERITY9 min read

Stack Corruption on ESP32: When memcpy Reads Beyond UART Buffer Bounds

A high-severity vulnerability in ESP32 firmware allowed attackers to trigger stack and heap corruption by sending malformed UART frames shorter than expected to an mmWave sensor driver. Multiple `memcpy` operations copied data into fixed-size local variables without first verifying the source buffer was large enough, opening the door to arbitrary code execution. The fix replaces magic-number length guards with `sizeof()`-based checks that are portable, self-documenting, and provably correct.

O
By orbisai0security
May 28, 2026

Stack Corruption on ESP32: When memcpy Reads Beyond UART Buffer Bounds

Introduction

Embedded firmware is one of the most unforgiving environments in software security. There is no operating system memory manager to catch out-of-bounds reads, no garbage collector to clean up after a bad pointer, and often no runtime exception to tell you something went wrong — just silent corruption, a crashed device, or worse, an attacker who now controls your microcontroller.

This post breaks down a high-severity vulnerability discovered in an ESP32-based mmWave sensor driver (mmwave_sensor.c). The root cause is deceptively simple: several memcpy calls copied UART-received bytes directly into typed local variables without checking whether the source buffer was actually long enough. On a microcontroller like the ESP32, that is all an attacker needs to corrupt the stack and achieve arbitrary code execution.

If you write firmware, parse binary protocols, or work with any code that copies data from an untrusted external source into a fixed-size buffer, this post is for you.


The Vulnerability Explained

What Is a Buffer Overread via memcpy?

memcpy(dst, src, n) copies exactly n bytes from src to dst. It does not know — and does not care — whether src actually contains n valid bytes. If the source buffer is shorter than n, memcpy will happily read past the end of the buffer, pulling in whatever bytes happen to live in adjacent memory: other local variables, saved return addresses, or heap metadata.

On the ESP32 (an Xtensa LX6 core), the stack grows downward and function frames are tightly packed. Reading or writing a few bytes past a local buffer can silently overwrite a saved $a0 return address, turning a parsing bug into a control-flow hijack.

Where Did This Happen?

The vulnerable code lives in firmware/esp32-csi-node/main/mmwave_sensor.c, inside the function mr60_process_frame(). This function parses incoming binary frames from an MR60 mmWave radar sensor delivered over UART. Three frame types were affected:

Frame Type Local Variable Expected Size Guard Used
MR60_TYPE_BREATHING float 4 bytes len >= 4
MR60_TYPE_HEARTRATE float 4 bytes len >= 4
MR60_TYPE_DISTANCE uint32_t + float 8 bytes len >= 8

The guards look correct at first glance — float is 4 bytes on most platforms, and uint32_t is always 4 bytes. So what is the problem?

The Three Specific Problems

Problem 1 — Magic numbers are not portable guarantees.
The C standard does not guarantee that float is exactly 4 bytes on every platform or compiler configuration. Using the literal 4 instead of sizeof(float) means the guard and the copy are not logically linked. A future port, a different toolchain flag, or a code review that changes one without the other can silently break the invariant.

Problem 2 — The inner redundant check on MR60_TYPE_DISTANCE was misleading.
The distance handler had an outer guard (len >= 8) and then an inner guard (range_flag != 0 && len >= 8). The inner re-check of len >= 8 created the false impression that length was being validated carefully, while the outer check used a magic number that could drift out of sync with the actual memcpy offset.

Problem 3 — The data offset &data[4] was a magic number.
Inside the distance case, the second memcpy read from &data[4] — the byte immediately after the uint32_t. If someone changed the uint32_t to a uint64_t during a protocol revision, the offset 4 would silently become wrong, causing a misaligned or out-of-bounds read.

How Could This Be Exploited?

An attacker with the ability to send crafted UART frames to the ESP32 — for example, through a compromised sensor, a man-in-the-middle on the UART bus, or direct physical access — could:

  1. Send a frame with a type field of MR60_TYPE_BREATHING, MR60_TYPE_HEARTRATE, or MR60_TYPE_DISTANCE.
  2. Set the len field to a value that passes the guard (e.g., len = 3 for breathing if the guard were >= 3, or craft a frame where the buffer ends before the full payload).
  3. Force memcpy to read 1–8 bytes past the end of the received data buffer.
  4. The bytes read are adjacent stack or heap memory, which get written into local variables used in subsequent logic.
  5. With careful frame crafting, the attacker can control the values written, corrupt the stack frame, and redirect execution.

Real-world impact: Arbitrary code execution on the ESP32. An attacker could disable safety-critical sensor readings, exfiltrate data over Wi-Fi, pivot to other devices on the network, or brick the device entirely.


The Fix

The fix is small, surgical, and elegant. Every change replaces a magic number with a sizeof() expression, making the length guard structurally coupled to the type being copied.

MR60_TYPE_BREATHING and MR60_TYPE_HEARTRATE

Before:

if (len >= 4) {
    float breathing_rate;
    memcpy(&breathing_rate, data, sizeof(float));
    // use breathing_rate ...
}

After:

if (len >= sizeof(float)) {
    float breathing_rate;
    memcpy(&breathing_rate, data, sizeof(float));
    // use breathing_rate ...
}

The change is one token, but the security improvement is significant. Now the guard condition and the copy size are expressed using the same type. If float ever changes size (unlikely, but possible in cross-compilation scenarios), both the check and the copy update together automatically. A static analyzer or code reviewer can immediately see that the guard is sufficient.

MR60_TYPE_DISTANCE

This case had three related changes:

Before:

if (len >= 8) {
    uint32_t range_flag;
    memcpy(&range_flag, data, sizeof(uint32_t));

    if (range_flag != 0 && len >= 8) {  // redundant len check
        float distance;
        memcpy(&distance, &data[4], sizeof(float));  // magic offset
        // use distance ...
    }
}

After:

if (len >= sizeof(uint32_t) + sizeof(float)) {
    uint32_t range_flag;
    memcpy(&range_flag, data, sizeof(uint32_t));

    if (range_flag != 0) {  // len already validated above
        float distance;
        memcpy(&distance, &data[sizeof(uint32_t)], sizeof(float));  // computed offset
        // use distance ...
    }
}

Three improvements in one block:

  1. Outer guard uses sizeof(uint32_t) + sizeof(float) — the exact number of bytes that will be read. No magic numbers, no drift risk.
  2. Redundant inner len >= 8 check removed — the outer guard already guarantees sufficient length. Keeping the inner check was noise that could mislead future readers into thinking the inner block had weaker guarantees.
  3. Offset &data[4] replaced with &data[sizeof(uint32_t)] — the offset is now computed from the type, not hardcoded. If the protocol ever changes range_flag to a different type, the offset updates automatically.

Prevention & Best Practices

This vulnerability is a textbook example of a class of bugs that appears constantly in embedded and systems code. Here is how to prevent it systematically.

1. Always Couple Length Guards to sizeof() Expressions

Never write:

if (len >= 4) { memcpy(&my_float, buf, sizeof(float)); }

Always write:

if (len >= sizeof(float)) { memcpy(&my_float, buf, sizeof(float)); }

If the guard and the copy use the same sizeof() expression, they cannot drift apart.

2. Use a Safe Wrapper for Typed Copies from Buffers

Consider a helper macro or inline function:

/**
 * Safely copy sizeof(T) bytes from buf+offset into *dst.
 * Returns false if buf_len is insufficient.
 */
#define SAFE_COPY_TYPE(dst, buf, offset, buf_len)          \
    ((offset) + sizeof(*(dst)) <= (buf_len)                \
        ? (memcpy((dst), (buf) + (offset), sizeof(*(dst))), true) \
        : false)

Usage:

float breathing_rate;
if (!SAFE_COPY_TYPE(&breathing_rate, data, 0, len)) {
    ESP_LOGE(TAG, "Frame too short for breathing rate");
    return;
}

This pattern makes it impossible to forget the length check.

3. Validate at the Frame Boundary, Not Inside Each Case

For protocol parsers, consider validating the minimum expected payload length for each frame type as soon as the type field is parsed, before entering the type-specific handler:

static const size_t MR60_MIN_LEN[] = {
    [MR60_TYPE_BREATHING] = sizeof(float),
    [MR60_TYPE_HEARTRATE] = sizeof(float),
    [MR60_TYPE_DISTANCE]  = sizeof(uint32_t) + sizeof(float),
};

if (type < ARRAY_SIZE(MR60_MIN_LEN) && len < MR60_MIN_LEN[type]) {
    ESP_LOGE(TAG, "Frame type %d too short: got %zu, need %zu",
             type, len, MR60_MIN_LEN[type]);
    return;
}

4. Enable Compiler and Static Analysis Warnings

Tool What It Catches
gcc -Wall -Wextra Some obvious size mismatches
clang --analyze More sophisticated buffer analysis
Coverity / CodeChecker Production-grade static analysis for embedded
PC-lint / MISRA-C Strict embedded coding standard checks
AddressSanitizer (on host) Runtime detection during unit tests

For ESP32 specifically, you can run unit tests on your host machine with AddressSanitizer enabled to catch exactly this class of bug before it ever reaches hardware.

5. Fuzz Your Protocol Parsers

Tools like libFuzzer or AFL++ can be compiled against your parsing functions on a host machine. Feed them random and malformed frames. They will find length-check bugs far faster than manual review.

# Example: compile parser with libFuzzer
clang -fsanitize=address,fuzzer -o fuzz_mr60 fuzz_mr60_harness.c mmwave_sensor.c
./fuzz_mr60 corpus/

6. Relevant Standards and References

  • CWE-125: Out-of-bounds Read
  • CWE-119: Improper Restriction of Operations within the Bounds of a Memory Buffer
  • OWASP Embedded Application Securityhttps://owasp.org/www-project-embedded-application-security/
  • MISRA-C:2012 Rule 1.3: Undefined behavior shall not occur (reading past buffer end is UB)
  • SEI CERT C Coding Standard — ARR38-C: Guarantee that library functions do not form invalid pointers

Conclusion

This vulnerability is a reminder that the most dangerous bugs are often the ones that look almost correct. The original guards (len >= 4, len >= 8) were clearly written with the right intent — the developer knew a length check was needed. The problem was using magic numbers that were not structurally tied to the types being copied. One protocol revision, one compiler change, or one copy-paste error could silently break the invariant.

The fix is minimal but meaningful:

  • Replace magic numbers with sizeof() expressions so guards and copies are always in sync.
  • Remove redundant checks that create false confidence without adding real protection.
  • Use computed offsets (&data[sizeof(uint32_t)]) instead of hardcoded ones (&data[4]) so the code remains correct through future changes.

On an ESP32 driving a safety sensor, arbitrary code execution is not a theoretical risk — it is a real threat to the physical systems these devices monitor. Taking five minutes to write sizeof(float) instead of 4 is one of the highest-leverage security improvements you can make in embedded code.

Write code that makes the correct thing obvious and the incorrect thing impossible.


This post is part of Fenny Security's ongoing series on automated vulnerability detection and remediation in embedded and IoT firmware.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #414

Related Articles

critical

Heap Buffer Overflow in OPDS Parser: How a Misplaced Variable Nearly Opened the Door to Remote Code Execution

A critical heap buffer overflow vulnerability was discovered in `lib/OpdsParser/OpdsParser.cpp`, where the buffer allocation size was calculated *after* a fixed chunk size was used to allocate memory, meaning the actual bytes read could exceed the allocated buffer. On embedded devices parsing untrusted OPDS catalog data from the network, this flaw could allow a remote attacker to corrupt heap memory and potentially achieve arbitrary code execution. The fix was elegantly simple: move the `toRead`

medium

Integer Overflow in Shared Memory Bounds Check: How a Missing Cast Opened the Door to Arbitrary Memory Writes

A subtle but dangerous integer overflow vulnerability was discovered in `lib/rpmi_shmem.c`, where bounds checks on shared memory operations could be silently bypassed due to 32-bit arithmetic overflow. By carefully crafting `offset` and `len` values, an OS-level or hypervisor-level caller could direct firmware writes to arbitrary memory addresses — including interrupt vector tables and security-critical configuration structures. The fix was elegantly simple: casting operands to 64-bit before add

high

GPIO Bounds Checking: Fixing an Out-of-Bounds Access in py32ioexp Driver

A high-severity out-of-bounds access vulnerability was discovered and patched in the `py32ioexp` Linux GPIO expander driver. The `py32io_gpio_direction_input()` function failed to validate a user-supplied pin offset against the chip's declared GPIO count, opening the door to memory corruption via the GPIO character device interface. A two-line bounds check now closes the vulnerability cleanly and efficiently.

critical

Critical Buffer Overflow in RC Device Parser: How One Missing Bounds Check Opens the Door to Memory Corruption

A critical buffer overflow vulnerability was discovered in the RC device request parser (`rcdevice.c`), where incoming packet data was written to a fixed-size buffer using an attacker-controlled length field as the only guard. Because the expected data length was parsed directly from the packet without being validated against the actual allocated buffer size, a malicious packet could overflow the buffer and overwrite adjacent stack or heap memory with arbitrary bytes. The fix adds a single, esse

high

Buffer Overflow in RS-232 Serial Input: How a Missing Length Check Put Embedded Systems at Risk

A critical buffer overflow vulnerability was discovered in `serial.c`, where the `rs232_buffered_input` function could write more bytes than the destination buffer `rs232_ibuff` could hold — with no size limit to stop it. An attacker with access to the RS-232 serial port could exploit this to overwrite adjacent OS memory, including return addresses and critical data structures. The fix adds a simple but essential bounds check that clamps the returned byte count to the actual buffer size.

critical

Stack Buffer Overflow in FTM File Parser: How strcpy() Almost Enabled Arbitrary Code Execution on ESP32

A critical stack buffer overflow vulnerability was discovered in `ftm_file.cpp`, where unchecked `strcpy()` calls allowed attacker-controlled filenames from crafted FTM files to overwrite stack memory, including the saved return address, enabling arbitrary code execution on ESP32 devices. The fix replaces both dangerous `strcpy()` calls with bounds-checked `strncpy()` plus explicit null-termination, eliminating the overflow vector entirely. This is a textbook reminder that unsafe C string functi