ESP32 Security/ ESP32 NVS Forensics / NVS Architecture

NVS Architecture

theory

What Is NVS?

NVS stands for Non-Volatile Storage. It is a key-value store implemented directly in the ESP32's flash memory by Espressif's ESP-IDF framework. The purpose is simple: give application developers a reliable way to persist small pieces of data across reboots and power cycles, without needing an external EEPROM or a full filesystem.

From the application developer's perspective, NVS behaves like a dictionary: you open a named namespace, write a key-value pair, close the handle, and that value survives a reset. From a forensic perspective, NVS is a structured binary format sitting at a known location in flash — and it can contain anything the developer chose to store there.

// What a developer writes
nvs_handle_t handle;
nvs_open("wifi", NVS_READWRITE, &handle);
nvs_set_str(handle, "password", "MyWifiPass123");
nvs_commit(handle);
nvs_close(handle);

// What you will find in flash
// → namespace: "wifi", key: "password", value: "MyWifiPass123"

That string is sitting in flash right now on millions of shipped ESP32 devices.


Why NVS Exists: Flash Write Cycle Constraints

NAND and NOR flash memory have a fundamental physical limitation: each storage cell can only be erased and rewritten a finite number of times before it degrades. For the SPI NOR flash used by ESP32 devices, the typical endurance spec is 100,000 erase cycles per sector.

A sector is 4KB. If an application naively wrote its WiFi password to the same 4KB sector every time it changed, that sector would fail after 100,000 updates. In a device that reconnects to WiFi frequently, that could happen within months.

NVS solves this through wear leveling: it distributes writes across all pages in the NVS partition rather than always writing to the same location. When a page fills up, NVS moves to the next available page. When all pages are used, the oldest clean pages are reclaimed. The developer never sees this complexity — it happens transparently inside the NVS library.

The consequence for forensics: old data is not immediately overwritten. A page that NVS has moved away from may still contain its previous entries in flash. Deleted keys are marked as erased in the page bitmap but the underlying data bytes may persist until the page is explicitly reclaimed and erased. This is a well-known source of forensic evidence in NVS dumps.


NVS Internal Structure

NVS organizes flash into a sequence of pages, each exactly 4096 bytes (one flash erase sector). A typical default NVS partition (24KB = 0x6000 bytes) contains six pages.

Page Layout

Each NVS page has this structure:

Offset   Size    Field
──────────────────────────────────────────────────────────
0x000    32B     Page header
0x020    32B     Entry state bitmap (tracks status of all 126 entries)
0x040    4032B   Entry storage (126 entries × 32 bytes each)

Page Header

The page header (32 bytes) contains:

Field Size Description
Magic 4B 0xA5A5A5A5 — identifies a valid NVS page
Sequence number 4B Monotonically increasing, used for wear leveling ordering
Version 1B NVS format version
State 1B 0xFF = empty, 0xFE = active, 0xFC = full, 0xF8 = freeing
CRC32 4B Covers the header fields
Reserved 16B Zero-filled

If the magic is not 0xA5A5A5A5, the page has either never been written or has been erased. An erased NVS partition will show all 0xFF bytes.

Entry State Bitmap

Immediately after the header sits a 32-byte bitmap that tracks the state of each of the 126 possible entries on the page. Each entry uses 2 bits:

  • 11 — Empty (available for writing)
  • 10 — Written (active entry)
  • 00 — Erased (deleted entry, data may still be present in flash)
  • 01 — Invalid

This bitmap is where you will find evidence of deleted keys. An entry marked 00 (erased) in the bitmap but still containing readable data bytes is a classic NVS artifact.

Entry Structure

Each entry is 32 bytes:

Offset   Size    Field
──────────────────────────────────────────────────────────
0x00     1B      Namespace index (which namespace this entry belongs to)
0x01     1B      Type (see type table below)
0x02     1B      Span (number of consecutive entries used by this value)
0x03     1B      Chunk index (for blob multi-entry sequences)
0x04     4B      CRC32 of this entry
0x08     8B      Key (null-terminated string, max 15 chars + null = 16B)
0x18     8B      Value (for small types) or data chunk (for strings/blobs)

The key is capped at 15 characters plus a null terminator. The namespace name is also capped at 15 characters. These limits exist because the key field is exactly 8 bytes — wait, note that the key occupies bytes 0x08–0x17 (16 bytes total), allowing 15 printable characters plus a null byte.


Entry Types

NVS supports the following data types:

Type Code Type Name Size Description
0x01 uint8 1B Unsigned 8-bit integer
0x11 int8 1B Signed 8-bit integer
0x02 uint16 2B Unsigned 16-bit integer
0x12 int16 2B Signed 16-bit integer
0x04 uint32 4B Unsigned 32-bit integer
0x14 int32 4B Signed 32-bit integer
0x08 uint64 8B Unsigned 64-bit integer
0x18 int64 8B Signed 64-bit integer
0x21 string variable Null-terminated string, uses span field
0x41 blob variable Binary data, uses span and chunk_index
0x42 blob_data variable Continuation chunk of a blob
0x48 blob_idx 8B Index entry for a blob

For integer types, the value fits entirely in the 8-byte value field of the entry. For strings and blobs, the span field tells the parser how many consecutive 32-byte entries are consumed by this value. A 60-byte string needs ceiling(60/32) = 2 additional data entries, so span = 3 (1 header + 2 data).


Namespaces

Namespaces are NVS's mechanism for organizing keys — think of them as directories. Each namespace is a string identifier (max 15 chars), and all key-value pairs are scoped within a namespace.

How namespaces work internally:

Namespaces are themselves stored as NVS entries, using the reserved namespace index 0 (the NVS system namespace). Each namespace entry assigns a numeric index (1–254) to the namespace name string. Subsequent data entries in that namespace reference this numeric index in their namespace_index field.

Entry 0: namespace_index=0, type=string, key="wifi",   value → index=1
Entry 1: namespace_index=1, type=string, key="ssid",   value="HomeNetwork"
Entry 2: namespace_index=1, type=string, key="password", value="SuperSecret99"
Entry 3: namespace_index=0, type=string, key="device", value → index=2
Entry 4: namespace_index=2, type=string, key="serial", value="ESP-A1B2C3"

This means when parsing NVS manually, you first collect all namespace entries (those with namespace_index=0) to build the namespace index-to-name mapping, then parse data entries using that map.


Default Partition Locations

In the ESP-IDF default partition table, NVS lives at:

Partition Type Offset Size
nvs data/nvs 0x9000 0x6000 (24KB)
phy_init data/phy 0xF000 0x1000 (4KB)
factory app/factory 0x10000 varies

The NVS partition starts at byte offset 36864 (0x9000) from the beginning of the flash chip. On a 4MB flash image, you can extract it with:

dd if=full_dump.bin bs=1 skip=$((0x9000)) count=$((0x6000)) of=nvs.bin

Custom partition tables can place NVS anywhere, which is why reading the partition table first (Lesson 3) is the correct workflow.


NVS Encryption

ESP-IDF supports encrypting NVS contents using AES-XTS (AES with XEX-based Tweaked CodeBook mode with ciphertext Stealing). When NVS encryption is enabled:

  • A separate nvs_keys partition stores the AES key material (typically at an offset like 0x310000)
  • All NVS entry data — both keys and values — are encrypted
  • Page headers and bitmaps are still partially readable (the magic and sequence number remain) but entry content is ciphertext
  • The nvs_keys partition itself may be protected by Flash Encryption (a separate ESP32 feature that encrypts the entire flash using a hardware key stored in eFuses)

When you encounter an NVS partition where entries look like random bytes despite correct magic numbers in page headers, you are looking at encrypted NVS. If Flash Encryption is not enabled, the nvs_keys partition is readable in plaintext and you can decrypt NVS. If Flash Encryption is enabled, recovering the key requires accessing the hardware eFuses — a much harder target.

In practice, most consumer ESP32 products do not enable NVS encryption. The developer wrote credentials into NVS, shipped the device, and left the flash readable.


Why NVS Is a Forensic Goldmine

Consider what ESP32 developers actually store in NVS. These are patterns from real, publicly available ESP-IDF projects:

// WiFi provisioning (ESP-IDF WiFi station code)
nvs_set_str(handle, "ap_ssid", config.wifi_ssid);
nvs_set_str(handle, "ap_pass", config.wifi_password);

// MQTT broker credentials (IoT telemetry devices)
nvs_set_str(handle, "mqtt_user", "device_001");
nvs_set_str(handle, "mqtt_pass", "Br0k3rS3cr3t!");
nvs_set_str(handle, "mqtt_host", "mqtt.company-internal.com");

// AWS IoT (common in commercial products)
nvs_set_str(handle, "aws_endpoint", "a1b2c3d4e5.iot.us-east-1.amazonaws.com");
nvs_set_blob(handle, "client_cert", cert_pem, cert_len);
nvs_set_blob(handle, "private_key", key_pem, key_len);

// Device identity
nvs_set_str(handle, "serial", "SN-20240315-A7F2");
nvs_set_str(handle, "activation", "ACT-5XK9-M2P1-ZRQW");

// HTTP API tokens
nvs_set_str(handle, "api_key", "sk-prod-a8f3c1d9e2b6...");

None of this is hypothetical. These patterns appear in ESP-IDF projects on GitHub, in production IoT products sold on Amazon, and in industrial sensors deployed in factory networks.

The developer's mental model is: "This is stored in flash, users can't easily read flash." The hardware hacker's mental model is: "This is stored in flash, I have esptool and a USB cable."


Key Takeaway

NVS is a structured, well-documented binary format at a predictable flash offset. It contains whatever the developer persisted — including credentials that were never intended to be externally readable. The format is open, the tools are free, and the data is sitting there on every shipped ESP32 device that hasn't enabled Flash Encryption. This is what we are going to extract in the following lessons.