Partition Table Analysis
practicalThe 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_keyspartition that is unprotected (no Flash Encryption) means the developer intended to use NVS encryption but left the key readable. A largestoragepartition suggests significant local data storage — likely worth extracting. Acoredumppartition may contain forensic artifacts from crashes. Read the partition table first. Always.