From 383e9adb82478d805a2744e64518b1c99fa76409 Mon Sep 17 00:00:00 2001 From: Meet Patel Date: Wed, 24 Jun 2026 11:13:09 +0530 Subject: [PATCH] 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). --- CMakeLists.txt | 66 +++ Kconfig | 78 +++ components/esp_system/CMakeLists.txt | 50 +- components/esp_system/include/esp_kasan.h | 91 ++++ components/esp_system/kasan.c | 512 ++++++++++++++++++ components/esp_system/port/cpu_start.c | 21 +- components/esp_system/port/panic_handler.c | 11 + components/esp_system/system_init_fn.txt | 4 + components/heap/CMakeLists.txt | 41 +- components/heap/heap_caps.c | 12 +- components/heap/heap_caps_base.c | 135 ++++- components/heap/heap_kasan.c | 288 ++++++++++ components/heap/heap_kasan_hooks.c | 48 ++ components/heap/heap_kasan_layout.h | 43 ++ docs/en/api-reference/system/heap_debug.rst | 28 +- .../zh_CN/api-reference/system/heap_debug.rst | 28 +- tools/test_apps/system/.build-test-rules.yml | 6 + .../system/kasan_test/CMakeLists.txt | 4 + tools/test_apps/system/kasan_test/README.md | 72 +++ .../system/kasan_test/main/CMakeLists.txt | 3 + .../system/kasan_test/main/kasan_test_main.c | 250 +++++++++ .../system/kasan_test/pytest_kasan.py | 104 ++++ .../system/kasan_test/sdkconfig.ci.halt | 2 + .../system/kasan_test/sdkconfig.ci.no_halt | 2 + .../system/kasan_test/sdkconfig.defaults | 6 + 25 files changed, 1856 insertions(+), 49 deletions(-) create mode 100644 components/esp_system/include/esp_kasan.h create mode 100644 components/esp_system/kasan.c create mode 100644 components/heap/heap_kasan.c create mode 100644 components/heap/heap_kasan_hooks.c create mode 100644 components/heap/heap_kasan_layout.h create mode 100644 tools/test_apps/system/kasan_test/CMakeLists.txt create mode 100644 tools/test_apps/system/kasan_test/README.md create mode 100644 tools/test_apps/system/kasan_test/main/CMakeLists.txt create mode 100644 tools/test_apps/system/kasan_test/main/kasan_test_main.c create mode 100644 tools/test_apps/system/kasan_test/pytest_kasan.py create mode 100644 tools/test_apps/system/kasan_test/sdkconfig.ci.halt create mode 100644 tools/test_apps/system/kasan_test/sdkconfig.ci.no_halt create mode 100644 tools/test_apps/system/kasan_test/sdkconfig.defaults diff --git a/CMakeLists.txt b/CMakeLists.txt index c87bc17b38c..512bae14a1c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -185,6 +185,20 @@ elseif(CONFIG_COMPILER_STACK_CHECK_MODE_ALL) list(APPEND compile_options "-fstack-protector-all") endif() +if(CONFIG_COMPILER_KASAN) + # Only instrument the app build; the bootloader runs before kasan_init_shadow() + # is called and does not have the KASAN runtime. + if(NOT BOOTLOADER_BUILD) + list(APPEND c_compile_options "-fsanitize=kernel-address") + list(APPEND cxx_compile_options "-fsanitize=kernel-address") + if(NOT CONFIG_KASAN_STACK) + list(APPEND c_compile_options "--param" "asan-stack=0") + list(APPEND cxx_compile_options "--param" "asan-stack=0") + endif() + list(APPEND link_options "-fsanitize=kernel-address") + endif() +endif() + if(CONFIG_COMPILER_DUMP_RTL_FILES) list(APPEND compile_options "-fdump-rtl-expand") endif() @@ -355,3 +369,55 @@ if(NOT bootloader_build AND NOT esp_tee_build) include("${CMAKE_CURRENT_LIST_DIR}/tools/cmake/component_validation.cmake") __component_validation_run_checks() endif() + +# KASAN: exclude low-level / hardware-access components from instrumentation. +# Apply the exclusion after add_subdirectory() so every target already exists. +# +# Rationale per bucket: +# - hal / soc / esp_rom + every esp_hal_* peripheral HAL: perform volatile +# MMIO accesses (outside the shadow window, so each check is a no-op but +# still pays the indirect call into __asan_load* / __asan_store*). +# - spi_flash: runs with the flash cache disabled. +# - esp_hw_support: RTC/PMU register access, runs with MSPI bus held. +# - bootloader_support: runs before kasan_init_shadow(). +# - freertos: scheduler / ISR plumbing is too hot to instrument. +# - heap: walks its own TLSF metadata living inside the (poisoned) pool; +# instrumented metadata walks would self-trigger. Shadow updates are +# handled explicitly via heap_kasan.c / heap_kasan_hooks.c, which are +# individually compiled with -fno-sanitize via set_source_files_properties. +if(CONFIG_COMPILER_KASAN AND NOT BOOTLOADER_BUILD) + # Match every esp_hal_* peripheral HAL component dynamically (instead of + # listing them one by one) so newly added HAL components stay excluded + # without revisiting this file. + idf_build_get_property(__kasan_all_components BUILD_COMPONENTS) + set(__kasan_excluded_components "") + foreach(__kasan_c ${__kasan_all_components}) + if(__kasan_c MATCHES "^esp_hal_") + list(APPEND __kasan_excluded_components ${__kasan_c}) + endif() + endforeach() + + list(APPEND __kasan_excluded_components + hal soc esp_rom + spi_flash + esp_hw_support + bootloader_support + freertos + heap + ) + + foreach(__kasan_comp ${__kasan_excluded_components}) + set(__kasan_lib "__idf_${__kasan_comp}") + if(TARGET ${__kasan_lib}) + get_target_property(__kasan_type ${__kasan_lib} TYPE) + if(NOT __kasan_type STREQUAL "INTERFACE_LIBRARY") + # Only apply to real (non-INTERFACE) libraries that have source + # files. INTERFACE libraries have no sources so there is nothing + # to de-instrument, and adding an INTERFACE compile option would + # propagate -fno-sanitize to all downstream consumers (including + # the application under test). + target_compile_options(${__kasan_lib} PRIVATE "-fno-sanitize=kernel-address") + endif() + endif() + endforeach() +endif() diff --git a/Kconfig b/Kconfig index 0ccdeb5cc6e..46257d81faa 100644 --- a/Kconfig +++ b/Kconfig @@ -813,6 +813,83 @@ mainmenu "Espressif IoT Development Framework Configuration" Define _GLIBCXX23_CONSTEXPR=__attribute__((cold)). endchoice + config COMPILER_KASAN + bool "Enable Kernel Address Sanitizer (KASAN)" + depends on IDF_EXPERIMENTAL_FEATURES && IDF_TOOLCHAIN_GCC && !IDF_TARGET_LINUX + select HEAP_USE_HOOKS + default n + help + Enables Kernel Address Sanitizer instrumentation (-fsanitize=kernel-address). + GCC instruments every memory load and store with calls to __asan_load_noabort / + __asan_store_noabort. These stubs check a shadow memory region and panic if + poisoned (freed / out-of-bounds) memory is accessed. + + KASAN is useful for detecting: + - Heap buffer overflows and underflows (with redzones) + - Use-after-free bugs (when heap hooks are enabled) + - Out-of-bounds accesses in global and stack variables (optional) + + Enabling this option increases code size by 1.5-3x for instrumented components + and reserves shadow memory in DRAM (~42-64 KiB depending on target). + + NOT compatible with bootloader builds or ROM code. Components in the + hal, soc, esp_rom, and bootloader_support families are automatically + excluded from instrumentation. + + menu "Kernel Address Sanitizer (KASAN)" + depends on COMPILER_KASAN + + config KASAN_STACK + bool "Instrument stack variables (higher overhead)" + depends on COMPILER_KASAN + default n + help + Pass --param asan-stack=1 to enable KASAN instrumentation of + stack (local) variables. This detects stack buffer overflows but + significantly increases stack usage for every instrumented function. + Leave disabled unless you specifically need stack-overflow detection. + + config KASAN_HEAP_REDZONE_SIZE + int "Heap allocation redzone size in bytes (0 to disable)" + depends on COMPILER_KASAN + default 8 + range 0 64 + help + Number of bytes of poisoned redzone added on each side of every heap + allocation. Redzones catch heap buffer overflows and underflows. + This value must be a multiple of 4; the build enforces that with + a static assertion in the heap KASAN runtime. Set to 0 to disable + redzones (reduces per-allocation overhead at the cost of reduced + detection coverage). + + config KASAN_QUARANTINE_SIZE + int "Freed-block quarantine queue size in bytes (0 to disable)" + depends on COMPILER_KASAN + default 8192 + range 0 65536 + help + When non-zero, freed heap blocks are held in a FIFO quarantine for this + many bytes total before being returned to the allocator. Quarantined + blocks remain poisoned, allowing KASAN to catch use-after-free accesses + for some time after the block is freed. Increases peak memory usage by + the configured amount. Set to 0 to disable (frees memory immediately). + + config KASAN_NO_HALT + bool "Continue execution after KASAN error (no abort)" + depends on COMPILER_KASAN + default n + help + When enabled, KASAN prints the error report but does not call + esp_system_abort(). Execution continues after each violation. + + This is useful for test applications that need to verify multiple + KASAN detections in a single boot cycle. + + Not recommended for production or debugging real bugs, as continued + execution after a memory safety violation may cause undefined behaviour. + + endmenu # Kernel Address Sanitizer (KASAN) + endmenu # Compiler Options menu "Component config" @@ -855,3 +932,4 @@ mainmenu "Espressif IoT Development Framework Configuration" - CONFIG_ESP_WIFI_NAN_SECURITY - CONFIG_USB_HOST_EXT_PORT_RESET_ATTEMPTS - CONFIG_GDMA_ENABLE_WEIGHTED_ARBITRATION + - CONFIG_COMPILER_KASAN diff --git a/components/esp_system/CMakeLists.txt b/components/esp_system/CMakeLists.txt index 3e7be90fdeb..c458f1a0758 100644 --- a/components/esp_system/CMakeLists.txt +++ b/components/esp_system/CMakeLists.txt @@ -56,6 +56,10 @@ else() "system_time.c" "stack_check.c" "ubsan.c") + + if(CONFIG_COMPILER_KASAN) + list(APPEND srcs "kasan.c") + endif() if(CONFIG_SOC_WDT_SUPPORTED) list(APPEND srcs "int_wdt.c") endif() @@ -110,11 +114,30 @@ else() target_link_libraries(${COMPONENT_LIB} INTERFACE "-u start_app_other_cores") endif() - # Disable stack protection in files which are involved in initialization of that feature - set_source_files_properties( - "startup.c" "stack_check.c" "port/cpu_start.c" - PROPERTIES COMPILE_FLAGS - -fno-stack-protector) + if(CONFIG_COMPILER_KASAN) + # Files involved in stack-protector initialisation: disable stack protector. + # Also exclude from KASAN: cpu_start.c runs before kasan_init_shadow() and + # panic.c / startup.c must not recurse into KASAN during crash handling. + set_source_files_properties( + "startup.c" "stack_check.c" "port/cpu_start.c" + PROPERTIES COMPILE_FLAGS + "-fno-stack-protector -fno-sanitize=kernel-address") + + # kasan.c and ubsan.c implement sanitizer runtime stubs – must never be + # instrumented themselves (infinite recursion). + # panic.c / port/panic_handler.c must not be instrumented to avoid + # recursion in the error path. + set_source_files_properties( + "kasan.c" "ubsan.c" "panic.c" "port/panic_handler.c" + PROPERTIES COMPILE_FLAGS + "-fno-sanitize=kernel-address") + else() + # Disable stack protection in files which are involved in initialization of that feature + set_source_files_properties( + "startup.c" "stack_check.c" "port/cpu_start.c" + PROPERTIES COMPILE_FLAGS + -fno-stack-protector) + endif() target_linker_script(${COMPONENT_LIB} INTERFACE "ld/${target}/memory.ld.in") @@ -137,6 +160,23 @@ endif() # due to -ffunction-sections -Wl,--gc-sections options. target_link_libraries(${COMPONENT_LIB} INTERFACE "-u __ubsan_include") +# Force-link the __asan_*_noabort stubs: GCC-instrumented code calls them but +# the linker cannot see the call sites at GC time, so without explicit -u flags +# they would be silently dropped (and any -u flag against the file is enough +# to pull kasan.c in as well). +if(CONFIG_COMPILER_KASAN) + foreach(__kasan_stub + __asan_load1_noabort __asan_load2_noabort + __asan_load4_noabort __asan_load8_noabort + __asan_load16_noabort __asan_loadN_noabort + __asan_store1_noabort __asan_store2_noabort + __asan_store4_noabort __asan_store8_noabort + __asan_store16_noabort __asan_storeN_noabort + __asan_handle_no_return) + target_link_libraries(${COMPONENT_LIB} INTERFACE "-u ${__kasan_stub}") + endforeach() +endif() + target_link_libraries(${COMPONENT_LIB} INTERFACE "-u esp_system_include_startup_funcs") # [refactor-todo] requirements due to init code, should be removable diff --git a/components/esp_system/include/esp_kasan.h b/components/esp_system/include/esp_kasan.h new file mode 100644 index 00000000000..15396dccbac --- /dev/null +++ b/components/esp_system/include/esp_kasan.h @@ -0,0 +1,91 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#pragma once + +#include +#include +#include "sdkconfig.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief KASAN shadow nibble poison tags. + * + * Each 4-bit nibble in the shadow covers a 4-byte granule of real memory. + * Values 0x0-0x3 indicate valid (or partially valid) memory; 0xC-0xF + * indicate poisoned memory with the tag describing the reason. + */ +#define KASAN_POISON_HEAP_FREE ((uint8_t)0xF) /**< Freed heap region */ +#define KASAN_POISON_HEAP_LRZ ((uint8_t)0xE) /**< Heap left redzone (before alloc) */ +#define KASAN_POISON_HEAP_RRZ ((uint8_t)0xD) /**< Heap right redzone (after alloc) */ +#define KASAN_POISON_UNINIT ((uint8_t)0xC) /**< Never-allocated / uninitialised */ + +#if CONFIG_COMPILER_KASAN +/** + * @brief Initialise KASAN shadow memory. + * + * Must be called before heap_caps_init() so that the shadow region is ready + * when the first allocation hook fires. + */ +void kasan_init_shadow(void); + +/** + * @brief Poison a memory region in the KASAN shadow. + * + * Marks [addr, addr+size) as invalid with the given @p tag. + */ +void kasan_poison_region(const void *addr, size_t size, uint8_t tag); + +/** + * @brief Unpoison a memory region in the KASAN shadow. + * + * Marks [addr, addr+size) as valid (accessible). + */ +void kasan_unpoison_region(const void *addr, size_t size); + +/** + * @brief Temporarily disable KASAN load/store checks on the current core. + * + * Increments a nested suppression counter; while it is non-zero, the + * __asan_load_N / __asan_store_N runtime stubs return without touching shadow + * memory. Each call must be paired with kasan_enable_checks(). + * + * Intended for short critical sections that manipulate cache/MMU state or + * otherwise execute in conditions where accessing the KASAN shadow region + * would itself fault (for example, the early SPI flash chip probe in + * esp_flash_init_default_chip()). + * + * This API is safe to call from any context (task, ISR, panic handler). + */ +void kasan_disable_checks(void); + +/** + * @brief Re-enable KASAN load/store checks suppressed by kasan_disable_checks(). + * + * Decrements the suppression counter. Calls must be balanced; otherwise + * checks remain disabled (or, if unbalanced the other way, the counter wraps + * around and produces undefined behaviour). + */ +void kasan_enable_checks(void); +#endif + +#if CONFIG_KASAN_NO_HALT +/** + * @brief Return the number of KASAN errors reported since boot or last reset. + */ +uint32_t kasan_get_error_count(void); + +/** + * @brief Reset the KASAN error counter to zero. + */ +void kasan_reset_error_count(void); +#endif + +#ifdef __cplusplus +} +#endif diff --git a/components/esp_system/kasan.c b/components/esp_system/kasan.c new file mode 100644 index 00000000000..2ee707ca646 --- /dev/null +++ b/components/esp_system/kasan.c @@ -0,0 +1,512 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ + +/* + * Kernel Address Sanitizer (KASAN) runtime for ESP-IDF. + * + * GCC -fsanitize=kernel-address instruments loads/stores with calls to + * __asan_load_noabort / __asan_store_noabort. These stubs check a + * shadow memory region and report a violation if poisoned memory is accessed. + * + * Shadow layout (nibble-based): + * One shadow byte covers 8 bytes of real memory via two 4-bit nibbles. + * Low nibble (bits 0-3) → real bytes 0-3; high nibble (bits 4-7) → 4-7. + * Shadow address = shadow_offset + (real_addr >> 3) + * Nibble select = (real_addr >> 2) & 1 + * + * Nibble values: + * 0x0 all 4 bytes valid + * 0x1-0x3 first N bytes valid (partial granule) + * 0xC uninitialised / never allocated + * 0xD heap right redzone + * 0xE heap left redzone + * 0xF freed heap block + * + * The 4-byte granule matches TLSF's native alignment, so ROM TLSF can be used. + * + * This file MUST be compiled with -fno-sanitize=kernel-address. + */ + +#include +#include +#include +#include +#include +#include "sdkconfig.h" +#include "esp_attr.h" +#include "esp_cpu.h" +#include "esp_rom_sys.h" +#include "esp_system.h" +#include "soc/soc.h" + +#if CONFIG_COMPILER_KASAN + +#define KASAN_SHADOW_MAP_BASE ((uintptr_t)SOC_DRAM_LOW) +#define KASAN_SHADOW_SIZE ((size_t)((((uintptr_t)SOC_DRAM_HIGH - (uintptr_t)SOC_DRAM_LOW) + 7U) >> 3)) + +/* + * Shadow array — DRAM_ATTR so loads/stores remain valid when the SPI flash + * cache is disabled (IRAM KASAN stubs still run during flash operations). + */ +DRAM_ATTR uint8_t __attribute__((aligned(4))) +kasan_shadow_mem[KASAN_SHADOW_SIZE]; + +/* + * Runtime shadow offset: &kasan_shadow_mem[0] - (MAP_BASE >> 3). + * DRAM_ATTR so it is accessible with cache disabled. + */ +DRAM_ATTR uintptr_t kasan_shadow_offset; + +/* Nibble poison tags */ +#define KASAN_NIBBLE_VALID 0x0 +#define KASAN_NIBBLE_UNINIT 0xC +#define KASAN_NIBBLE_HEAP_RRZ 0xD +#define KASAN_NIBBLE_HEAP_LRZ 0xE +#define KASAN_NIBBLE_HEAP_FREE 0xF + +/* + * Suppression counter for KASAN checks. + * + * Incremented (and the corresponding decrement on exit) when: + * - a report is in flight: the report path itself executes instrumented code, + * which would otherwise recurse; + * - kasan_disable_checks() is called explicitly: the panic handler disables + * checks for the remainder of crash handling, since backtrace and stack + * dumps legitimately read guard pages and poisoned redzones that would + * otherwise trigger spurious reports. + * + * Atomic so it is safe across cores and ISRs. DRAM-resident so it remains + * accessible with cache disabled. + */ +static DRAM_ATTR atomic_uint_fast32_t s_kasan_suppress_depth; + +#if CONFIG_KASAN_NO_HALT +static DRAM_ATTR atomic_uint_fast32_t s_kasan_error_count; +#endif + +static inline bool kasan_checks_are_disabled(void) +{ + return atomic_load_explicit(&s_kasan_suppress_depth, memory_order_relaxed) != 0; +} + +static inline void kasan_report_enter(void) +{ + atomic_fetch_add_explicit(&s_kasan_suppress_depth, 1, memory_order_relaxed); +} + +static inline void kasan_report_exit(void) +{ + atomic_fetch_sub_explicit(&s_kasan_suppress_depth, 1, memory_order_relaxed); +} + +#if CONFIG_KASAN_NO_HALT +static inline uint32_t kasan_error_count_get(void) +{ + return (uint32_t)atomic_load_explicit(&s_kasan_error_count, memory_order_relaxed); +} + +static inline void kasan_error_count_reset(void) +{ + atomic_store_explicit(&s_kasan_error_count, 0, memory_order_relaxed); +} + +static inline void kasan_error_count_inc(void) +{ + atomic_fetch_add_explicit(&s_kasan_error_count, 1, memory_order_relaxed); +} +#endif + +/* ---- Shadow accessors --------------------------------------------------- */ + +/* + * These helpers are always inlined into their callers, so they carry no + * placement attribute of their own: when inlined into a flash-resident API + * (e.g. kasan_poison_region) they stay in flash, and when inlined into the + * IRAM hot path (kasan_is_valid_access / the __asan_* stubs) they run from + * IRAM with the rest of that function. + */ +static inline __attribute__((always_inline)) uint8_t *kasan_mem_to_shadow(uintptr_t addr) +{ + return (uint8_t *)(kasan_shadow_offset + (addr >> 3)); +} + +static inline __attribute__((always_inline)) int kasan_is_high_nibble(uintptr_t addr) +{ + return (addr >> 2) & 1; +} + +static inline __attribute__((always_inline)) uint8_t kasan_get_nibble(uintptr_t addr) +{ + uint8_t *shadow = kasan_mem_to_shadow(addr); + if (kasan_is_high_nibble(addr)) { + return (*shadow >> 4) & 0xF; + } else { + return *shadow & 0xF; + } +} + +static inline __attribute__((always_inline)) void kasan_set_nibble(uintptr_t addr, uint8_t val) +{ + uint8_t *shadow = kasan_mem_to_shadow(addr); + if (kasan_is_high_nibble(addr)) { + *shadow = (*shadow & 0x0F) | ((val & 0xF) << 4); + } else { + *shadow = (*shadow & 0xF0) | (val & 0xF); + } +} + +static inline __attribute__((always_inline)) bool kasan_addr_in_shadow_range(uintptr_t addr) +{ + uintptr_t map_base = KASAN_SHADOW_MAP_BASE; + uintptr_t map_end = map_base + ((uintptr_t)KASAN_SHADOW_SIZE << 3); + return (addr >= map_base && addr < map_end); +} + +/* ---- Poison / unpoison -------------------------------------------------- */ + +void kasan_poison_region(const void *addr, size_t size, uint8_t tag) +{ + if (!kasan_shadow_offset || size == 0) { + return; + } + uintptr_t start = (uintptr_t)addr; + uintptr_t end = start + size; + + uintptr_t map_base = KASAN_SHADOW_MAP_BASE; + uintptr_t map_end = map_base + ((uintptr_t)KASAN_SHADOW_SIZE << 3); + if (end <= map_base || start >= map_end) { + return; + } + if (start < map_base) { + start = map_base; + } + if (end > map_end) { + end = map_end; + } + + uintptr_t aligned_start = (start + 3) & ~3UL; + uintptr_t aligned_end = end & ~3UL; + + if (start < aligned_start && start < end) { + kasan_set_nibble(start & ~3UL, tag); + } + + for (uintptr_t a = aligned_start; a < aligned_end; a += 4) { + if ((a & 4) == 0 && (a + 4) < aligned_end) { + uint8_t *shadow = kasan_mem_to_shadow(a); + *shadow = (tag << 4) | tag; + a += 4; + } else { + kasan_set_nibble(a, tag); + } + } + + if (aligned_end < end) { + kasan_set_nibble(aligned_end, tag); + } +} + +void kasan_unpoison_region(const void *addr, size_t size) +{ + if (!kasan_shadow_offset || size == 0) { + return; + } + uintptr_t start = (uintptr_t)addr; + uintptr_t end = start + size; + + uintptr_t map_base = KASAN_SHADOW_MAP_BASE; + uintptr_t map_end = map_base + ((uintptr_t)KASAN_SHADOW_SIZE << 3); + if (end <= map_base || start >= map_end) { + return; + } + if (start < map_base) { + start = map_base; + } + if (end > map_end) { + end = map_end; + } + + uintptr_t granule_start = start & ~3UL; + uintptr_t granule_end = (end + 3) & ~3UL; + + for (uintptr_t a = granule_start; a < granule_end; a += 4) { + if (a + 4 > end && end > a) { + uint8_t partial = (uint8_t)(end - a); + if (partial > 0 && partial < 4) { + kasan_set_nibble(a, partial); + } else { + kasan_set_nibble(a, KASAN_NIBBLE_VALID); + } + } else { + if ((a & 4) == 0 && (a + 4) < granule_end) { + uint8_t *shadow = kasan_mem_to_shadow(a); + *shadow = 0x00; + a += 4; + } else { + kasan_set_nibble(a, KASAN_NIBBLE_VALID); + } + } + } +} + +/* ---- Shadow initialisation ---------------------------------------------- */ + +void kasan_disable_checks(void) +{ + atomic_fetch_add_explicit(&s_kasan_suppress_depth, 1, memory_order_acquire); +} + +void kasan_enable_checks(void) +{ + atomic_fetch_sub_explicit(&s_kasan_suppress_depth, 1, memory_order_release); +} + +void kasan_init_shadow(void) +{ + /* + * The shadow array lives in .data (DRAM_ATTR), not .bss, so it is loaded + * from the image rather than implicitly zeroed at startup. Zero it here + * explicitly so every nibble starts as 0 (= valid). The alloc hook then + * marks redzones and the free hook poisons freed blocks; no bulk poisoning + * is done here so boot-path code sees clean shadow and no false positives. + */ + memset(kasan_shadow_mem, 0, KASAN_SHADOW_SIZE); + + kasan_shadow_offset = (uintptr_t)kasan_shadow_mem + - (KASAN_SHADOW_MAP_BASE >> 3); + + esp_rom_printf("KASAN: kernel-address sanitizer initialized (shadow %u bytes, map base 0x%08" PRIxPTR ")\n", + (unsigned)KASAN_SHADOW_SIZE, (uintptr_t)KASAN_SHADOW_MAP_BASE); +} + +/* ---- Error counter (CONFIG_KASAN_NO_HALT) ------------------------------- */ + +#if CONFIG_KASAN_NO_HALT +uint32_t kasan_get_error_count(void) +{ + return kasan_error_count_get(); +} + +void kasan_reset_error_count(void) +{ + kasan_error_count_reset(); +} +#endif + +/* ---- Access validation -------------------------------------------------- */ + +static inline __attribute__((always_inline)) bool kasan_is_valid_access(uintptr_t addr, size_t size) +{ + /* Address outside monitored DRAM window, not mapped to shadow region, assume valid to avoid false positives */ + if (!kasan_addr_in_shadow_range(addr) || !kasan_addr_in_shadow_range(addr + size - 1)) { + return true; + } + + /* + * Fast path: both nibbles in the shadow byte are valid. + * + * Each shadow byte covers 8 real bytes (two 4-byte granules), so we can + * only short-circuit when the *entire* access falls inside the shadow + * byte(s) we have actually examined. Larger or mis-aligned accesses fall + * through to the slow path below, which walks every granule. + */ + uintptr_t offset_in_byte = addr & 7U; + uint8_t shadow_byte = *kasan_mem_to_shadow(addr); + if (shadow_byte == 0x00) { + if ((offset_in_byte + size) <= 8U) { + return true; + } + if ((offset_in_byte + size) <= 16U) { + uint8_t next_shadow = *kasan_mem_to_shadow(addr + 8); + if (next_shadow == 0x00) { + return true; + } + } + } + + /* Slow path: check each granule. */ + uintptr_t end_addr = addr + size; + for (uintptr_t a = addr; a < end_addr;) { + uint8_t nibble = kasan_get_nibble(a); + + if (nibble == KASAN_NIBBLE_VALID) { + a = (a & ~3UL) + 4; + continue; + } + + if (nibble >= 1 && nibble <= 3) { + uintptr_t granule_base = a & ~3UL; + uintptr_t offset_in_granule = a - granule_base; + uintptr_t access_end_in_granule = end_addr - granule_base; + if (access_end_in_granule > 4) { + access_end_in_granule = 4; + } + if (offset_in_granule >= nibble || access_end_in_granule > nibble) { + return false; + } + a = granule_base + 4; + continue; + } + + return false; + } + + return true; +} + +/* ---- Error reporting ---------------------------------------------------- */ + +/* + * Bug-type and access-type strings live in DRAM so that the IRAM error + * reporting path stays safe when the SPI flash cache is disabled (ISR + * context, spi_flash operations, early boot). Plain string literals would + * land in .rodata (flash) and dereferencing them with cache off would mask + * the real KASAN violation with a cache-error panic. + */ +static DRAM_ATTR const char kasan_str_use_after_free[] = "use-after-free"; +static DRAM_ATTR const char kasan_str_underflow_lrz[] = "heap-buffer-underflow (left redzone)"; +static DRAM_ATTR const char kasan_str_overflow_rrz[] = "heap-buffer-overflow (right redzone)"; +static DRAM_ATTR const char kasan_str_uninitialised[] = "uninitialised memory"; +static DRAM_ATTR const char kasan_str_overflow_partial[] = "heap-buffer-overflow (partial granule)"; +static DRAM_ATTR const char kasan_str_unknown_poison[] = "unknown poison"; +static DRAM_ATTR const char kasan_str_write[] = "WRITE"; +static DRAM_ATTR const char kasan_str_read[] = "READ"; +#if !CONFIG_KASAN_NO_HALT +static DRAM_ATTR const char kasan_str_abort_msg[] = "KASAN: invalid memory access"; +#endif + +static DRAM_ATTR const char kasan_fmt_error[] = "KASAN error: %s of size %u at 0x%08x\n"; +static DRAM_ATTR const char kasan_fmt_bug[] = " Bug type: %s (shadow nibble=0x%x)\n"; + +static IRAM_ATTR const char *kasan_nibble_to_string(uint8_t nibble) +{ + switch (nibble) { + case KASAN_NIBBLE_HEAP_FREE: return kasan_str_use_after_free; + case KASAN_NIBBLE_HEAP_LRZ: return kasan_str_underflow_lrz; + case KASAN_NIBBLE_HEAP_RRZ: return kasan_str_overflow_rrz; + case KASAN_NIBBLE_UNINIT: return kasan_str_uninitialised; + default: + if (nibble >= 1 && nibble <= 3) { + return kasan_str_overflow_partial; + } + return kasan_str_unknown_poison; + } +} + +static IRAM_ATTR void kasan_report(uintptr_t addr, size_t size, bool is_write) +{ + kasan_report_enter(); + + if (esp_cpu_dbgr_is_attached()) { + esp_cpu_dbgr_break(); + } + + const char *access_type = is_write ? kasan_str_write : kasan_str_read; + uint8_t nibble = kasan_get_nibble(addr); + const char *bug_type = kasan_nibble_to_string(nibble); + + esp_rom_printf(kasan_fmt_error, access_type, (unsigned)size, (unsigned)addr); + esp_rom_printf(kasan_fmt_bug, bug_type, nibble); + +#if CONFIG_KASAN_NO_HALT + kasan_error_count_inc(); + kasan_report_exit(); +#else + /* + * Avoid flash-resident helpers (strlcat, libc string operations) here: + * kasan_report runs from IRAM and may execute with the SPI flash cache + * disabled. esp_rom_printf above has already emitted the detailed + * diagnostic via ROM; pass a short DRAM-resident message to the abort + * path so the panic itself does not touch flash. + */ + esp_system_abort(kasan_str_abort_msg); +#endif +} + +/* ---- Check dispatcher --------------------------------------------------- */ + +static IRAM_ATTR void kasan_check(uintptr_t addr, size_t size, bool is_write) +{ + if (__builtin_expect(kasan_checks_are_disabled() || !kasan_shadow_offset, 0)) { + return; + } + if (!kasan_is_valid_access(addr, size)) { + kasan_report(addr, size, is_write); + } +} + +/* ---- GCC-emitted KASAN stubs -------------------------------------------- */ + +/* + * __attribute__((used)) prevents --gc-sections from removing stubs that GCC's + * instrumentation pass calls but that the linker cannot see at analysis time. + */ +__attribute__((used)) void IRAM_ATTR __asan_load1_noabort(void *addr) +{ + kasan_check((uintptr_t)addr, 1, false); +} + +__attribute__((used)) void IRAM_ATTR __asan_load2_noabort(void *addr) +{ + kasan_check((uintptr_t)addr, 2, false); +} + +__attribute__((used)) void IRAM_ATTR __asan_load4_noabort(void *addr) +{ + kasan_check((uintptr_t)addr, 4, false); +} + +__attribute__((used)) void IRAM_ATTR __asan_load8_noabort(void *addr) +{ + kasan_check((uintptr_t)addr, 8, false); +} + +__attribute__((used)) void IRAM_ATTR __asan_load16_noabort(void *addr) +{ + kasan_check((uintptr_t)addr, 16, false); +} + +__attribute__((used)) void IRAM_ATTR __asan_loadN_noabort(void *addr, size_t size) +{ + kasan_check((uintptr_t)addr, size, false); +} + +__attribute__((used)) void IRAM_ATTR __asan_store1_noabort(void *addr) +{ + kasan_check((uintptr_t)addr, 1, true); +} + +__attribute__((used)) void IRAM_ATTR __asan_store2_noabort(void *addr) +{ + kasan_check((uintptr_t)addr, 2, true); +} + +__attribute__((used)) void IRAM_ATTR __asan_store4_noabort(void *addr) +{ + kasan_check((uintptr_t)addr, 4, true); +} + +__attribute__((used)) void IRAM_ATTR __asan_store8_noabort(void *addr) +{ + kasan_check((uintptr_t)addr, 8, true); +} + +__attribute__((used)) void IRAM_ATTR __asan_store16_noabort(void *addr) +{ + kasan_check((uintptr_t)addr, 16, true); +} + +__attribute__((used)) void IRAM_ATTR __asan_storeN_noabort(void *addr, size_t size) +{ + kasan_check((uintptr_t)addr, size, true); +} + +/* Called before noreturn functions; nothing to do on bare-metal. */ +void IRAM_ATTR __asan_handle_no_return(void) +{ +} + +#endif /* CONFIG_COMPILER_KASAN */ diff --git a/components/esp_system/port/cpu_start.c b/components/esp_system/port/cpu_start.c index f4fd9e92cfe..8a0031cba65 100644 --- a/components/esp_system/port/cpu_start.c +++ b/components/esp_system/port/cpu_start.c @@ -401,6 +401,18 @@ void IRAM_ATTR do_multicore_settings(void) FORCE_INLINE_ATTR IRAM_ATTR void init_cpu(void) { #ifdef __riscv + // Configure the global pointer register. + // This must be the first thing the IDF app does on RISC-V, as any other + // piece of code could be relaxed by the linker to access something relative + // to __global_pointer$. With KASAN enabled, even calls like + // esp_cpu_dbgr_is_attached() are instrumented and may emit gp-relative + // loads, so gp must be set up before any C function call. + __asm__ __volatile__( + ".option push\n" + ".option norelax\n" + "la gp, __global_pointer$\n" + ".option pop" + ); if (esp_cpu_dbgr_is_attached()) { /* Let debugger some time to detect that target started, halt it, enable ebreaks and resume. 500ms should be enough. */ @@ -408,15 +420,6 @@ FORCE_INLINE_ATTR IRAM_ATTR void init_cpu(void) esp_rom_delay_us(100000); } } - // Configure the global pointer register - // (This should be the first thing IDF app does, as any other piece of code could be - // relaxed by the linker to access something relative to __global_pointer$) - __asm__ __volatile__( - ".option push\n" - ".option norelax\n" - "la gp, __global_pointer$\n" - ".option pop" - ); #endif /* NOTE: When ESP-TEE is enabled, this sets up the callback function diff --git a/components/esp_system/port/panic_handler.c b/components/esp_system/port/panic_handler.c index e59cf50e63c..37c94ee693e 100644 --- a/components/esp_system/port/panic_handler.c +++ b/components/esp_system/port/panic_handler.c @@ -27,6 +27,10 @@ #include "esp_private/panic_internal.h" #include "esp_private/panic_reason.h" +#if CONFIG_COMPILER_KASAN +#include "esp_kasan.h" +#endif + #if SOC_WDT_SUPPORTED || SOC_RTC_WDT_SUPPORTED #include "hal/wdt_types.h" #include "hal/wdt_hal.h" @@ -128,6 +132,13 @@ void busy_wait(void) static void panic_handler(void *frame, bool pseudo_excause) { +#if CONFIG_COMPILER_KASAN + /* Disable KASAN checks for the remainder of crash handling: backtrace and + * stack dumps legitimately read guard pages and poisoned redzones, which + * would otherwise trigger spurious KASAN reports. */ + kasan_disable_checks(); +#endif + /* If watchdogs are enabled, the panic handler runs the risk of getting aborted pre-emptively because * an overzealous watchdog decides to reset it. Hence, we feed the WDTs here. * diff --git a/components/esp_system/system_init_fn.txt b/components/esp_system/system_init_fn.txt index 252598df81c..ff0f5845bdd 100644 --- a/components/esp_system/system_init_fn.txt +++ b/components/esp_system/system_init_fn.txt @@ -24,6 +24,10 @@ CORE: 10: init_show_cpu_freq in components/esp_system/startup_funcs.c on BIT(0) CORE: 20: init_show_app_info in components/esp_app_format/esp_app_desc.c on BIT(0) CORE: 21: init_efuse_show_app_info in components/efuse/src/esp_efuse_startup.c on BIT(0) +# When KASAN is enabled, initialise the KASAN shadow region before the heap allocator. +# The shadow offset must be set up before the first heap_caps_init hook fires. +CORE: 98: init_kasan_shadow in components/heap/heap_kasan.c on BIT(0) + # Set the standard stream to non blocking and register the default vfs CORE: 99: init_vfs_linux_coop in components/vfs/vfs_linux_default_coop.c on BIT(0) diff --git a/components/heap/CMakeLists.txt b/components/heap/CMakeLists.txt index 23b8563df27..9c9ccdea2a3 100644 --- a/components/heap/CMakeLists.txt +++ b/components/heap/CMakeLists.txt @@ -66,11 +66,13 @@ if(NOT BOOTLOADER_BUILD) endif() endif() +set(ldfragments linker.lf) + idf_component_register(SRCS "${srcs}" INCLUDE_DIRS ${includes} PRIV_INCLUDE_DIRS ${priv_includes} - LDFRAGMENTS linker.lf - PRIV_REQUIRES soc) + LDFRAGMENTS ${ldfragments} + PRIV_REQUIRES soc esp_system) if(CONFIG_HEAP_TLSF_USE_ROM_IMPL AND NOT BOOTLOADER_BUILD) # After registering the component, set the tlsf_set_rom_patches symbol as undefined @@ -110,3 +112,38 @@ else() endif() endif() endif() + +# KASAN heap integration: hook into alloc/free to maintain shadow memory. +# heap_kasan.c holds the implementation (compiled without KASAN instrumentation). +# heap_kasan_hooks.c provides strong (non-weak) overrides of the hook stubs; +# it intentionally avoids including esp_heap_caps.h to prevent GCC from +# inheriting the 'weak' attribute from the declarations in that header. +if(CONFIG_COMPILER_KASAN AND CONFIG_HEAP_USE_HOOKS) + target_sources(${COMPONENT_LIB} PRIVATE + "heap_kasan.c" + "heap_kasan_hooks.c" + ) + # Sources excluded from KASAN instrumentation: + # heap_kasan.c / heap_kasan_hooks.c implement the sanitizer hooks themselves + # (instrumenting them would cause infinite recursion). + # heap_caps_init.c runs while heap metadata is being brought up: the first + # allocations happen before the shadow is fully initialised, and the + # memcpy of the registered-heaps array touches DRAM regions whose + # shadow state is still being populated. + set_source_files_properties( + "heap_caps_init.c" + "heap_kasan.c" + "heap_kasan_hooks.c" + PROPERTIES COMPILE_OPTIONS "-fno-sanitize=kernel-address" + ) + idf_component_get_property(esp_system_lib esp_system COMPONENT_LIB) + target_link_libraries(${COMPONENT_LIB} PRIVATE ${esp_system_lib}) + # Force the linker to include our strong hook definitions. Without this, + # --gc-sections would silently discard them because the call site in + # heap_caps_base.c uses a weak-symbol pointer (if (hook != NULL) ...) which + # doesn't create a strong reference that the linker follows. + target_link_libraries(${COMPONENT_LIB} INTERFACE + "-u esp_heap_trace_alloc_hook" + "-u esp_heap_trace_free_hook" + ) +endif() diff --git a/components/heap/heap_caps.c b/components/heap/heap_caps.c index b34a9b44082..972ec294ddf 100644 --- a/components/heap/heap_caps.c +++ b/components/heap/heap_caps.c @@ -13,6 +13,7 @@ #include "multi_heap.h" #include "esp_log.h" #include "heap_private.h" +#include "heap_kasan_layout.h" #include "esp_system.h" /* @@ -480,13 +481,22 @@ void heap_caps_dump_all(void) size_t heap_caps_get_allocated_size( void *ptr ) { + /* The heap layout is: + * [block-owner][left redzone][user][right redzone] + * so undo the KASAN shift before removing the block-owner word. + */ + ptr = KASAN_USER_TO_PTR(ptr); // add the block owner bytes back to ptr before handing over // to multi heap layer. ptr = MULTI_HEAP_REMOVE_BLOCK_OWNER_OFFSET(ptr); heap_t *heap = find_containing_heap(ptr); assert(heap); size_t size = multi_heap_get_allocated_size(heap->heap, ptr); - return MULTI_HEAP_REMOVE_BLOCK_OWNER_SIZE(size); + size = MULTI_HEAP_REMOVE_BLOCK_OWNER_SIZE(size); + if (size > 2 * KASAN_RZ) { + size -= 2 * KASAN_RZ; + } + return size; } size_t heap_caps_get_containing_block_size(void *ptr) diff --git a/components/heap/heap_caps_base.c b/components/heap/heap_caps_base.c index 5118b672c3f..5727a0cc832 100644 --- a/components/heap/heap_caps_base.c +++ b/components/heap/heap_caps_base.c @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: 2015-2025 Espressif Systems (Shanghai) CO LTD + * SPDX-FileCopyrightText: 2015-2026 Espressif Systems (Shanghai) CO LTD * * SPDX-License-Identifier: Apache-2.0 */ @@ -12,6 +12,7 @@ #include "multi_heap.h" #include "esp_log.h" #include "heap_private.h" +#include "heap_kasan_layout.h" #if CONFIG_HEAP_TASK_TRACKING #include "esp_heap_task_info.h" #include "esp_heap_task_info_internal.h" @@ -28,9 +29,28 @@ #define CALL_HOOK(hook, ...) {} #endif +/* + * KASAN redzone support: inflate allocations by 2*KASAN_RZ bytes and shift + * the user pointer past the left redzone. heap_kasan.c poisons the redzones. + * + * Final allocation layout (with CONFIG_HEAP_TASK_TRACKING=y): + * + * [ block-owner word ][ left redzone ][ user bytes ][ right redzone ] + * + * The ordering is intentional: user underflows must hit the left redzone + * before they can reach the task-tracking block-owner word. + * + * Pointer arithmetic macros live in heap_kasan_layout.h. + */ + //This is normally provided by the heap-memalign-hw component. extern void esp_heap_adjust_alignment_to_hw(size_t *p_alignment, size_t *p_size, uint32_t *p_caps); +#if CONFIG_COMPILER_KASAN && CONFIG_HEAP_USE_HOOKS +bool kasan_heap_should_defer_free(void); +void kasan_heap_clear_deferred_free(void); +#endif + //Default alignment the multiheap allocator / tlsf will align 'unaligned' memory to, in bytes #define UNALIGNED_MEM_ALIGNMENT_BYTES 4 @@ -67,6 +87,17 @@ HEAP_IRAM_ATTR void heap_caps_free( void *ptr) return; } + /* + * KASAN free hook must see the same pointer that the alloc hook saw, i.e. + * the user-visible (IRAM) pointer with the redzone shift applied. Call + * it before any DIRAM -> DRAM translation; otherwise the shadow update + * would touch the wrong address range and use-after-free detection on + * IRAM-aliased blocks would be incorrect. + */ +#if CONFIG_COMPILER_KASAN && CONFIG_HEAP_USE_HOOKS + CALL_HOOK(esp_heap_trace_free_hook, ptr); +#endif + if ((!esp_dram_match_iram() && esp_ptr_in_diram_iram(ptr)) || (!esp_rtc_dram_match_rtc_iram() && esp_ptr_in_rtc_iram_fast(ptr))) { //Memory allocated here is actually allocated in the DRAM alias region and @@ -75,17 +106,29 @@ HEAP_IRAM_ATTR void heap_caps_free( void *ptr) uint32_t *dramAddrPtr = (uint32_t *)ptr; ptr = (void *)dramAddrPtr[-1]; } - void *block_owner_ptr = MULTI_HEAP_REMOVE_BLOCK_OWNER_OFFSET(ptr); + + /* Reverse the pointer transforms in the opposite order used on alloc: + * user -> left redzone start -> block-owner word. + */ + void *raw_ptr = KASAN_USER_TO_PTR(ptr); + void *block_owner_ptr = MULTI_HEAP_REMOVE_BLOCK_OWNER_OFFSET(raw_ptr); heap_t *heap = find_containing_heap(block_owner_ptr); assert(heap != NULL && "free() target pointer is outside heap areas"); #if CONFIG_HEAP_TASK_TRACKING - heap_caps_update_per_task_info_free(heap, ptr); + heap_caps_update_per_task_info_free(heap, raw_ptr); #endif +#if CONFIG_COMPILER_KASAN && CONFIG_HEAP_USE_HOOKS + if (kasan_heap_should_defer_free()) { + kasan_heap_clear_deferred_free(); + return; + } + multi_heap_free(heap->heap, block_owner_ptr); +#else multi_heap_free(heap->heap, block_owner_ptr); - CALL_HOOK(esp_heap_trace_free_hook, ptr); +#endif } HEAP_IRAM_ATTR static inline void *aligned_or_unaligned_alloc(multi_heap_handle_t heap, size_t size, size_t alignment, size_t offset) { @@ -139,6 +182,8 @@ HEAP_IRAM_ATTR NOINLINE_ATTR void *heap_caps_aligned_alloc_base(size_t alignment size = (size + 3) & (~3); // int overflow checked above } + const size_t alloc_size = KASAN_ADD_RZ(size); + for (int prio = 0; prio < SOC_MEMORY_TYPE_NO_PRIOS; prio++) { //Iterate over heaps and check capabilities at this priority heap_t *heap; @@ -159,7 +204,7 @@ HEAP_IRAM_ATTR NOINLINE_ATTR void *heap_caps_aligned_alloc_base(size_t alignment //This is special, insofar that what we're going to get back is a DRAM address. If so, //we need to 'invert' it (lowest address in DRAM == highest address in IRAM and vice-versa) and //add a pointer to the DRAM equivalent before the address we're going to return. - ret = aligned_or_unaligned_alloc(heap->heap, MULTI_HEAP_ADD_BLOCK_OWNER_SIZE(size) + 4, + ret = aligned_or_unaligned_alloc(heap->heap, MULTI_HEAP_ADD_BLOCK_OWNER_SIZE(alloc_size) + 4, alignment, MULTI_HEAP_BLOCK_OWNER_SIZE()); // int overflow checked above if (ret != NULL) { #if CONFIG_HEAP_TASK_TRACKING @@ -171,13 +216,14 @@ HEAP_IRAM_ATTR NOINLINE_ATTR void *heap_caps_aligned_alloc_base(size_t alignment MULTI_HEAP_SET_BLOCK_OWNER(ret); ret = MULTI_HEAP_ADD_BLOCK_OWNER_OFFSET(ret); + ret = KASAN_PTR_TO_USER(ret); uint32_t *iptr = dram_alloc_to_iram_addr(ret, size + 4); // int overflow checked above CALL_HOOK(esp_heap_trace_alloc_hook, iptr, size, caps); return iptr; } } else { //Just try to alloc, nothing special. - ret = aligned_or_unaligned_alloc(heap->heap, MULTI_HEAP_ADD_BLOCK_OWNER_SIZE(size), + ret = aligned_or_unaligned_alloc(heap->heap, MULTI_HEAP_ADD_BLOCK_OWNER_SIZE(alloc_size), alignment, MULTI_HEAP_BLOCK_OWNER_SIZE()); if (ret != NULL) { #if CONFIG_HEAP_TASK_TRACKING @@ -189,6 +235,7 @@ HEAP_IRAM_ATTR NOINLINE_ATTR void *heap_caps_aligned_alloc_base(size_t alignment MULTI_HEAP_SET_BLOCK_OWNER(ret); ret = MULTI_HEAP_ADD_BLOCK_OWNER_OFFSET(ret); + ret = KASAN_PTR_TO_USER(ret); CALL_HOOK(esp_heap_trace_alloc_hook, ret, size, caps); return ret; } @@ -236,31 +283,43 @@ HEAP_IRAM_ATTR NOINLINE_ATTR void *heap_caps_realloc_base( void *ptr, size_t siz return NULL; } - //The pointer to memory may be aliased, we need to - //recover the corresponding address before to manage a new allocation: - if(esp_ptr_in_diram_iram((void *)ptr)) { - uint32_t *dram_addr = (uint32_t *)ptr; - dram_ptr = (void *)dram_addr[-1]; + /* + * DIRAM aliasing detection must happen on the original user pointer: + * dram_alloc_to_iram_addr stores the underlying DRAM address one word + * before the IRAM pointer, so the KASAN redzone shift (which moves the + * pointer by KASAN_RZ on the IRAM side) would land us in the wrong place + * if we applied it first. + */ + void *raw_ptr; + if (esp_ptr_in_diram_iram(ptr)) { + uint32_t *iram_addr = (uint32_t *)ptr; + dram_ptr = (void *)iram_addr[-1]; + /* On the DRAM side the layout is [block-owner][left redzone][user] + * so undo redzone first, then block-owner, matching alloc order. */ + dram_ptr = KASAN_USER_TO_PTR(dram_ptr); dram_ptr = MULTI_HEAP_REMOVE_BLOCK_OWNER_OFFSET(dram_ptr); heap = find_containing_heap(dram_ptr); assert(heap != NULL && "realloc() pointer is outside heap areas"); - //with pointers that reside on diram space, we avoid using - //the realloc implementation due to address translation issues, - //instead force a malloc/copy/free + /* with pointers that reside on diram space, we avoid using + * the realloc implementation due to address translation issues, + * instead force a malloc/copy/free */ ptr_in_diram_case = true; - + raw_ptr = NULL; /* not used in the diram path */ } else { - heap = find_containing_heap(ptr); + /* Reverse the alloc-time layout transform in the same order as free(): + * user -> left redzone start -> block-owner word. Doing the + * block-owner removal *before* find_containing_heap() matches the + * free() path and the DIRAM branch above. */ + raw_ptr = KASAN_USER_TO_PTR(ptr); + raw_ptr = MULTI_HEAP_REMOVE_BLOCK_OWNER_OFFSET(raw_ptr); + + heap = find_containing_heap(raw_ptr); assert(heap != NULL && "realloc() pointer is outside heap areas"); } - // shift ptr by block owner offset. Since the ptr returned to the user - // does not include the block owner bytes (that are located at the - // beginning of the allocated memory) we have to add them back before - // processing the realloc. - ptr = MULTI_HEAP_REMOVE_BLOCK_OWNER_OFFSET(ptr); + const size_t alloc_size = KASAN_ADD_RZ(size); // are the existing heap's capabilities compatible with the // requested ones? @@ -273,17 +332,17 @@ HEAP_IRAM_ATTR NOINLINE_ATTR void *heap_caps_realloc_base( void *ptr, size_t siz // (which will resize the block if it can) #if CONFIG_HEAP_TASK_TRACKING - size_t old_size = multi_heap_get_full_block_size(heap->heap, ptr); - TaskHandle_t old_task = MULTI_HEAP_GET_BLOCK_OWNER(ptr); + size_t old_size = multi_heap_get_full_block_size(heap->heap, raw_ptr); + TaskHandle_t old_task = MULTI_HEAP_GET_BLOCK_OWNER(raw_ptr); #endif - void *r = multi_heap_realloc(heap->heap, ptr, MULTI_HEAP_ADD_BLOCK_OWNER_SIZE(size)); + void *r = multi_heap_realloc(heap->heap, raw_ptr, MULTI_HEAP_ADD_BLOCK_OWNER_SIZE(alloc_size)); if (r != NULL) { MULTI_HEAP_SET_BLOCK_OWNER(r); #if CONFIG_HEAP_TASK_TRACKING heap_caps_update_per_task_info_realloc(heap, - MULTI_HEAP_ADD_BLOCK_OWNER_OFFSET(ptr), + MULTI_HEAP_ADD_BLOCK_OWNER_OFFSET(raw_ptr), MULTI_HEAP_ADD_BLOCK_OWNER_OFFSET(r), old_size, old_task, multi_heap_get_full_block_size(heap->heap, r), @@ -291,6 +350,19 @@ HEAP_IRAM_ATTR NOINLINE_ATTR void *heap_caps_realloc_base( void *ptr, size_t siz #endif r = MULTI_HEAP_ADD_BLOCK_OWNER_OFFSET(r); + r = KASAN_PTR_TO_USER(r); +#if CONFIG_COMPILER_KASAN && CONFIG_HEAP_USE_HOOKS + /* + * If multi_heap_realloc() relocated the block (r != ptr), the + * old user range needs its shadow re-poisoned as use-after-free + * so that lingering references hit a KASAN violation. When the + * block was resized in place, alloc hook below will simply + * refresh the shadow over the new range. + */ + if (r != ptr) { + CALL_HOOK(esp_heap_trace_free_hook, ptr); + } +#endif CALL_HOOK(esp_heap_trace_alloc_hook, r, size, caps); return r; } @@ -307,14 +379,17 @@ HEAP_IRAM_ATTR NOINLINE_ATTR void *heap_caps_realloc_base( void *ptr, size_t siz if(ptr_in_diram_case) { old_size = multi_heap_get_allocated_size(heap->heap, dram_ptr); } else { - old_size = multi_heap_get_allocated_size(heap->heap, ptr); + old_size = multi_heap_get_allocated_size(heap->heap, raw_ptr); } assert(old_size > 0); - // do not copy the block owner bytes - memcpy(new_p, MULTI_HEAP_ADD_BLOCK_OWNER_OFFSET(ptr), MIN(size, old_size)); - // add the block owner bytes to ptr since they are removed in heap_caps_free - heap_caps_free(MULTI_HEAP_ADD_BLOCK_OWNER_OFFSET(ptr)); + old_size = MULTI_HEAP_REMOVE_BLOCK_OWNER_SIZE(old_size); + /* Subtract KASAN redzone overhead to get the actual user-data size. */ + if (old_size > 2 * KASAN_RZ) { + old_size -= 2 * KASAN_RZ; + } + memcpy(new_p, ptr, MIN(size, old_size)); + heap_caps_free(ptr); return new_p; } diff --git a/components/heap/heap_kasan.c b/components/heap/heap_kasan.c new file mode 100644 index 00000000000..2fe2a90468c --- /dev/null +++ b/components/heap/heap_kasan.c @@ -0,0 +1,288 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ + +/* + * KASAN heap hooks for ESP-IDF. + * + * Provides strong definitions of the weak heap hooks so the KASAN shadow stays + * in sync with every heap_caps_malloc / heap_caps_free. + * + * When CONFIG_KASAN_HEAP_REDZONE_SIZE > 0, each allocation gets a poisoned + * guard band on both sides (left and right redzones) for overflow/underflow + * detection. + * + * When CONFIG_KASAN_QUARANTINE_SIZE > 0, freed blocks are held in a FIFO + * before the real free, keeping their shadow poisoned to catch use-after-free. + * + * Compiled with -fno-sanitize=kernel-address to avoid recursive instrumentation. + */ + +#include +#include +#include +#include +#include +#include +#include "sdkconfig.h" +#include "esp_rom_sys.h" +/* + * Avoid including esp_heap_caps.h: it declares the hook symbols as weak, and + * GCC propagates that attribute to definitions in the same TU. Forward-declare + * only what we need. + */ +void heap_caps_free(void *ptr); +size_t heap_caps_get_allocated_size(void *ptr); + +void kasan_heap_alloc_impl(void *ptr, size_t size, uint32_t caps); +void kasan_heap_free_impl(void *ptr); +bool kasan_heap_should_defer_free(void); +void kasan_heap_clear_deferred_free(void); + +#include "esp_kasan.h" +#include "esp_private/startup_internal.h" +#include "heap_private.h" +#include "heap_kasan_layout.h" +#include "freertos/FreeRTOS.h" +#include "freertos/portmacro.h" + +#if CONFIG_COMPILER_KASAN && CONFIG_HEAP_USE_HOOKS + +/* + * Initialise the KASAN shadow region before the heap allocator (priority 100). + * Runs at priority 99 so the shadow offset is in place before the first heap + * caps registration, and before the alloc/free hooks below start running. + */ +ESP_SYSTEM_INIT_FN(init_kasan_shadow, CORE, BIT(0), 98) +{ + kasan_init_shadow(); + return ESP_OK; +} + +/* ---- Left-redzone header ------------------------------------------------ */ + +#define KASAN_LRZ_MAGIC 0xA5B6C7D8UL + +typedef struct { + uint32_t magic; + size_t user_size; +} kasan_lrz_hdr_t; + +#if HEAP_KASAN_RZ_ENABLED +_Static_assert((CONFIG_KASAN_HEAP_REDZONE_SIZE % 4) == 0, + "CONFIG_KASAN_HEAP_REDZONE_SIZE must be a multiple of 4"); +_Static_assert(sizeof(kasan_lrz_hdr_t) <= KASAN_RZ, + "CONFIG_KASAN_HEAP_REDZONE_SIZE is too small to hold the KASAN header"); +#endif + +/* ---- Quarantine ring-buffer --------------------------------------------- */ + +static inline unsigned kasan_q_core_id(void) +{ + int core_id = xPortGetCoreID(); + assert(core_id >= 0 && core_id < portNUM_PROCESSORS); + return (unsigned)core_id; +} + +#if CONFIG_KASAN_QUARANTINE_SIZE > 0 + +/* + * Per-core "deferred-free ticket counter". Each call into + * kasan_heap_free_impl() enqueues one block in the quarantine and bumps the + * counter; heap_caps_free() pops one ticket through kasan_heap_should_defer_free + * + kasan_heap_clear_deferred_free. A counter (rather than a bool) is required + * because frees can nest: e.g. heap_caps_free(A) starts on core 0, an ISR fires + * mid-way and runs heap_caps_free(B) on the same core, then heap_caps_free(A) + * resumes. With a bool the ISR's "clear" would mask the outer free's defer + * request and the outer pointer would be released both via multi_heap_free() + * *and* later via the quarantine eviction (double free). + * + * Access is from one core at a time but can be from an ISR on that core, so + * an atomic fetch-add is sufficient; we don't need a cross-core barrier here. + */ +static DRAM_ATTR atomic_uint_fast32_t s_q_defer_free[portNUM_PROCESSORS]; + +#define KASAN_Q_ENTRIES 64U + +typedef struct { + void *ptr; + size_t size; +} kasan_q_entry_t; + +static kasan_q_entry_t s_q[KASAN_Q_ENTRIES]; +static unsigned s_q_head; +static unsigned s_q_tail; +static size_t s_q_bytes; +static portMUX_TYPE s_q_mux = portMUX_INITIALIZER_UNLOCKED; + +static void kasan_q_release_one(void *ptr) +{ + void *raw_ptr = KASAN_USER_TO_RAW(ptr); + void *block_owner_ptr = MULTI_HEAP_REMOVE_BLOCK_OWNER_OFFSET(raw_ptr); + heap_t *heap = find_containing_heap(block_owner_ptr); + assert(heap != NULL && "quarantine free target pointer is outside heap areas"); + multi_heap_free(heap->heap, block_owner_ptr); +} + +static void kasan_q_add(void *ptr, size_t size) +{ + /* + * Collect every block that needs to be evicted into a small on-stack + * array. We update head/tail/bytes atomically inside the critical + * section and only call multi_heap_free() after we have exited it. This + * avoids the previous "drop the lock, do work, re-acquire" pattern where + * a concurrent kasan_q_add() on the other core could mutate s_q_head / + * s_q_tail / s_q_bytes during the lock-release window and we would + * resume with stale state. + */ + void *evict[KASAN_Q_ENTRIES]; + unsigned n_evict = 0; + + portENTER_CRITICAL(&s_q_mux); + + /* If the ring is full, evict the oldest entry to make room. */ + unsigned next = (s_q_head + 1U) % KASAN_Q_ENTRIES; + if (next == s_q_tail) { + evict[n_evict++] = s_q[s_q_tail].ptr; + s_q_bytes -= s_q[s_q_tail].size; + s_q_tail = (s_q_tail + 1U) % KASAN_Q_ENTRIES; + } + + /* Insert the new entry at head. */ + s_q[s_q_head].ptr = ptr; + s_q[s_q_head].size = size; + s_q_head = (s_q_head + 1U) % KASAN_Q_ENTRIES; + s_q_bytes += size; + + /* Trim back to the configured byte budget. */ + while (s_q_bytes > (size_t)CONFIG_KASAN_QUARANTINE_SIZE + && s_q_tail != s_q_head + && n_evict < KASAN_Q_ENTRIES) { + evict[n_evict++] = s_q[s_q_tail].ptr; + s_q_bytes -= s_q[s_q_tail].size; + s_q_tail = (s_q_tail + 1U) % KASAN_Q_ENTRIES; + } + + portEXIT_CRITICAL(&s_q_mux); + + for (unsigned i = 0; i < n_evict; i++) { + kasan_q_release_one(evict[i]); + } +} + +#endif /* CONFIG_KASAN_QUARANTINE_SIZE > 0 */ + +bool kasan_heap_should_defer_free(void) +{ +#if CONFIG_KASAN_QUARANTINE_SIZE > 0 + return atomic_load_explicit(&s_q_defer_free[kasan_q_core_id()], + memory_order_acquire) > 0U; +#else + return false; +#endif +} + +void kasan_heap_clear_deferred_free(void) +{ +#if CONFIG_KASAN_QUARANTINE_SIZE > 0 + /* + * Consume one ticket. Wrapping below zero is treated as a programming + * error (call to clear() without matching enqueue from kasan_q_add). + */ + uint_fast32_t prev = atomic_fetch_sub_explicit(&s_q_defer_free[kasan_q_core_id()], + 1U, memory_order_release); + assert(prev > 0U && "kasan_heap_clear_deferred_free: counter underflow"); + (void)prev; +#endif +} + +/* ---- Heap hooks --------------------------------------------------------- */ + +void kasan_heap_alloc_impl(void *ptr, size_t size, uint32_t caps) +{ + (void)caps; + if (ptr == NULL || size == 0) { + return; + } + + /* + * Mark the full granule containing the tail of the user allocation as + * unconditionally valid (instead of "first N bytes valid"). Many libc + * memcpy / strlen / strcpy implementations on RISC-V perform word-at-a- + * time loads and may legitimately touch the bytes between the requested + * end and the next 4-byte boundary; treating those as a partial poison + * would flag a false positive. The actual right redzone still starts at + * the next granule boundary, so genuine overflows past 4 bytes are still + * detected. + */ + const size_t granule_aligned_size = (size + 3U) & ~(size_t)3U; + kasan_unpoison_region(ptr, granule_aligned_size); + +#if CONFIG_KASAN_HEAP_REDZONE_SIZE > 0 + uint8_t *lrz = (uint8_t *)ptr - KASAN_RZ; + kasan_lrz_hdr_t hdr = { .magic = KASAN_LRZ_MAGIC, .user_size = size }; + memcpy(lrz, &hdr, sizeof(hdr)); + kasan_poison_region(lrz, KASAN_RZ, KASAN_POISON_HEAP_LRZ); + + /* Start RRZ at the next granule boundary, not at user_ptr + size. */ + uint8_t *rrz = (uint8_t *)ptr + granule_aligned_size; + kasan_poison_region(rrz, KASAN_RZ, KASAN_POISON_HEAP_RRZ); +#endif +} + +void kasan_heap_free_impl(void *ptr) +{ + if (ptr == NULL) { + return; + } + +#if CONFIG_KASAN_HEAP_REDZONE_SIZE > 0 + kasan_lrz_hdr_t hdr; + uint8_t *lrz = (uint8_t *)ptr - KASAN_RZ; + memcpy(&hdr, lrz, sizeof(hdr)); + if (hdr.magic == KASAN_LRZ_MAGIC) { + size_t total = KASAN_RZ + hdr.user_size + KASAN_RZ; + kasan_poison_region(lrz, total, KASAN_POISON_HEAP_FREE); + } else { + size_t poison_size = heap_caps_get_allocated_size(ptr); + if (poison_size == 0) { + poison_size = 8; + } + kasan_poison_region(ptr, poison_size, KASAN_POISON_HEAP_FREE); + } +#else + size_t poison_size = heap_caps_get_allocated_size(ptr); + if (poison_size == 0) { + poison_size = 8; + } + kasan_poison_region(ptr, poison_size, KASAN_POISON_HEAP_FREE); +#endif + +#if CONFIG_KASAN_QUARANTINE_SIZE > 0 + size_t q_size = 8; +#if CONFIG_KASAN_HEAP_REDZONE_SIZE > 0 + { + kasan_lrz_hdr_t qhdr; + memcpy(&qhdr, (uint8_t *)ptr - KASAN_RZ, sizeof(qhdr)); + if (qhdr.magic == KASAN_LRZ_MAGIC) { + q_size = qhdr.user_size; + } + } +#endif + kasan_q_add(ptr, q_size); + /* + * Bump the per-core counter *after* the block is safely in the + * quarantine. This keeps in-progress heap_caps_free() invocations on + * the same core (including ISR-driven nested ones) in 1:1 lock-step with + * kasan_heap_clear_deferred_free() consumes, so neither the outer call + * nor the ISR-injected call can hand its pointer back to multi_heap_free + * after the block is already queued for deferred release. + */ + atomic_fetch_add_explicit(&s_q_defer_free[kasan_q_core_id()], 1U, + memory_order_release); +#endif +} + +#endif /* CONFIG_COMPILER_KASAN && CONFIG_HEAP_USE_HOOKS */ diff --git a/components/heap/heap_kasan_hooks.c b/components/heap/heap_kasan_hooks.c new file mode 100644 index 00000000000..42bb29a5d26 --- /dev/null +++ b/components/heap/heap_kasan_hooks.c @@ -0,0 +1,48 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ + +/* + * Strong (non-weak) definitions of the heap trace hooks for KASAN. + * + * IMPORTANT: This file must NOT include esp_heap_caps.h or any header that + * transitively includes it (e.g. freertos/idf_additions.h). The reason is + * that esp_heap_caps.h declares esp_heap_trace_alloc_hook and + * esp_heap_trace_free_hook with __attribute__((weak)), and GCC propagates the + * weak attribute to the definition in the same TU. By keeping this file free + * of that header we get strong (globally overriding) definitions that the + * linker will prefer over the empty weak stubs. + * + * The actual KASAN logic lives in heap_kasan.c; this file just calls into it. + */ + +#include "sdkconfig.h" + +#if CONFIG_COMPILER_KASAN && CONFIG_HEAP_USE_HOOKS + +#include +#include +#include "esp_kasan.h" + +/* Forward-declare the real implementation from heap_kasan.c */ +void kasan_heap_alloc_impl(void *ptr, size_t size, uint32_t caps); +void kasan_heap_free_impl(void *ptr); + +/* + * These definitions are strong because this TU never sees the weak declaration + * from esp_heap_caps.h. The linker will therefore use these in preference to + * the empty weak stubs generated by the heap component's own code. + */ +void esp_heap_trace_alloc_hook(void *ptr, size_t size, uint32_t caps) +{ + kasan_heap_alloc_impl(ptr, size, caps); +} + +void esp_heap_trace_free_hook(void *ptr) +{ + kasan_heap_free_impl(ptr); +} + +#endif /* CONFIG_COMPILER_KASAN && CONFIG_HEAP_USE_HOOKS */ diff --git a/components/heap/heap_kasan_layout.h b/components/heap/heap_kasan_layout.h new file mode 100644 index 00000000000..4d3728de217 --- /dev/null +++ b/components/heap/heap_kasan_layout.h @@ -0,0 +1,43 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#pragma once + +#include +#include "sdkconfig.h" + +/* + * Shared KASAN heap redzone layout helpers for heap_caps_base.c, heap_caps.c, + * and heap_kasan.c. + * + * Allocation layout (with CONFIG_HEAP_TASK_TRACKING=y): + * + * [ block-owner word ][ left redzone ][ user bytes ][ right redzone ] + * + * The user-visible pointer points at the start of the user region. + * KASAN_USER_TO_PTR / KASAN_USER_TO_RAW move back to the left redzone start; + * KASAN_PTR_TO_USER moves a block-owner-relative pointer to the user region. + */ + +#if CONFIG_COMPILER_KASAN && CONFIG_HEAP_USE_HOOKS && (CONFIG_KASAN_HEAP_REDZONE_SIZE > 0) +#define HEAP_KASAN_RZ_ENABLED 1 +#else +#define HEAP_KASAN_RZ_ENABLED 0 +#endif + +#if HEAP_KASAN_RZ_ENABLED +#define KASAN_RZ CONFIG_KASAN_HEAP_REDZONE_SIZE +#define KASAN_ADD_RZ(sz) ((sz) + 2 * KASAN_RZ) +#define KASAN_PTR_TO_USER(p) ((void *)((uint8_t *)(p) + KASAN_RZ)) +#define KASAN_USER_TO_PTR(p) ((void *)((uint8_t *)(p) - KASAN_RZ)) +#define KASAN_USER_TO_RAW(p) KASAN_USER_TO_PTR(p) +#else +#define KASAN_RZ 0 +#define KASAN_ADD_RZ(sz) (sz) +#define KASAN_PTR_TO_USER(p) (p) +#define KASAN_USER_TO_PTR(p) (p) +#define KASAN_USER_TO_RAW(p) (p) +#endif diff --git a/docs/en/api-reference/system/heap_debug.rst b/docs/en/api-reference/system/heap_debug.rst index f923efb57f3..ab359aae8d3 100644 --- a/docs/en/api-reference/system/heap_debug.rst +++ b/docs/en/api-reference/system/heap_debug.rst @@ -6,7 +6,7 @@ Heap Memory Debugging Overview -------- -ESP-IDF integrates tools for requesting :ref:`heap information `, :ref:`heap corruption detection `, and :ref:`heap tracing `. These can help track down memory-related bugs. +ESP-IDF integrates tools for requesting :ref:`heap information `, :ref:`heap corruption detection `, :ref:`kernel address sanitizer (KASAN) `, and :ref:`heap tracing `. These can help track down memory-related bugs. For general information about the heap memory allocator, see :doc:`Heap Memory Allocation `. @@ -195,6 +195,32 @@ Calls to :cpp:func:`heap_caps_check_integrity` or :cpp:func:`heap_caps_check_int - For allocated heap blocks, the behavior is the same as for the Light Impact mode. The canary bytes ``0xABBA1234`` and ``0xBAAD5678`` are checked at the head and tail of each allocated buffer, and any variation indicates a buffer overrun or underrun. +.. _heap-kasan: + +Kernel Address Sanitizer (KASAN) +-------------------------------- + +KASAN is a compiler-assisted heap and DRAM memory safety checker. When enabled, GCC instruments memory loads and stores with runtime checks against a shadow memory region. Violations (buffer overflows, underflows, use-after-free, etc.) are reported at the point of access. + +Enable it under ``Component config`` > ``Compiler options`` > ``Enable Kernel Address Sanitizer (KASAN)`` (see :ref:`CONFIG_COMPILER_KASAN`). The option is currently marked experimental: turn on ``Make experimental features visible`` (see :ref:`CONFIG_IDF_EXPERIMENTAL_FEATURES`) first. + +KASAN is most useful during development and debugging: + +- Detects out-of-bounds heap accesses via configurable allocation redzones (see :ref:`CONFIG_KASAN_HEAP_REDZONE_SIZE`) +- Can catch use-after-free when the freed-block quarantine is enabled (see :ref:`CONFIG_KASAN_QUARANTINE_SIZE`) +- Instruments most application and component code; low-level HAL/ROM/bootloader code is excluded automatically + +Trade-offs to keep in mind: + +- Code size for instrumented components typically grows by 1.5–3x +- Shadow memory reserves roughly 42–64 KiB of internal DRAM (target-dependent) +- Runtime overhead is significant; do not enable in production firmware + +KASAN uses its own heap hooks and redzone scheme. Do not enable heap poisoning at the same time — leave :ref:`CONFIG_HEAP_CORRUPTION_DETECTION` at ``Basic (no poisoning)`` (the default). + +For deliberate fault injection and regression testing, see the ``kasan_test`` application under ``tools/test_apps/system/kasan_test``. + + .. _heap-task-tracking: Heap Task Tracking diff --git a/docs/zh_CN/api-reference/system/heap_debug.rst b/docs/zh_CN/api-reference/system/heap_debug.rst index 370cc13751a..2fbef5cf556 100644 --- a/docs/zh_CN/api-reference/system/heap_debug.rst +++ b/docs/zh_CN/api-reference/system/heap_debug.rst @@ -6,7 +6,7 @@ 概述 -------- -ESP-IDF 集成了用于请求 :ref:`堆内存信息 `、:ref:`堆内存损坏检测 ` 和 :ref:`堆内存跟踪 ` 的工具,有助于跟踪内存相关错误。 +ESP-IDF 集成了用于请求 :ref:`堆内存信息 `、:ref:`堆内存损坏检测 `、:ref:`内核地址消毒器 (KASAN) ` 和 :ref:`堆内存跟踪 ` 的工具,有助于跟踪内存相关错误。 有关堆内存分配器的基本信息,请参阅 :doc:`堆内存分配 `。 @@ -195,6 +195,32 @@ ESP-IDF 集成了用于请求 :ref:`堆内存信息 `、:ref:` - 对于已分配的堆内存块,检测器的检查模式与轻量级模式相同,即在每个分配的缓冲区头部和尾部检查 canary 字节 ``0xABBA1234`` 和 ``0xBAAD5678``,检测到任何其他字节都表示缓冲区越界或下溢。 +.. _heap-kasan: + +内核地址消毒器 (KASAN) +---------------------- + +KASAN 是一种由编译器辅助的堆内存和 DRAM 内存安全检查工具。启用后,GCC 会为内存加载和存储操作插入运行时检查,对照影子内存区域进行校验。一旦发生违规访问(缓冲区溢出、下溢、释放后使用等),会在访问发生处立即报告。 + +可在 ``Component config`` > ``Compiler options`` > ``Enable Kernel Address Sanitizer (KASAN)`` 中启用该功能(参见 :ref:`CONFIG_COMPILER_KASAN`)。该选项目前标记为实验性功能,需先开启 ``Make experimental features visible``\ (参见 :ref:`CONFIG_IDF_EXPERIMENTAL_FEATURES`)。 + +KASAN 在开发和调试阶段最为有用: + +- 通过可配置的分配红区检测堆内存越界访问(参见 :ref:`CONFIG_KASAN_HEAP_REDZONE_SIZE`) +- 启用已释放内存块隔离队列后,可捕获释放后使用问题(参见 :ref:`CONFIG_KASAN_QUARANTINE_SIZE`) +- 会为大多数应用程序和组件代码插桩;底层 HAL/ROM/bootloader 代码会被自动排除 + +需要权衡的方面: + +- 插桩组件的代码大小通常会增加 1.5–3 倍 +- 影子内存会占用约 42–64 KiB 的内部 DRAM(取决于目标芯片) +- 运行时开销较大,请勿在量产固件中启用 + +KASAN 使用自己的堆内存钩子和红区方案。请勿同时启用堆内存毒化功能,应将 :ref:`CONFIG_HEAP_CORRUPTION_DETECTION` 保持为 ``Basic (no poisoning)``\ (默认值)。 + +如需进行人为故障注入和回归测试,请参阅 ``tools/test_apps/system/kasan_test`` 下的 ``kasan_test`` 应用程序。 + + .. _heap-task-tracking: 堆任务跟踪 diff --git a/tools/test_apps/system/.build-test-rules.yml b/tools/test_apps/system/.build-test-rules.yml index 25d6139d76c..e8ba21278e6 100644 --- a/tools/test_apps/system/.build-test-rules.yml +++ b/tools/test_apps/system/.build-test-rules.yml @@ -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"] diff --git a/tools/test_apps/system/kasan_test/CMakeLists.txt b/tools/test_apps/system/kasan_test/CMakeLists.txt new file mode 100644 index 00000000000..6943daf8ff2 --- /dev/null +++ b/tools/test_apps/system/kasan_test/CMakeLists.txt @@ -0,0 +1,4 @@ +cmake_minimum_required(VERSION 3.22) + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) +project(kasan_test) diff --git a/tools/test_apps/system/kasan_test/README.md b/tools/test_apps/system/kasan_test/README.md new file mode 100644 index 00000000000..4ab9c46d773 --- /dev/null +++ b/tools/test_apps/system/kasan_test/README.md @@ -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_noabort` and `__asan_store_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. diff --git a/tools/test_apps/system/kasan_test/main/CMakeLists.txt b/tools/test_apps/system/kasan_test/main/CMakeLists.txt new file mode 100644 index 00000000000..da67f868653 --- /dev/null +++ b/tools/test_apps/system/kasan_test/main/CMakeLists.txt @@ -0,0 +1,3 @@ +idf_component_register(SRCS "kasan_test_main.c" + INCLUDE_DIRS "." + PRIV_REQUIRES esp_system heap unity) diff --git a/tools/test_apps/system/kasan_test/main/kasan_test_main.c b/tools/test_apps/system/kasan_test/main/kasan_test_main.c new file mode 100644 index 00000000000..5c9a27e1230 --- /dev/null +++ b/tools/test_apps/system/kasan_test/main/kasan_test_main.c @@ -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 +#include +#include +#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_noabort and __asan_store_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(); +} diff --git a/tools/test_apps/system/kasan_test/pytest_kasan.py b/tools/test_apps/system/kasan_test/pytest_kasan.py new file mode 100644 index 00000000000..c39cb350d16 --- /dev/null +++ b/tools/test_apps/system/kasan_test/pytest_kasan.py @@ -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) diff --git a/tools/test_apps/system/kasan_test/sdkconfig.ci.halt b/tools/test_apps/system/kasan_test/sdkconfig.ci.halt new file mode 100644 index 00000000000..f6eebec33f8 --- /dev/null +++ b/tools/test_apps/system/kasan_test/sdkconfig.ci.halt @@ -0,0 +1,2 @@ +# Halt on panic so pytest can read the register dump (not KASAN-specific). +CONFIG_ESP_SYSTEM_PANIC_PRINT_HALT=y diff --git a/tools/test_apps/system/kasan_test/sdkconfig.ci.no_halt b/tools/test_apps/system/kasan_test/sdkconfig.ci.no_halt new file mode 100644 index 00000000000..6db147cbbd3 --- /dev/null +++ b/tools/test_apps/system/kasan_test/sdkconfig.ci.no_halt @@ -0,0 +1,2 @@ +# Run all tests in one boot cycle without aborting on errors +CONFIG_KASAN_NO_HALT=y diff --git a/tools/test_apps/system/kasan_test/sdkconfig.defaults b/tools/test_apps/system/kasan_test/sdkconfig.defaults new file mode 100644 index 00000000000..b9e1e321686 --- /dev/null +++ b/tools/test_apps/system/kasan_test/sdkconfig.defaults @@ -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