Emulating ESP32 on the QEMU fork
Contents:
The goal is to test ESP32 firmware. I know you can flash the fucking MCU with that and run it but for safety critical situations (or in any other sane person’s hands) this doesn’t make sense.
# Running espressif’s QEMU fork
- Install the deps:
- Get the submodules
Since I am running this for ESP32 (that is EXtensa and not a RISC-V build), we need to run: (from the fork’s readme for ESP32)
# The above are mentioned here at: https://wiki.qemu.org/Hosts/Linux. Note that this is around 700mb of disk space in total
Note that you can do this for the S2 and S3 boards too, the config command might change though. After this, we’ll need to build from source to get access to the xtensa CLI:
# From the QEMU root
This is the qemu-system-xtensa all done and built (inside /build/qemu-system-xtensa). Now we’ll have to build an ESP-IDF image and create a small firmware which we can push here and run inside this emulator.
# Installing ESP-IDF
I have already covered this in a previous blog about wifi stuff, but let’s go over it again:
- Get the pre-reqs
- I will go with the 5.5 release and not 6.0 (which is the latest at the time of writing). I have tried many older versions (with the hope that it will allow me to send management frames through their wifi adapter) and honestly I can’t tell the difference, but its better to go with an older version from latest.
- Install the toolchain for esp32. I just need to write a dummy script so doesn’t matter which chip I choose. This is going to create a python env and pull the
xtensa-esp32-elf.
# Once this finishes we can activate the environment
# Compile a sample project
Now we can compile projects with idf.py build. I am just going to compile and build the sample helloworld example:
This will populate the build/ directory with hello_world.elf. So the espressif QEMU fork is kinda different from renode, it won’t just load the firmware into memory, it’s going to go through the entire boot sequence. So, we need the entire build/ directory for this, not just the elf file.
idf.py build compiled three binaries:
- hello_world.bin
- bootloader/bootloader.bin
- partition_table/partition-table.bin
To get a runnable format for QEMU, we need to do what idf.py flash would do on a real silicon step by step. We need to then run:
@flash_args feeds in that offset map so you don’t hand-type addresses; merge_bin lays each binary at its offset into a single image and zero-fills the gaps; --fill-flash-size 4MB pads the whole thing out to a legal flash size (the esp32 machine only accepts 2/4/8/16 MB and rejects anything else). The result is qemu_flash.bin. This is what CAN go to the emulator and boot and run.
Once you do this, you just need to run this via qemu-system-xtensa. This will be in your qemu directory.
# Navigate to your qemu fork directory
# Only then do this
Then run it and witness the ESP32 running without an ESP32:
What happens if the binary was cooked? As in, the firmware we wrote was not just a vanilla hello-world but something with a big flaw.
- Replace the
hello_world_main.cfile inside the examples with this one (thanks to claude on this one):
/* ---- pick your poison: change this one line, then rebuild ---- */
/* -------------------------------------------------------------- */
static int
void
Just replace the hello_world_main.c with this and build again:
# Once this finishes
Then we again need to run the binary from the qemu fork:
# Then do
Alright, brace yourself for the incoming logs:
Adding SPI flash device
ets Jul 29 2019 12:21:46
rst:0x1 (POWERON_RESET),boot:0x12 (SPI_FAST_FLASH_BOOT)
configsip: 0, SPIWP:0xee
clk_drv:0x00,q_drv:0x00,d_drv:0x00,cs0_drv:0x00,hd_drv:0x00,wp_drv:0x00
mode:DIO, clock div:2
load:0x3fff0030,len:6280
load:0x40078000,len:15864
load:0x40080400,len:3920
entry 0x40080644
I (721) boot: ESP-IDF v5.5.4-1077-gdfe53e2090-dirty 2nd stage bootloader
I (723) boot: compile time Jun 17 2026 20:18:38
I (723) boot: Multicore bootloader
I (765) boot: chip revision: v0.0
I (770) boot.esp32: SPI Speed : 40MHz
I (770) boot.esp32: SPI Mode : DIO
I (770) boot.esp32: SPI Flash Size : 2MB
I (780) boot: Enabling RNG early entropy source...
I (794) boot: Partition Table:
I (794) boot: ## Label Usage Type ST Offset Length
I (795) boot: 0 nvs WiFi data 01 02 00009000 00006000
I (795) boot: 1 phy_init RF data 01 01 0000f000 00001000
I (796) boot: 2 factory factory app 00 00 00010000 00100000
I (800) boot: End of partition table
I (836) esp_image: segment 0: paddr=00010020 vaddr=3f400020 size=06bb0h ( 27568) map
I (862) esp_image: segment 1: paddr=00016bd8 vaddr=3ffb0000 size=02624h ( 9764) load
I (880) esp_image: segment 2: paddr=00019204 vaddr=40080000 size=06e14h ( 28180) load
I (906) esp_image: segment 3: paddr=00020020 vaddr=400d0020 size=0b7cch ( 47052) map
I (936) esp_image: segment 4: paddr=0002b7f4 vaddr=40086e14 size=05bb4h ( 23476) load
I (962) esp_image: segment 5: paddr=000313b0 vaddr=50000000 size=00020h ( 32) load
I (1024) boot: Loaded app from partition at offset 0x10000
I (1024) boot: Disabling RNG early entropy source...
I (1053) cpu_start: Multicore app
I (1806) cpu_start: GPIO 3 and 1 are used as console UART I/O pins
I (1814) cpu_start: Pro cpu start user code
I (1814) cpu_start: cpu freq: 160000000 Hz
I (1815) app_init: Application information:
I (1815) app_init: Project name: hello_world
I (1815) app_init: App version: v5.5.4-1077-gdfe53e2090-dirty
I (1816) app_init: Compile time: Jun 17 2026 20:18:35
I (1816) app_init: ELF file SHA256: 2f7618cc3...
I (1817) app_init: ESP-IDF: v5.5.4-1077-gdfe53e2090-dirty
I (1817) efuse_init: Min chip rev: v0.0
I (1817) efuse_init: Max chip rev: v3.99
I (1818) efuse_init: Chip rev: v0.0
I (1820) heap_init: Initializing. RAM available for dynamic allocation:
I (1822) heap_init: At 3FFAE6E0 len 00001920 (6 KiB): DRAM
I (1823) heap_init: At 3FFB2E88 len 0002D178 (180 KiB): DRAM
I (1824) heap_init: At 3FFE0440 len 00003AE0 (14 KiB): D/IRAM
I (1824) heap_init: At 3FFE4350 len 0001BCB0 (111 KiB): D/IRAM
I (1824) heap_init: At 4008C9C8 len 00013638 (77 KiB): IRAM
I (1864) spi_flash: detected chip: gd
I (1871) spi_flash: flash io: dio
W (1876) spi_flash: Detected size(4096k) larger than the size in the binary image header(2048k). Using the size in the binary image header.
I (1893) main_task: Started on CPU0
I (1903) main_task: Calling app_main()
reading through NULL...
Guru Meditation Error: Core 0 panic'ed (LoadProhibited). Exception was unhandled.
Core 0 register dump:
PC : 0x400d3603 PS : 0x00060130 A0 : 0x800db153 A1 : 0x3ffb4150
A2 : 0x00000003 A3 : 0x3f401dfc A4 : 0x3f401e70 A5 : 0x0000076f
A6 : 0x3f401dfc A7 : 0x3ffb4180 A8 : 0x00000000 A9 : 0x3ffb4130
A10 : 0x0000000a A11 : 0x3ffb4160 A12 : 0x3ffb4140 A13 : 0x0000000c
A14 : 0x400817d4 A15 : 0x00000002 SAR : 0x00000004 EXCCAUSE: 0x0000001c
EXCVADDR: 0x00000000 LBEG : 0x400014fd LEND : 0x4000150d LCOUNT : 0xfffffffa
Backtrace: 0x400d3600:0x3ffb4150 0x400db150:0x3ffb4180 0x400856f9:0x3ffb41b0
These are the logs for the NULL_DEREF. The EXCCAUSE: 0x1c is the integer 28 which is Xtensa’s LoadProhibitedCause. This means the CPU tried to read from an address that is not allowed. And EXCVADDR: 0x00000000 is the address it faulted on.
Guru Meditation Error: Core 0 panic'ed (LoadProhibited). Exception was unhandled. . What’s a Guru Meditation Error? Well it has a funny origin story, but it basically means a hard system crash.
So, because of this unhandled error, the system crashed hard.
# Lessons from the stack overflow
If we toggle the poison to F_STACK_OVERFLOW and observe the crash:
recursing with no base case...
Guru Meditation Error: Core 1 panic'ed (StoreProhibited). Exception was unhandled.
Core 1 register dump:
PC : 0x40085cda PS : 0x00050033 A0 : 0x40085b87 A1 : 0x3ffb47e0
A2 : 0xbb2e7d50 A3 : 0x00000000 A4 : 0x00010d32 A5 : 0x3ffb1480
A6 : 0x00000001 A7 : 0x00000000 A8 : 0x400829ae A9 : 0x3ffaf550
A10 : 0x00000001 A11 : 0x00000000 A12 : 0x80085e95 A13 : 0x3ffb1470
A14 : 0x3ffb2b94 A15 : 0x00000001 SAR : 0x00000000 EXCCAUSE: 0x0000001d
EXCVADDR: 0xbb2e7d50 LBEG : 0x00000000 LEND : 0x00000000 LCOUNT : 0x00000000
Backtrace: 0x40085cd7:0x3ffb47e0 0x40085b84:0x3ffb47f0 0x0006001e:0xa5a5a5a5 |<-CORRUPTED
Now while this looks similar, its something very different. The basics are the same:
EXCCAUSE: 0x1d-> Value 19 ->StoreProhibitedEXCVADDR: 0xbb2e7d50-> The address of the fault. Note thatA2also holds the same value. In this case its probably a garbage value (cause we’ll see why).
The backtrace is more interesting, there is pattern to this: 0x0006001e:0xa5a5a5a5 |<-CORRUPTED. The 0xa5a5a5a5 is FreeRTOS’s stack fill pattern. When a stack is created it is filled with 0xa5 - so there is already a known pattern in the stack. Seeing 0xa5a5a5a5 where a saved SP should be is the proof of overflow: the stack pointer ran off the end of its allocated region into the virgin fill bytes.
The interesting thing to note here is that the stack overflow happened at Core 1 and not Core 0 (the previous fault, NULL address reading was on Core 0).
# Explanation
This is getting too long, so head over to understanding-buffer-overflows-with-FreeRTOS