ESP32 Security/ ESP32 NVS Forensics / Flash Dump with esptool

Flash Dump with esptool

practical

HARDWARE REQUIRED

This lesson requires a physical ESP32 devkit connected to your computer via USB. Any ESP32 variant works: original ESP32, ESP32-S3, ESP32-C3, ESP32-C6. The commands shown are identical across variants — esptool auto-detects the chip type.


Prerequisites

Before connecting your board, confirm these are installed and working:

# Python 3 (3.7 or newer)
python3 --version

# esptool — install if missing
pip install esptool

# Verify esptool is available
esptool.py version
# Expected output: esptool.py v4.x.x

USB driver requirement: ESP32 devkits use a USB-to-UART bridge chip to expose the serial interface over USB. The chip varies by board manufacturer:

Bridge Chip Boards That Use It Driver
CP2102 / CP2104 NodeMCU, many clone devkits Usually auto-installs on Linux/macOS. Windows: Silicon Labs CP210x driver
CH340 / CH341 Many cheap Chinese devkits Linux: built-in. macOS/Windows: CH340 driver from WCH
FTDI FT232 Adafruit Feather, some Espressif boards Usually auto-installs

On Linux, the device will appear as /dev/ttyUSB0 or /dev/ttyACM0. On macOS: /dev/cu.usbserial-* or /dev/cu.wchusbserial*. On Windows: COM3, COM4, etc.

Linux permission check: If esptool gives a permission error on Linux, add yourself to the dialout group:

sudo usermod -aG dialout $USER
# Log out and back in for the change to take effect
# Or for a single session:
sudo chmod 666 /dev/ttyUSB0

Identifying Your Device

Before reading anything, identify the connected chip. This step verifies your connection and tells you the flash size.

esptool.py --port /dev/ttyUSB0 flash_id

Expected output for a standard ESP32 with 4MB flash:

esptool.py v4.7.0
Serial port /dev/ttyUSB0
Connecting....
Detecting chip type... Unsupported detection protocol, switching and trying again...
Detecting chip type... ESP32
Chip is ESP32-D0WD-V3 (revision v3.1)
Features: WiFi, BT, Dual Core, 240MHz, VRef calibration in efuse, Coding Scheme None
Crystal is 26MHz
MAC: a8:42:e3:f1:0c:2d
Uploading stub...
Running stub...
Stub running...
Manufacturer: ef
Device: 4016
Detected flash size: 4MB
Flash type set in eFuse: AUTO
Hard resetting via RTS pin...

Key fields to read:

  • Detected flash size — tells you how many bytes to read. Common values: 4MB (0x400000), 8MB (0x800000), 16MB (0x1000000).
  • Chip type — ESP32, ESP32-S3, ESP32-C3, etc. Affects memory map details.
  • Manufacturer / Device — identifies the flash chip vendor. ef = Winbond. 20 = XMC/Micron. c8 = GigaDevice.

Entering Download Mode

Most ESP32 devkits with a USB bridge enter download mode automatically when esptool opens the serial port — it pulses the DTR and RTS lines to trigger the boot sequence.

If auto-reset fails and esptool hangs at Connecting.....:

Manual boot mode entry:

  1. Hold down the BOOT button (also labeled IO0 or GPIO0 on some boards)
  2. While holding BOOT, briefly press and release the EN button (reset)
  3. Release the BOOT button
  4. The chip is now in download mode — run esptool within a few seconds

On some boards, the buttons are not labeled. The BOOT button pulls GPIO0 low. The EN button is connected to the CHIP_EN (chip enable / reset) pin. Consult your specific board schematic if unlabeled.


Reading the Full Flash

Once the chip is identified, read the complete flash image. The baud rate 921600 is the maximum reliable speed for most setups — if you encounter read errors, drop to 460800 or 230400.

4MB flash (most common):

esptool.py --port /dev/ttyUSB0 --baud 921600 read_flash 0 0x400000 full_dump.bin

8MB flash:

esptool.py --port /dev/ttyUSB0 --baud 921600 read_flash 0 0x800000 full_dump.bin

16MB flash:

esptool.py --port /dev/ttyUSB0 --baud 921600 read_flash 0 0x1000000 full_dump.bin

The read_flash command takes three arguments: start address (hex or decimal), byte count (hex or decimal), and output filename.

Expected progress output:

Reading 4194304 bytes at 0x0 in 56 seconds (slowdown caused by pyserial)...
Read 4194304 bytes at 0x0 in 56.4 seconds, 74.3 kB/s.
Hash of data verified.

At 921600 baud, a 4MB read takes roughly 45–90 seconds depending on the USB bridge chip and host system.


Verifying the Dump

A corrupted read is worse than no read — you will waste time analyzing garbage data. Always verify with a second read and compare checksums.

# Read again to a second file
esptool.py --port /dev/ttyUSB0 --baud 921600 read_flash 0 0x400000 full_dump2.bin

# Compare checksums — both must match
md5sum full_dump.bin full_dump2.bin

Expected output:

d4f3a8c1e9b2701f6e3d5a8c2b1f9e4a  full_dump.bin
d4f3a8c1e9b2701f6e3d5a8c2b1f9e4a  full_dump2.bin

If the checksums differ: - Try a lower baud rate: --baud 460800 - Check the USB cable (charge-only cables have no data lines) - Try a different USB port (avoid hubs, connect directly to the host) - On the ESP32-S3 or ESP32-C3 with native USB, ensure you are using the correct USB port (some boards have two: one for JTAG/USB-OTG and one for the bridge chip)


Reading Only the NVS Partition

If you already know the NVS offset and size (from the default partition table or from prior partition table analysis), you can read just the NVS region directly. This is faster and produces a smaller file.

Default partition table NVS location:

esptool.py --port /dev/ttyUSB0 read_flash 0x9000 0x6000 nvs.bin

This reads 24576 bytes (6 NVS pages) starting at offset 0x9000. The resulting nvs.bin file is ready for direct input to nvs-tool.

Why read only NVS: - Speed: a 24KB read completes in under 1 second vs 60+ seconds for a full 4MB dump - Useful on-site when time is limited - Still captures all 6 NVS pages including potentially recoverable deleted entries

When to read the full flash instead: - You do not know the partition table layout (custom firmware) - You want to capture all partitions (SPIFFS, LittleFS, OTA partitions, nvs_keys) - You want a complete forensic image for analysis and archival


Practical Workflow

Here is the complete command sequence for a first-time flash extraction from an unknown ESP32 device:

# Step 1: Identify the chip and note flash size
esptool.py --port /dev/ttyUSB0 flash_id

# Step 2: Read full flash (adjust size from step 1 output)
esptool.py --port /dev/ttyUSB0 --baud 921600 read_flash 0 0x400000 full_dump.bin

# Step 3: Verify with second read
esptool.py --port /dev/ttyUSB0 --baud 921600 read_flash 0 0x400000 full_dump2.bin
md5sum full_dump.bin full_dump2.bin

# Step 4: Inspect the first 64 bytes — should not be all 0xFF (erased)
xxd full_dump.bin | head -4

# Step 5: Check for the NVS magic at expected offset
xxd -s 0x9000 -l 32 full_dump.bin
# Look for: a5 a5 a5 a5 at offset 0x9000

If offset 0x9000 shows a5 a5 a5 a5, you have a live NVS partition to analyze. If it shows ff ff ff ff, either the device has never written to NVS, or the partition table places NVS at a different offset (proceed to Lesson 3 to read the partition table).


Troubleshooting Common Errors

A fatal error occurred: Failed to connect to ESP32: Timed out waiting for packet header

The chip did not enter download mode. Try manual BOOT+EN sequence. Check that the USB cable has data lines (not charge-only). Try --before default_reset or --before no_reset flags.

A fatal error occurred: Invalid head of packet (0x00): Possible serial noise or corruption

Baud rate mismatch or noise on the serial line. Try --baud 115200. Check for a competing serial monitor open on the same port (close Arduino IDE, PlatformIO monitor, etc.).

Read 0 bytes or partial read

Usually a connection issue during the read. Lower the baud rate and ensure nothing else is accessing the serial port.

Permission denied on /dev/ttyUSB0 (Linux)

Run sudo chmod 666 /dev/ttyUSB0 for the current session or add your user to the dialout group permanently.


What You Now Have

A full_dump.bin file is a complete, raw byte-for-byte image of the ESP32's SPI flash. Everything is in there: the bootloader, partition table, application firmware, and all data partitions including NVS. In the next lessons you will navigate this image to find and decode the NVS content.