An earlier article, From the Binary, Not the Build Plan, made a point that a software bill of materials is a claim about a build, while the shipped device is the only authoritative record of itself. This article shows how it works in practice. It takes one real ESP32 image, works from the binary alone, and shows what that recovers, where it is more reliable than the declared inventory, where to concentrate the effort that follows, and how much of that groundwork can be automated.

The target is deliberately non-exciting. It is the prebuilt ESP-Hosted firmware that ESPHome ships for its ESP32 Hosted component, the widely used arrangement where one ESP32 acts as a Wi-Fi and Bluetooth radio for a separate host processor. Since its source is publicly available, it makes a suitable target, and you as a reader can verify the claims. Everything below is reproducible, and nothing here is a vulnerability disclosure. The point is not to highlight firmware flaws, but to show what a binary captures that an SBOM usually leaves out.

The standard toolchain finds nothing

Start where a market surveillance authority or a supplier doing due diligence starts. With the flash dump and nothing else. Run the tools that generate an SBOM from a filesystem, and this is the result on a 993 KB ESP32 image.

$ syft network_adapter_esp32.bin
 no packages discovered

$ binwalk -e network_adapter_esp32.bin
 0 files extracted

This is not a misconfiguration, and it is no fault of the tools. A microcontroller firmware is a single binary blob. There is no filesystem to walk, no package manager database, no shared objects with version strings. SBOM tools are built for filesystem-based platforms, where software arrives as packages that carry their own metadata, and a firmware image carries almost none of that. Plain strings does a better job by scraping a few leaked source paths, but it is still a fraction of what is actually in the image.

Reading Xtensa from the bytes up

The image is Xtensa, the architecture behind the classic ESP32. That is not the newest thing Espressif builds. The recent parts have moved to RISC-V, and the original ESP32 is now marked not recommended for new designs. That does not make it historical. It shipped in a vast number of connected products that are still in the field and still under support, and the Xtensa line lives on in the ESP32-S3. For a regulation tied to a product's supported life, being able to handle a platform like this is a capability worth having.

Close-up of a black ESP32-S3 development board on a blue mat, showing the square ESP32-S3 SoC beside a Winbond SPI flash chip, a U.FL antenna connector and gold castellated pin headers.
An ESP32-S3, the Xtensa part still in production, on a development board. Next to it, the Winbond chip is the external SPI flash a firmware image is read from.

With the symbols gone, the first job is simply to find the functions. Xtensa's windowed calling convention leaves a recognisable prologue at the start of most functions, so espfw scans for that entry pattern and then walks the control flow from each one rather than sweeping the bytes linearly. On this image that recovers several thousand functions from a fully stripped binary, with no vendor analysis engine involved.

But finding functions is not the same as knowing what they are. The same function, compiled twice, might come out slightly different at the byte level, mostly because the compiler is free to hold values in different registers each time. So each function is reduced to a canonical form that ignores those register choices, and then matched against the same functions compiled from known SDK sources. A unique match gives the function a name.

Matching functions by a normalised signature is an old idea. IDA's FLIRT has done it since the 1990s, and every serious reverse-engineering tool has a version of it. What makes espfw's approach slightly different is that it brings a corpus built for this exact problem. It holds the ESP-IDF SDK compiled across its versions and common configurations. The canonicalisation is tuned to how Xtensa code varies from one build to the next. And each match records which of those builds it came from.

$ espfw symbols network_adapter_esp32.bin

network_adapter_esp32.bin  chip esp32
corpus     : 22 build(s), 12 version(s)
matched    : 2,032 of 4,725 functions (43.0%)

ELF ADDRESS  IMAGE OFFSET  SIZE   SYMBOL  CORPUS SOURCE(S)
0x400efc2c  0x0005fc2c       24  hmac_sha1_vector  25:v5.4/default, 26:v5.4/size
0x400f028c  0x0006028c       27  fastpbkdf2_hmac_sha1  26:v5.4/size
0x400f4478  0x00064478       18  mbedtls_cipher_init  25:v5.4/default, 26:v5.4/size, 43:v6.0/default, 44:v6.0/size, 45:v6.1/default, 46:v6.1/size
0x40083dec  0x000e042c       12  spi1_start  42:v5.5.2/size, 44:v6.0/size, 55:v5.5.3/size, 56:v5.5.5/size
0x400850cc  0x000e170c      322  pp_post  45:v6.1/default, 46:v6.1/size, 56:v5.5.5/size
0x4008474c  0x000e0d8c      165  coex_core_request  55:v5.5.3/size, 56:v5.5.5/size
0x400846cc  0x000e0d0c       84  coex_core_ts_end  56:v5.5.5/size

In this case, more than 2,000 functions are matched, and their names alone tell you most of what is in there. The SHA and PBKDF2 primitives behind Wi-Fi key derivation, the mbedTLS cipher setup, the flash driver, the packet-processing and radio-coexistence cores. These are functions that would otherwise be unnamed, recovered before you start the work that really matters.

For each function, the last column lists every corpus build it matches, and no single row is decisive. Some functions pin to one build, like coex_core_ts_end to v5.5.5. Others carry little version signal and match an older or neighbouring build instead. So you read the aggregate, not a row. Across all 2,000 functions the matches cluster on the 5.5 line, with v5.5.5 on top, and that is the version the image reports in its own header. Two independent signals landing on one release is what turns a reading into a measurement.

The 43% match is a floor, not a ceiling. espfw names a function only on a match in its corpus of known SDK builds, and this firmware's own code is not in that corpus. So the stock SDK gets subtracted, and what remains is the code worth a closer look. espfw helps you locate that code, drawing the executable segments as a coverage map and listing the largest regions no known component claims.

layout: █ identified  ▒ inferred from neighbours  · unidentified
  iram  0x40080000  ·············████████████···▒▒·██·····████████████████████··▒▒▒·   90,440 bytes
  irom  0x400d0020  ··············▒▒▒▒▒███████████████████·▒▒▒▒▒▒▒▒▒▒▒▒·██████·█·▒█  637,532 bytes

regions no component claims (>= 2 KB), largest first:
  START       END         BYTES    FUNCS  BETWEEN
  0x400d8734  0x400e759e   61,034    517  vfs | esp_driver_gpio
  0x400f1ccc  0x400f585e   15,250    137  esp_driver_sdio | mbedcrypto
  0x400eae28  0x400edf26   12,542     96  esp_hw_support | nvs_flash
  0x400e8118  0x400e9c46    6,958     50  esp_partition | spi_flash
  0x4008b6d4  0x4008d182    6,830     33  hal | log
  0x400d59d8  0x400d7474    6,812     44  newlib | esp_driver_uart
  0x4014f340  0x40150b35    6,133     47  wpa_supplicant | core
  0x40130324  0x40131a07    5,859     32  esp_phy | lwip
  0x401658b8  0x40166b23    4,715     22  esp_event | esp_system
  0x400ee96c  0x400efbcc    4,704     44  esp_netif | wpa_supplicant
  0x400d7520  0x400d85d4    4,276     42  esp_vfs_console | vfs
  0x400d34e4  0x400d451e    4,154     36  esp_system | heap

The biggest is 61 KB and 517 functions, sitting between the VFS layer and the GPIO driver. Nothing in the corpus explains it, which puts it high on the list of areas to investigate.

The floor has a second cause, and it is not specific to ESP-IDF. RTOS and bare-metal SDKs are configured at compile time, because firmware runs under tight resource budgets and a build strips out what it does not use. ESP-IDF is no different. Its source is threaded with conditional blocks that the build keeps or drops according to the sdkconfig the engineers chose. A function's compiled shape therefore depends not only on its source but on that configuration, and the canonical form espfw computes absorbs a compiler's register choices, not code that a different config compiled in or out. So when the target was built with a configuration espfw holds no matching build for, its config-sensitive functions quietly fall into the unmatched pile. The ones that survive are the config-insensitive functions, enough to anchor the identification, not enough to lift coverage much past that floor.

The value is not the list of what it found. It is knowing what to look at next, and where. Identifying the SDK half augments the analysis and shifts the focus onto the smaller, more interesting remainder. The firmware's own logic, and, more quietly, any library function someone changed. That is the part no bill of materials will ever describe.

The build identifies itself

The image also states what it is outright. Before any of the matching, espfw reads its header and the embedded metadata back out.

$ espfw flash-info network_adapter_esp32.bin

network_adapter_esp32.bin  993,248 bytes  sha256:c0f05a4b06e4ed92...
input kind : app_partition
chip       : esp32
app: offset 0x0, entry 0x400811e8, 993,248 bytes selected
  app: eh_cp_bt_wifi_hosted_hci_mcu 1, IDF v5.5.5, built Jul 27 2026 16:29:02
  0x3f400020   241,380  data  drom      image+0x20
  0x3ffbdb60    20,748  data  dram      image+0x3af0c
  0x400d0020   637,532  code  irom      image+0x40020
  0x3ffc2c6c     2,996  data  dram      image+0xdba84
  0x40080000    90,440  code  iram      image+0xdc640
  0x50000000        32  data  rtc_data  image+0xf2790

In one command the image gives up its chip, its project name, the exact framework it was built against, ESP-IDF 5.5.5, its build date, its entry point and its full memory layout. None of this was asserted about the file. It was measured from it.

That distinction is important. A bill of materials might list ESP-Hosted, and the ESP-IDF version it is built on. What it does not include is the compile-time configuration those versions were built with, and on an embedded target that is often what decides the security posture. The binary does not declare it either, but it was built from it, so it can be recovered through reverse-engineering rather than taken on trust.

Binary-first is not guessing

The reasonable objection is that matching compiled functions is inference, and inference can be wrong. The risk that matters is a firmware's own function labelled as an SDK function and potentially overlooked. espfw guards against that by naming a function only on a unique match.

Since this target is open source, names can be verified. The ones that mattered were checked against the upstream source and confirmed accurate.

Working from the binary is not a lesser substitute for reading the source, and it is not a replacement for the build-side discipline either. It is a complementary measurement, and in some ways a stronger one, because the source and the SBOM describe what engineering intended while the binary describes what the product actually is. The two answer different questions. Whether you built it right, and whether the thing you built is the thing that shipped. A serious programme wants both. Where they agree, you have certainty. Where they disagree, the binary wins, because the binary is what shipped.

Built to be driven by an agent

There is a lot of repetitive work in the above. Carving thousands of functions, applying 2,000 recovered symbols, triaging the remainder for the handful of functions worth reading closely, annotating results back into the reverse-engineering platform. It is tedious work, and espfw is built to help automate that process.

That is a design choice, not an afterthought. Every command emits structured, machine-readable output rather than a wall of text. The results carry their own provenance, a per-function confidence score, and an explicit list of what did not match. Each stage is a discrete step with a stable interface, the flash-info and symbols output above being two of them. Output shaped like that is equally legible to a person and to an automated agent, which can call espfw, read the result, decide the next step, and drive a reverse-engineering process through the same tools an analyst would use.

Getting the image into a disassembler at all is the first thing espfw does. A raw flash dump is a seemingly flat blob with no notion of where each byte lives when the chip runs it, which is not something an RE platform can typically load and reason about. espfw reads the segment map shown earlier and writes the image back out as a proper ELF, each segment a section fixed at its true runtime address and the code marked executable. That reconstructed ELF is what everything downstream analyses, and it is what turns a stripped dump into an ELF a disassembler can work with.

flash.bin · file offsetELF · runtime addressespfw rebuilds the flat image into addressed sectionsdromimage+0x20236 KBdatadramimage+0x3af0c20 KBdatairomimage+0x40020623 KBcodedramimage+0xdba843 KBdatairamimage+0xdc64088 KBcodertcimage+0xf279032 Bdatadrom0x3f400020datadram0x3ffbdb60datadram0x3ffc2c6cdatairam0x40080000execirom0x400d0020execrtc0x50000000datacode, marked executabledata
A raw flash dump is a flat run of bytes. espfw reads the segment map and rebuilds it as an ELF, placing each segment at its true runtime address and marking the code executable, which is what a disassembler needs.

The tool

MKR Labs is releasing espfw, the function-identification tool at the centre of this. It carves functions from a stripped Xtensa image, canonicalises them, and matches them against known SDK builds. Naming the SDK code puts a spotlight on the rest, the non-vendor code, far smaller than the whole image, and the part that deserves the most attention. It is research code, published as method rather than product, with no support promise and no roadmap. That is as far as espfw goes. Deciding what the remaining code means is the work MKR Labs does for clients.

That work goes beyond the firmware. Processing firmware the way this article does is one step in a product security assessment, an important one, but a step. The full assessment runs the whole of a connected device, the physical hardware and its debug interfaces, the firmware, the companion app, and the backend it talks to. The flaws worth finding often sit on the boundaries between those layers, where no single team owns the responsibility. espfw is what makes the firmware layer manageable.

If there is one line to take from this, it is that a stripped firmware image is not a black-box. When processed properly, it is a document, and it is the most honest one a product has, because it is the thing that shipped. espfw is a good way to start recovering that document.

References and reproduction

  • espfw, the ESP32 Xtensa function-identification tool, released open source by MKR Labs.
  • ESP-Hosted firmware, the open-source project analysed here. The behaviour described is its documented upstream default and was confirmed against the source at the matching release.
  • ESP-Hosted for ESPHome, the distribution the analysed image comes from.
  • From the Binary, Not the Build Plan, the companion piece that argues the position this one demonstrates.
  • Regulation (EU) 2024/2847, the Cyber Resilience Act. The SBOM obligation and the essential requirements are in Annex I.