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:
- Send a frame with a
typefield ofMR60_TYPE_BREATHING,MR60_TYPE_HEARTRATE, orMR60_TYPE_DISTANCE. - Set the
lenfield to a value that passes the guard (e.g.,len = 3for breathing if the guard were>= 3, or craft a frame where the buffer ends before the full payload). - Force
memcpyto read 1–8 bytes past the end of the received data buffer. - The bytes read are adjacent stack or heap memory, which get written into local variables used in subsequent logic.
- 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:
- Outer guard uses
sizeof(uint32_t) + sizeof(float)— the exact number of bytes that will be read. No magic numbers, no drift risk. - Redundant inner
len >= 8check 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. - Offset
&data[4]replaced with&data[sizeof(uint32_t)]— the offset is now computed from the type, not hardcoded. If the protocol ever changesrange_flagto 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 Security — https://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.