feat(kasan): add Kernel Address Sanitizer (KASAN) support for ESP-IDF

Add KASAN support for detecting heap memory safety bugs (buffer
overflows, underflows, use-after-free) at runtime using compiler
instrumentation and shadow memory. Gated behind
CONFIG_IDF_EXPERIMENTAL_FEATURES, with touch points kept to esp_system
and heap so other components stay untouched.

- Core runtime (esp_system/kasan.c, esp_kasan.h): nibble-based shadow
  memory in DRAM, poison/unpoison, per-access validation, and __asan_*
  stubs; hot-path stubs in IRAM so they stay valid with the flash cache
  off. Shadow init runs before heap bring-up.
- Heap integration (heap/heap_kasan*.c): alloc/free hooks add redzones,
  a quarantine FIFO, and shadow updates.
- Panic handling: disable checks once at the panic handler entry so
  backtrace and stack dumps can read redzones without nested reports.
- Build system: -fsanitize=kernel-address for app code, with HAL, SoC,
  esp_rom, SPI flash, esp_hw_support, bootloader_support, FreeRTOS, and
  heap internals excluded from instrumentation.
- Test app (tools/test_apps/system/kasan_test): Unity tests for
  overflow, underflow, use-after-free, and all sized __asan_* stubs,
  with halt and no-halt configurations.
- Docs: document KASAN in the heap memory debugging guide (EN and CN).
This commit is contained in:
Meet Patel
2026-06-24 11:27:00 +05:30
parent 6fc0a63c4e
commit 383e9adb82
25 changed files with 1856 additions and 49 deletions
@@ -105,6 +105,12 @@ tools/test_apps/system/init_array:
depends_filepatterns:
- tools/tools.json
tools/test_apps/system/kasan_test:
depends_components:
- *common_components
- esp_system
- heap
tools/test_apps/system/log:
disable_test:
- if: IDF_TARGET not in ["esp32", "esp32c3"]
@@ -0,0 +1,4 @@
cmake_minimum_required(VERSION 3.22)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(kasan_test)
@@ -0,0 +1,72 @@
| Supported Targets | ESP32 | ESP32-C2 | ESP32-C3 | ESP32-C5 | ESP32-C6 | ESP32-C61 | ESP32-H2 | ESP32-H21 | ESP32-H4 | ESP32-P4 | ESP32-S2 | ESP32-S3 | ESP32-S31 |
| ----------------- | ----- | -------- | -------- | -------- | -------- | --------- | -------- | --------- | -------- | -------- | -------- | -------- | --------- |
# KASAN Test Application
This test application validates the Kernel Address Sanitizer (KASAN) integration
in ESP-IDF by deliberately triggering memory safety bugs and checking that KASAN
detects and reports them before calling the panic handler.
## Test Cases
| Test | Description | Expected Outcome |
|------|-------------|-----------------|
| `overflow` | 1-byte write past end of 16-byte heap allocation | KASAN WRITE error + panic |
| `use_after_free` | Write to freed heap block | KASAN WRITE error + panic |
| `uaf_read` | Read from freed heap block | KASAN READ error + panic |
| `underflow` | Write before start of allocation (into left redzone) | KASAN WRITE error + panic |
| `large_overflow` | `memset` of 16 bytes into an 8-byte buffer | KASAN WRITE error + panic |
| `no_bug` | Clean alloc/use/free cycle | Completes without panic |
| `asan stubs valid access no error` | Direct calls to every sized `__asan_load<N>_noabort` and `__asan_store<N>_noabort` stub (N in {1, 2, 4, 8, 16, N}) on a valid buffer | Completes without panic; covers all 12 stubs |
| `asan stubs poisoned access all sizes` (no_halt only) | Same 12 stubs called on a freed pointer | Exactly 12 KASAN errors reported |
## Building
```bash
cd tools/test_apps/system/kasan_test
idf.py set-target esp32c6
idf.py build
```
Or to select a specific test case:
```bash
idf.py -DSDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.ci.overflow" build
```
## Prerequisites
The following Kconfig options must be set (they are pre-configured in
`sdkconfig.defaults`):
- `CONFIG_IDF_EXPERIMENTAL_FEATURES=y` – Required to expose KASAN in menuconfig
- `CONFIG_COMPILER_KASAN=y` – Enable KASAN instrumentation
- `CONFIG_ESP_TASK_WDT_EN=n` – Unity menu blocks IDLE until you press Enter or
select a test; required for manual `idf.py monitor` as well as pytest
`CONFIG_COMPILER_KASAN` automatically selects `CONFIG_HEAP_USE_HOOKS`. Redzone
size (8 bytes), quarantine size (8192 bytes), and heap poisoning (disabled) use
their Kconfig defaults — no extra overrides are needed.
The `sdkconfig.ci.*` files add only mode-specific options (e.g. `CONFIG_KASAN_NO_HALT`
for the all-in-one run, `CONFIG_ESP_SYSTEM_PANIC_PRINT_HALT` for halt-mode pytest).
## Running pytest
```bash
pytest pytest_kasan.py --target esp32c6 -v
```
## Memory and Performance Impact
| Target | Shadow Memory | Code Size Overhead | Free Heap Impact |
|--------|--------------|-------------------|-----------------|
| ESP32 | ~42 KiB (internal SRAM) | ~1.5-3x instrumented components | ~14% of free heap |
| ESP32-S3 | ~60 KiB | ~1.5-3x | ~16% of free heap |
| ESP32-C3 | ~54 KiB | ~1.5-3x | ~16% of free heap |
| ESP32-C6 | ~64 KiB | ~1.5-3x | ~14% of free heap |
The shadow array is placed in DRAM via the `DRAM_ATTR` attribute on
`kasan_shadow_mem` (mapped into `dram0_data` by the standard `.dram1` linker
mapping). On targets with PSRAM the shadow currently stays in internal SRAM;
placing it in external RAM is not yet supported.
@@ -0,0 +1,3 @@
idf_component_register(SRCS "kasan_test_main.c"
INCLUDE_DIRS "."
PRIV_REQUIRES esp_system heap unity)
@@ -0,0 +1,250 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
/*
* Unity-based KASAN test application.
*
* With CONFIG_KASAN_NO_HALT: all tests run in one boot cycle; each test
* triggers a bug and asserts that kasan_get_error_count() increased.
*
* Without CONFIG_KASAN_NO_HALT (default): select individual tests via the
* Unity menu. Each error test causes an abort; pytest verifies the panic.
*/
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include "sdkconfig.h"
#include "unity.h"
#include "esp_log.h"
#include "esp_kasan.h"
static const char *TAG = "kasan_test";
/* ---------------------------------------------------------------------- */
TEST_CASE("heap buffer overflow", "[kasan]")
{
#if CONFIG_KASAN_NO_HALT
kasan_reset_error_count();
#endif
char *buf = (char *)malloc(16);
TEST_ASSERT_NOT_NULL(buf);
memset(buf, 'A', 16);
buf[16] = 'X'; /* write into right redzone */
#if CONFIG_KASAN_NO_HALT
TEST_ASSERT_GREATER_THAN(0, kasan_get_error_count());
#endif
free(buf);
}
/* ---------------------------------------------------------------------- */
TEST_CASE("use-after-free write", "[kasan]")
{
#if CONFIG_KASAN_NO_HALT
kasan_reset_error_count();
#endif
char *buf = (char *)malloc(32);
TEST_ASSERT_NOT_NULL(buf);
memset(buf, 0, 32);
free(buf);
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wuse-after-free"
buf[4] = 'Y';
#pragma GCC diagnostic pop
#if CONFIG_KASAN_NO_HALT
TEST_ASSERT_GREATER_THAN(0, kasan_get_error_count());
#endif
}
/* ---------------------------------------------------------------------- */
TEST_CASE("use-after-free read", "[kasan]")
{
#if CONFIG_KASAN_NO_HALT
kasan_reset_error_count();
#endif
int *buf = (int *)malloc(4 * sizeof(int));
TEST_ASSERT_NOT_NULL(buf);
buf[0] = 42;
free(buf);
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wuse-after-free"
volatile int val = buf[0];
(void)val;
#pragma GCC diagnostic pop
#if CONFIG_KASAN_NO_HALT
TEST_ASSERT_GREATER_THAN(0, kasan_get_error_count());
#endif
}
/* ---------------------------------------------------------------------- */
TEST_CASE("heap buffer underflow", "[kasan]")
{
#if CONFIG_KASAN_NO_HALT
kasan_reset_error_count();
#endif
char *buf = (char *)malloc(16);
TEST_ASSERT_NOT_NULL(buf);
buf[-1] = 'Z'; /* write into left redzone */
#if CONFIG_KASAN_NO_HALT
TEST_ASSERT_GREATER_THAN(0, kasan_get_error_count());
/* The underflow write corrupted the redzone header; skip free to avoid
* side-effects from a bad header read. Small intentional leak. */
#else
free(buf);
#endif
}
/* ---------------------------------------------------------------------- */
TEST_CASE("large heap overflow", "[kasan]")
{
#if CONFIG_KASAN_NO_HALT
kasan_reset_error_count();
#endif
char *buf = (char *)malloc(8);
TEST_ASSERT_NOT_NULL(buf);
for (int i = 0; i < 8; i++) {
buf[i] = 0;
}
buf[8] = 'X'; /* overflow into right redzone */
#if CONFIG_KASAN_NO_HALT
TEST_ASSERT_GREATER_THAN(0, kasan_get_error_count());
#endif
free(buf);
}
/* ---------------------------------------------------------------------- */
TEST_CASE("no false positive", "[kasan]")
{
#if CONFIG_KASAN_NO_HALT
kasan_reset_error_count();
#endif
char *buf = (char *)malloc(32);
TEST_ASSERT_NOT_NULL(buf);
memset(buf, 'A', 32);
volatile char c = buf[31];
(void)c;
free(buf);
#if CONFIG_KASAN_NO_HALT
TEST_ASSERT_EQUAL_UINT32(0, kasan_get_error_count());
#endif
ESP_LOGI(TAG, "no-bug test PASSED");
}
/* ---------------------------------------------------------------------- */
/*
* GCC -fsanitize=kernel-address instrumentation calls a sized family of
* stubs: __asan_load<N>_noabort and __asan_store<N>_noabort for
* N in {1, 2, 4, 8, 16, N}. The two tests below exercise every stub
* directly so the runtime contract (link symbol present, valid access
* passes, poisoned access reports an error) is verified for each size.
*
* Calling the stubs directly is intentional: GCC's choice of which sized
* stub to emit depends on access size, alignment, and target ISA, so
* relying on instrumentation alone leaves gaps (this is exactly how the
* 16-byte variants were missed previously: the dedicated test code
* never tripped GCC into emitting `__asan_*16_noabort`).
*/
extern void __asan_load1_noabort(void *addr);
extern void __asan_load2_noabort(void *addr);
extern void __asan_load4_noabort(void *addr);
extern void __asan_load8_noabort(void *addr);
extern void __asan_load16_noabort(void *addr);
/*
* The size parameter for the variable-size ASAN check stubs is declared as
* `int` by GCC's builtin, so we have to match that type here even though
* the size in practice is non-negative. Using `size_t` would trigger
* -Werror=builtin-declaration-mismatch under newer GCC (15+).
*/
extern void __asan_loadN_noabort(void *addr, int size);
extern void __asan_store1_noabort(void *addr);
extern void __asan_store2_noabort(void *addr);
extern void __asan_store4_noabort(void *addr);
extern void __asan_store8_noabort(void *addr);
extern void __asan_store16_noabort(void *addr);
extern void __asan_storeN_noabort(void *addr, int size);
/* All 12 sized ASAN check stubs in one call set.
* Returns the number of stub calls made (always 12). */
static unsigned exercise_all_asan_stubs(void *p)
{
__asan_load1_noabort(p);
__asan_load2_noabort(p);
__asan_load4_noabort(p);
__asan_load8_noabort(p);
__asan_load16_noabort(p);
__asan_loadN_noabort(p, 7);
__asan_store1_noabort(p);
__asan_store2_noabort(p);
__asan_store4_noabort(p);
__asan_store8_noabort(p);
__asan_store16_noabort(p);
__asan_storeN_noabort(p, 7);
return 12;
}
TEST_CASE("asan stubs valid access no error", "[kasan]")
{
#if CONFIG_KASAN_NO_HALT
kasan_reset_error_count();
#endif
/* malloc(64) gives us 64 valid bytes; the largest stub reads 16 bytes
* starting at p, so p..p+63 is safely inside the allocation. */
char *buf = (char *)malloc(64);
TEST_ASSERT_NOT_NULL(buf);
memset(buf, 'A', 64);
unsigned calls = exercise_all_asan_stubs(buf);
TEST_ASSERT_EQUAL_UINT(12, calls);
#if CONFIG_KASAN_NO_HALT
/* Each stub touched only valid bytes; no error should have been raised. */
TEST_ASSERT_EQUAL_UINT32(0, kasan_get_error_count());
#endif
free(buf);
ESP_LOGI(TAG, "asan stubs valid-access test PASSED");
}
#if CONFIG_KASAN_NO_HALT
TEST_CASE("asan stubs poisoned access all sizes", "[kasan]")
{
kasan_reset_error_count();
/* A freed pointer sits in the quarantine FIFO with its full block
* shadow poisoned, so every stub size will trip the shadow check. */
char *buf = (char *)malloc(64);
TEST_ASSERT_NOT_NULL(buf);
memset(buf, 0, 64);
free(buf);
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wuse-after-free"
unsigned calls = exercise_all_asan_stubs(buf);
#pragma GCC diagnostic pop
TEST_ASSERT_EQUAL_UINT(12, calls);
/* Exactly one error per stub call: 6 sized loads + 6 sized stores. */
TEST_ASSERT_EQUAL_UINT32(12, kasan_get_error_count());
ESP_LOGI(TAG, "asan stubs poisoned-access test PASSED (12 errors)");
}
#endif /* CONFIG_KASAN_NO_HALT */
/* ---------------------------------------------------------------------- */
void app_main(void)
{
ESP_LOGI(TAG, "KASAN test application starting");
unity_run_menu();
}
@@ -0,0 +1,104 @@
# SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: Apache-2.0
"""
Pytest test cases for the KASAN Unity test application.
Two configurations:
- no_halt: all tests run in one boot cycle (CONFIG_KASAN_NO_HALT=y).
The Unity runner executes every test; each verifies the
KASAN error count.
- halt: each error test triggers an abort. pytest selects one test
at a time via the Unity menu, expecting a panic.
"""
import pytest
from pytest_embedded import Dut
from pytest_embedded_idf.utils import idf_parametrize
# ---------------------------------------------------------------------------
# no_halt configuration: all tests pass in one run
# ---------------------------------------------------------------------------
@pytest.mark.generic
@idf_parametrize('target', ['supported_targets', 'preview_targets'], indirect=['target'])
@pytest.mark.parametrize('config', ['no_halt'], indirect=True)
def test_kasan_no_halt_all(dut: Dut) -> None:
"""Run all KASAN tests in one boot cycle with CONFIG_KASAN_NO_HALT=y."""
dut.expect('KASAN test application starting', timeout=15)
# Send '*' to Unity menu to run all tests
dut.write('*')
dut.expect(r'\d+ Tests \d+ Failures \d+ Ignored', timeout=45)
# ---------------------------------------------------------------------------
# halt configuration: each error test causes an abort
# ---------------------------------------------------------------------------
def _run_halt_test(dut: Dut, test_name: str) -> None:
"""Select a test from Unity menu and expect KASAN abort."""
dut.expect('KASAN test application starting', timeout=15)
dut.write('"' + test_name + '"')
dut.expect(r'KASAN error: (WRITE|READ) of size \d+ at 0x', timeout=20)
@pytest.mark.generic
@pytest.mark.timeout(120)
@idf_parametrize('target', ['supported_targets', 'preview_targets'], indirect=['target'])
@pytest.mark.parametrize('config', ['halt'], indirect=True)
def test_kasan_halt_overflow(dut: Dut) -> None:
_run_halt_test(dut, 'heap buffer overflow')
@pytest.mark.generic
@pytest.mark.timeout(120)
@idf_parametrize('target', ['supported_targets', 'preview_targets'], indirect=['target'])
@pytest.mark.parametrize('config', ['halt'], indirect=True)
def test_kasan_halt_uaf_write(dut: Dut) -> None:
_run_halt_test(dut, 'use-after-free write')
@pytest.mark.generic
@pytest.mark.timeout(120)
@idf_parametrize('target', ['supported_targets', 'preview_targets'], indirect=['target'])
@pytest.mark.parametrize('config', ['halt'], indirect=True)
def test_kasan_halt_uaf_read(dut: Dut) -> None:
_run_halt_test(dut, 'use-after-free read')
@pytest.mark.generic
@pytest.mark.timeout(120)
@idf_parametrize('target', ['supported_targets', 'preview_targets'], indirect=['target'])
@pytest.mark.parametrize('config', ['halt'], indirect=True)
def test_kasan_halt_underflow(dut: Dut) -> None:
_run_halt_test(dut, 'heap buffer underflow')
@pytest.mark.generic
@pytest.mark.timeout(120)
@idf_parametrize('target', ['supported_targets', 'preview_targets'], indirect=['target'])
@pytest.mark.parametrize('config', ['halt'], indirect=True)
def test_kasan_halt_large_overflow(dut: Dut) -> None:
_run_halt_test(dut, 'large heap overflow')
@pytest.mark.generic
@idf_parametrize('target', ['supported_targets', 'preview_targets'], indirect=['target'])
@pytest.mark.parametrize('config', ['halt'], indirect=True)
def test_kasan_halt_no_false_positive(dut: Dut) -> None:
"""No-bug test should complete without KASAN error."""
dut.expect('KASAN test application starting', timeout=15)
dut.write('"no false positive"')
dut.expect('no-bug test PASSED', timeout=15)
@pytest.mark.generic
@idf_parametrize('target', ['supported_targets', 'preview_targets'], indirect=['target'])
@pytest.mark.parametrize('config', ['halt'], indirect=True)
def test_kasan_halt_asan_stubs_valid_access(dut: Dut) -> None:
"""Direct calls to every sized __asan_*_noabort stub on a valid buffer
must link and run without raising an error."""
dut.expect('KASAN test application starting', timeout=15)
dut.write('"asan stubs valid access no error"')
dut.expect('asan stubs valid-access test PASSED', timeout=15)
@@ -0,0 +1,2 @@
# Halt on panic so pytest can read the register dump (not KASAN-specific).
CONFIG_ESP_SYSTEM_PANIC_PRINT_HALT=y
@@ -0,0 +1,2 @@
# Run all tests in one boot cycle without aborting on errors
CONFIG_KASAN_NO_HALT=y
@@ -0,0 +1,6 @@
# Minimal KASAN enablement — everything else stays at Kconfig defaults
# (redzone=8, quarantine=8192, heap poisoning=disabled, HEAP_USE_HOOKS selected).
CONFIG_IDF_EXPERIMENTAL_FEATURES=y
CONFIG_COMPILER_KASAN=y
CONFIG_ESP_TASK_WDT_EN=n