ESP32-C5: BLEDevice::init() writes 4 bytes past the end of a heap block and destroys the tail canary
Board
ESP32C5 Dev Module
Device Description
Three different ESP32-C5 boards, all of them fail the same way:
- ESP32-C5-DevKitC-1, silicon rev v1.2, ROM ESP-ROM:esp32c5-eco3-20250704, 8 MB flash
- Seeed XIAO ESP32C5, silicon rev v1.0, 8 MB flash (built with the generic ESP32C5 Dev Module board, since 3.3.0 has no XIAO entry)
- ESP32-C5-WROOM-1U module on a custom carrier board, silicon rev v1.0, 8 MB flash
Control board that does NOT fail: ESP32-C6FH4 (QFN32) rev v0.2, 4 MB flash, running the same sketch.
Hardware Configuration
Nothing needs to be attached. The bug shows up on a bare board with no wiring at all.
We also checked the other direction: it happens the same way on a board that has an SD card on SPI, a GPS on UART1 and an SPI display attached. So it is not caused by anything hanging off the GPIOs.
Version
v3.3.0
Type
Bug
IDE Name
arduino-cli 1.5.1 (commit 01f3d4f2b, 2026-06-05)
Operating System
Linux (CachyOS, kernel 7.1.3)
Flash frequency
80MHz
PSRAM enabled
no
Upload speed
921600
Description
On ESP32-C5, one call to BLEDevice::init() writes a 4 byte pointer three bytes past the end of a heap block. That write lands on the block's tail canary, which heap poisoning puts there. The same sketch on ESP32-C6 does not do this.
Expected: heap_caps_check_integrity_all(true) returns true both before and after BLEDevice::init(). What happens on C5: true before, false after, with "CORRUPT HEAP: Bad tail" printed in between.
Nothing crashes at that moment. The heap is just wrong from then on. The crash comes later, whenever something frees that block or merges it with a neighbor:
assert failed: multi_heap_free multi_heap_poisoning.c:279 (head != NULL)
BLEDevice::deinit(false) triggers it every single time, so on this chip we cannot shut BLE down at all. On a battery powered logger a crash is a reboot and a reboot means lost data.
The tail canary is 0xbaad5678. Stored little endian, that is the bytes 78 56 ad ba. What we read back is 0xba40856e, which is the bytes 6e 85 40 ba. The last byte is untouched. The first three have been replaced by 6e 85 40.
Now take a 4 byte pointer of the form 0x40856eNN and write it starting one byte BEFORE the canary. Its bytes 1, 2 and 3 land on canary bytes 0, 1 and 2, and canary byte 3 is left alone. That is exactly the pattern we see.
So: an unaligned 4 byte pointer store, three bytes past the end of an allocation.
The same math works on the other boards. On a second board the block was at 0x40846ebc and we read back 0xba40846e. In every case the value written is a pointer that points somewhere very near the damaged block itself. One run wrote 0xba000000 instead, which is the same store writing a null pointer.
REPEATABILITY:
Completely repeatable. Every boot, on all three C5 boards, at the same block address for a given build. Never once on the C6.
No special build settings are needed. The core already ships with CONFIG_HEAP_POISONING_LIGHT=y for both esp32c5 and esp32c6, which is what makes the canary exist and what makes the check able to see it. If poisoning were off, the bad write would still happen, it would just go unnoticed until it wrecked something that mattered.
Newer Core Versions do not fix this:
We tried newer cores, each one a stock install. On 3.3.6, nimble_port_init() returns failure on every boot, so BLE never starts at all. On 3.3.11, the heap stays clean but the board hits a hard Load access fault inside r_ble_controller_init on every boot. The heap staying clean only means BLE never got far enough to damage anything.
FOCUS:
BLEDevice::init() is where a reproduction starts, but the actual bad write is probably below the Arduino layer, in the NimBLE port or in IDF. I am filing here because this is the reachable entry point. Please move it if that is wrong.
Sketch
#include <BLEDevice.h>
#include <esp_heap_caps.h>
void setup() {
Serial.begin(115200);
delay(2000);
Serial.printf("before: %s\n",
heap_caps_check_integrity_all(true) ? "clean" : "CORRUPT");
BLEDevice::init("");
Serial.printf("after: %s\n",
heap_caps_check_integrity_all(true) ? "clean" : "CORRUPT");
}
void loop() {}Debug Message
On ESP32-C5 (every boot, all three boards):
before: clean
CORRUPT HEAP: Bad tail at 0x40856ee8. Expected 0xbaad5678 got 0xba40856e
after: CORRUPT
On ESP32-C6, same sketch, same core:
before: clean
after: clean
Other values we have seen on C5. Note the pattern is always the same: the last
byte survives, the first three are replaced by the low three bytes of a pointer
that is close to the damaged block.
WROOM-1U board Expected 0xbaad5678 got 0xba408505 / got 0xba000000
DevKitC-1 v1.2 Expected 0xbaad5678 got 0xba408443 / got 0xba4084eb / got 0xba408407
XIAO C5 v1.0 Expected 0xbaad5678 got 0xba40846b
When something later frees the damaged block, it aborts like this:
CORRUPT HEAP: Bad tail at 0x4085ad7c. Expected 0xbaad5678 got 0xba000000
assert failed: multi_heap_free multi_heap_poisoning.c:279 (head != NULL)Other Steps to Reproduce
The sketch above only prints a warning. If you want it to fail in a way you cannot miss, add one big allocation at the top of setup(), before BLE init, and hold on to it:
void *hold = heap_caps_malloc(43008, MALLOC_CAP_INTERNAL);
That shifts the heap layout enough that the damaged block ends up next to something that gets freed, and the board goes into a boot loop with the multi_heap_free assert. In our own firmware that single allocation was the only difference between a build that booted and the build right before it that did not, with zero changes to any BLE code.
BOARD MATRIX
One firmware build, five configurations. "boot check" is heap_caps_check_integrity_all() called right after BLE init.
| Board | Chip | Rev | PSRAM | Largest free internal block | Boot check |
|-----------------------|----------|-----------|-------|-----------------------------|------------|
| WROOM-1U carrier | ESP32-C5 | v1.0 | on | ~15,348 | CORRUPT |
| ESP32-C5-DevKitC-1 | ESP32-C5 | v1.2 ECO3 | off | 69,620 | CORRUPT |
| ESP32-C5-DevKitC-1 | ESP32-C5 | v1.2 ECO3 | on | 86,004 | CORRUPT |
| Seeed XIAO ESP32C5 | ESP32-C5 | v1.0 | off | 59,380 | CORRUPT |
| Seeed XIAO ESP32C6 | ESP32-C6 | v0.2 | off | 208,884 | clean |Three physically different C5 boards, two silicon revisions, PSRAM both ways: all corrupt, every boot. The C6 ran the identical firmware with BLE just as busy (490 advertisements captured in the same window) and stayed clean.
So it is not one bad board, it is not old silicon, and it is not PSRAM. ECO3 does not fix it.
I have checked existing issues, online documentation and the Troubleshooting Guide
- I confirm I have checked existing issues, online documentation and Troubleshooting guide.
Source: espressif/arduino-esp32