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
•Technically reviewed by Anupam Mediratta•Published May 28, 2026•Reviewed 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

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.

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.

Prevention and further reading

Related Articles

critical

How buffer overflow happens in C ieee80211_input() and how to fix it

A critical buffer overflow vulnerability was discovered in `src/firmware/src/net/ieee80211.c` at line 1584, where the `ieee80211_input()` function processed raw 802.11 data frames without verifying that the incoming frame was large enough to contain a valid `ieee80211_frame` header. An attacker within wireless range could craft undersized or malformed frames to trigger memory corruption, potentially leading to remote code execution on the firmware. The fix adds a single, targeted bounds check th

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

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`