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 Orbis AppSec
Published May 28, 2026Reviewed June 3, 2026

Answer Summary

CVE-2026-34601 is a high-severity out-of-bounds read/write vulnerability (CWE-125 / CWE-787) in ESP32 C firmware. The mmWave UART driver called `memcpy` into fixed-size local stack variables without verifying the incoming frame was at least as large as the destination buffer, allowing a crafted short UART frame to corrupt the stack and potentially enable arbitrary code execution. The fix adds `sizeof()`-based length guards before every `memcpy` call, ensuring the source buffer is always large enough before any copy proceeds.

Vulnerability at a Glance

cweCWE-125 (Out-of-bounds Read) / CWE-787 (Out-of-bounds Write)
fixAdd sizeof()-based frame-length checks before every memcpy in the mmWave UART driver
riskArbitrary code execution, device takeover, denial of service
languageC (ESP32 firmware / ESP-IDF)
root causememcpy into fixed-size local buffers without verifying source frame length ≥ sizeof(destination)
vulnerabilityOut-of-bounds read + stack/heap corruption via unchecked memcpy

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.

Frequently Asked Questions

What is stack corruption via unchecked memcpy?

It occurs when a program copies data into a fixed-size stack buffer using memcpy without first confirming the source is at least as large as the destination, allowing an attacker to overwrite adjacent stack memory including return addresses and saved registers.

How do you prevent unchecked memcpy in C firmware?

Always guard every memcpy with an explicit length check (e.g., `if (frame_len < sizeof(dest)) return ERROR;`) and prefer sizeof()-based comparisons over magic-number constants so the check automatically stays in sync with the buffer size.

What CWE is stack corruption via unchecked memcpy?

The primary CWEs are CWE-787 (Out-of-bounds Write) for the stack/heap corruption and CWE-125 (Out-of-bounds Read) for reading beyond the source buffer boundary, often combined with CWE-120 (Buffer Copy without Checking Size of Input).

Is input validation at the UART receive layer enough to prevent this?

No. While validating frame headers at the receive layer reduces risk, it is not sufficient on its own. Each memcpy call site must independently verify the available source bytes against sizeof(destination) because frame length can be corrupted or truncated at any point in the pipeline.

Can static analysis detect unchecked memcpy in C firmware?

Yes. Tools like Semgrep, CodeQL, Coverity, and PC-lint can flag memcpy calls where the length argument is not guarded by a preceding size check. Orbis AppSec detected this exact pattern automatically and opened a remediation pull request.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #414

Related Articles

critical

How buffer overflow happens in C display_controller.cpp and how to fix it

A critical buffer overflow vulnerability was discovered in `playground/GpsBasics/display_controller.cpp` where `sprintf` was used without bounds checking on fixed-size stack buffers. An attacker supplying malicious GPS data with extreme field values (such as a year value of `99999`) could produce a formatted string longer than the declared buffer, leading to stack corruption and potential code execution. The fix introduces proper buffer-length enforcement, ensuring formatted GPS strings can neve

critical

How plaintext credential storage happens in C embedded firmware (wifi.cpp) and how to fix it

A migration function in `main/wifi.cpp` was designed to move legacy WiFi credentials from plaintext SD card files into encrypted NVS storage, but a logic flaw meant that if the NVS write failed, the plaintext files were never deleted. This left SSID and password data recoverable from the SD card filesystem — even after "deletion" on FAT — by anyone with physical access to the device. The fix restructures the deletion logic so plaintext files are always wiped, regardless of whether the NVS migrat

critical

How buffer overflow happens in C TinyGSM sprintf and how to fix it

A critical buffer overflow vulnerability was discovered in `TinyGsmClientSequansMonarch.h` at line 515, where `sprintf` was writing a two-character hex string into a buffer only two bytes large — leaving no room for the null terminator. The fix replaces `sprintf` with `snprintf` and increases the buffer to three bytes, preventing a one-byte overflow that could corrupt adjacent memory in embedded firmware.

high

How buffer overflow via strcpy() happens in Nordic BLE C firmware and how to fix it

A high-severity buffer overflow vulnerability was discovered in the Nordic BLE Central Demo firmware, where unsafe `strcpy()` and `sprintf()` calls in the `BleDevDiscovered()` function could allow attackers to overflow stack buffers by sending specially crafted BLE service discovery responses. The fix replaced all unbounded string operations with size-checked `snprintf()` calls, preventing potential remote code execution in embedded Bluetooth devices.

high

NULL Pointer Dereference in ESP8266 user_interface.c wifi_station_set_default_hostname()

A critical NULL pointer dereference vulnerability in the ESP8266 firmware's `user_interface.c` allowed attackers to crash devices by exhausting the limited 80KB heap memory. The `wifi_station_set_default_hostname()` function's `os_malloc` call lacked a proper NULL guard, causing `ets_sprintf` to write to address 0 when allocation failed. The fix corrected a logic inversion in the NULL check condition.

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`