ESP32 Security/ ESP32 NVS Forensics / Partition Table Analysis

Partition Table Analysis

practical
🔧
Required hardware

The Partition Table's Role

The ESP32 flash is not a raw filesystem — it is divided into discrete regions, each with a purpose. The partition table is the map that defines these regions: their names, types, starting offsets, and sizes. Without reading the partition table, you are making assumptions about where NVS lives.

The default ESP-IDF partition layout places NVS at 0x9000. But custom firmware can place it anywhere. A product using a custom partition table might put NVS at 0x5000, 0x20000, or use multiple NVS partitions. If you read 24KB at 0x9000 and find all 0xFF, the NVS is almost certainly at a different offset — and the partition table will tell you exactly where.

The partition table itself lives at a fixed address: 0x8000 (32768 bytes from the start of flash). This address is hardcoded in the ESP32 ROM — it cannot be changed. The bootloader reads the partition table from 0x8000 on every boot.


Reading the Partition Table from a Live Device

If you have the device connected via USB, read the partition table directly:

esptool.py --port /dev/ttyUSB0 read_flash 0x8000 0x1000 ptable.bin

This reads 4096 bytes starting at 0x8000 — one full flash sector containing the entire partition table. The actual table data is far smaller (32 bytes per entry), but reading a full sector ensures you capture any metadata at the beginning.


Reading the Partition Table from a Full Dump

If you already have a full flash dump (from Lesson 2), extract the partition table without touching the device:

dd if=full_dump.bin bs=1 skip=$((0x8000)) count=$((0x1000)) of=ptable.bin

The skip value uses bash arithmetic expansion to convert the hex offset to decimal for dd. You can also use a hex offset directly with newer versions of dd using skip=0x8000 iflag=skip_bytes count=0x1000 iflag=count_bytes.


Parsing the Partition Table

Using gen_esp32part.py (Recommended)

The Espressif tool gen_esp32part.py is the canonical parser:

# If you have ESP-IDF installed:
python3 $IDF_PATH/components/partition_table/gen_esp32part.py ptable.bin

# If you installed esp-idf-monitor via pip (includes the tool):
pip install esp-idf-monitor
gen_esp32part.py ptable.bin

# Alternative: use esptool's partition table display
python3 -m esptool --port /dev/ttyUSB0 get_security_info  # ESP32-S3/C3 only

Using esptool's built-in partition display

For connected devices:

# Read and immediately display (requires ESP-IDF tools)
esptool.py --port /dev/ttyUSB0 read_flash 0x8000 0x1000 - | python3 -m esptool.bin_image

Manual parsing with xxd

Every partition entry is exactly 32 bytes, starting at byte 0 of the partition table sector. Entries use a fixed format:

Bytes   Content
──────────────────────────────────────────────
0–1     Magic: 0xAA 0x50
2       Type (0x00=app, 0x01=data)
3       Subtype (0x00=factory, 0x01=ota_0, ... for app; 0x02=nvs, 0x82=nvs_keys for data)
4–7     Offset in flash (little-endian uint32)
8–11    Size in bytes (little-endian uint32)
12–27   Label (null-padded ASCII string, max 16 chars)
28–31   Flags (0x0001 = encrypted, otherwise 0x0000)
xxd ptable.bin | head -20

Look for the aa 50 magic at the start of each 32-byte block:

00000000: aa50 0200 0090 0000 6000 0000 6e76 7300  .P......`...nvs.
00000020: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000040: aa50 0100 00f0 0000 0010 0000 7068 795f  .P..........phy_

Breaking down the first entry (aa50 0200 0090 0000 6000 0000 6e76 7300): - aa 50 — partition magic - 02 — type 0x02 = data - 00 — subtype 0x00 = nvs... wait, actually subtype 0x02 = nvs. Let's be precise: the raw byte here is 02 for the subtype field.

In practice, use gen_esp32part.py and read the formatted output.


Reading the Formatted Output

gen_esp32part.py produces CSV-like output with column headers:

# Espressif ESP32 Partition Table
# Name, Type, SubType, Offset, Size, Flags
nvs,      data, nvs,      0x9000,   0x6000,
phy_init, data, phy,      0xf000,   0x1000,
factory,  app,  factory,  0x10000,  0x300000,
nvs_key,  data, nvs_keys, 0x310000, 0x1000,
storage,  data, spiffs,   0x311000, 0xef000,

What each column means for your analysis:

Column What to Note
Name Human-readable label, set by the developer
Type app = application code, data = data partition
SubType Specifies the partition's purpose (see table below)
Offset Where this partition starts in flash
Size How many bytes this partition occupies
Flags encrypted if the partition uses Flash Encryption

Data Partition Subtypes (the ones you care about)

SubType Hex Meaning
nvs 0x02 NVS key-value store
phy 0x01 RF/PHY calibration data
nvs_keys 0x04 NVS encryption key material
fat 0x81 FAT filesystem
spiffs 0x82 SPIFFS filesystem
littlefs 0x83 LittleFS filesystem
coredump 0x03 Panic core dump

The nvs_keys Partition

If your partition table contains an entry with subtype nvs_keys, this is critical information: NVS encryption is in use.

nvs_key, data, nvs_keys, 0x310000, 0x1000,

The nvs_keys partition stores the 32-byte AES-XTS key used to encrypt the NVS partition. This partition is itself 4KB (one flash sector). Its format:

Offset   Size    Content
────────────────────────────────────────
0x000    32B     AES key for NVS encryption
0x020    32B     HMAC key (optional, used with HMAC-based NVS encryption)
...      rest    0xFF padding

If Flash Encryption is not enabled, the nvs_keys partition is readable in plaintext via esptool. You can extract the key and decrypt NVS.

If Flash Encryption is enabled, reading the nvs_keys partition over UART will return ciphertext (the flash read interface transparently decrypts, but only the chip itself can do that). The key is not accessible without hardware-level eFuse attacks.

To check if Flash Encryption is enabled on a connected device:

esptool.py --port /dev/ttyUSB0 get_security_info
# Look for: Flash Encryption: enabled/disabled
# Also shows Secure Boot status

Extracting Any Partition from a Full Dump

Once you know offsets and sizes from the partition table, use dd to extract any partition:

# General form
dd if=full_dump.bin bs=1 skip=$((OFFSET)) count=$((SIZE)) of=output.bin

# NVS at 0x9000, size 0x6000
dd if=full_dump.bin bs=1 skip=$((0x9000)) count=$((0x6000)) of=nvs.bin

# nvs_keys at 0x310000, size 0x1000
dd if=full_dump.bin bs=1 skip=$((0x310000)) count=$((0x1000)) of=nvs_keys.bin

# SPIFFS/storage partition at 0x311000, size 0xef000
dd if=full_dump.bin bs=1 skip=$((0x311000)) count=$((0xef000)) of=storage.bin

The $((0x...)) syntax performs shell arithmetic to convert hex to decimal, which dd skip= expects. Alternatively, use Python to compute the decimal values:

python3 -c "print(0x9000)"   # → 36864
python3 -c "print(0x6000)"   # → 24576

Interesting Partitions Beyond NVS

While NVS is the primary target for credential extraction, other partitions can contain valuable forensic data:

storage (SPIFFS or LittleFS)

Many ESP32 applications use a secondary filesystem partition for larger files: HTML pages, TLS certificates, configuration files, or log data. These filesystems can be mounted using:

# SPIFFS: use mkspiffs tool
pip install mkspiffs  # or build from source
mkspiffs -u ./spiffs_extracted -b 4096 -p 256 -s $((0xef000)) storage.bin

# LittleFS: use littlefs-python
pip install littlefs-python
python3 -c "
import littlefs
fs = littlefs.LittleFS(block_size=4096, block_count=239)
with open('storage.bin', 'rb') as f:
    fs.context.buffer = bytearray(f.read())
fs.mount()
for path in fs.listdir('/'):
    print(path)
"

otadata

The OTA data partition records which OTA slot (ota_0 or ota_1) is currently active. Reading this tells you which firmware version is running without booting the device.

coredump

If present and populated, the core dump partition contains a snapshot of RAM state from the last panic/crash. This can reveal runtime data including NVS-cached values held in heap memory.


Putting It Together

# Full workflow: partition table → identify NVS → extract NVS

# 1. Read partition table from live device
esptool.py --port /dev/ttyUSB0 read_flash 0x8000 0x1000 ptable.bin

# 2. Parse it
gen_esp32part.py ptable.bin

# 3. Note NVS offset and size from output (e.g., 0x9000 / 0x6000)
# Note nvs_keys offset if present (e.g., 0x310000 / 0x1000)

# 4. Extract NVS from full dump
dd if=full_dump.bin bs=1 skip=$((0x9000)) count=$((0x6000)) of=nvs.bin

# 5. If nvs_keys partition exists, extract it too
dd if=full_dump.bin bs=1 skip=$((0x310000)) count=$((0x1000)) of=nvs_keys.bin

# 6. Verify NVS magic at page 0
xxd -l 4 nvs.bin
# Expected: a5 a5 a5 a5

If xxd -l 4 nvs.bin shows a5 a5 a5 a5, you have a valid NVS partition. Proceed to Lesson 4 to decode it.


The Partition Table as Evidence

The presence or absence of specific partitions tells a story. A nvs_keys partition that is unprotected (no Flash Encryption) means the developer intended to use NVS encryption but left the key readable. A large storage partition suggests significant local data storage — likely worth extracting. A coredump partition may contain forensic artifacts from crashes. Read the partition table first. Always.