ESP32 Security/ ESP32 NVS Forensics / What Developers Store

What Developers Store

practical

The Developer's Threat Model

When an ESP32 firmware developer writes credentials to NVS, they are usually thinking about persistence — "I need this value to survive a reboot." They are almost never thinking about physical flash extraction. Their threat model centers on network attacks: TLS, authentication tokens, rate limiting. The physical layer is outside their threat model entirely.

This gap between developer assumptions and attacker capabilities is the fundamental insight behind ESP32 NVS forensics. Everything documented in this lesson comes from publicly available ESP-IDF application code, open-source ESP32 projects on GitHub, and published IoT security research. The patterns are consistent and predictable.


WiFi Credentials

WiFi credentials are the most common sensitive data in ESP32 NVS. Every device that connects to a WiFi network must store the SSID and password somewhere — and NVS is the canonical place.

From ESP-IDF's own WiFi provisioning example (examples/provisioning/wifi_prov_mgr):

nvs_handle_t nvs_handle;
nvs_open("wifi_config", NVS_READWRITE, &nvs_handle);
nvs_set_str(nvs_handle, "ssid", wifi_ssid);
nvs_set_str(nvs_handle, "password", wifi_password);
nvs_commit(nvs_handle);
nvs_close(nvs_handle);

What you find in NVS output:

Namespace: wifi_config
  ssid       [string]  HomeNetwork_5G
  password   [string]  correct-horse-battery

Namespace: wifi
  ap_ssid    [string]  OfficeMain
  ap_pass    [string]  CompanyWifi2023!

Why this matters: The WiFi password stored in a shipped consumer device is the password for the physical location where the device is deployed. If you extract it from a smart plug, a security camera, or an industrial sensor, you now have the building's WiFi credentials. This is lateral movement from device to infrastructure.

Common namespace names for WiFi: - wifi, wifi_config, wifi_cred, net, wlan, network

Common key names: - ssid, ap_ssid, sta_ssid, wifi_ssid, nw_ssid - password, passwd, pass, ap_pass, wifi_pass, psk


MQTT Broker Credentials

MQTT is the dominant messaging protocol in IoT deployments. ESP32 devices acting as sensors, actuators, or monitoring nodes authenticate to an MQTT broker with a username and password. These are stored in NVS.

From a typical ESP32 telemetry sensor firmware:

nvs_handle_t mqtt_nvs;
nvs_open("mqtt", NVS_READWRITE, &mqtt_nvs);
nvs_set_str(mqtt_nvs, "broker", "mqtt.company-internal.com");
nvs_set_str(mqtt_nvs, "user",   "sensor_device_042");
nvs_set_str(mqtt_nvs, "pass",   "MqttP@ssw0rd!");
nvs_set_u16(mqtt_nvs, "port",   1883);
nvs_commit(mqtt_nvs);

What you find:

Namespace: mqtt
  broker  [string]   mqtt.factory-automation.local
  user    [string]   esp_node_03
  pass    [string]   B3lt_L!ne_2024
  port    [uint16]   1883
  topic   [string]   sensors/factory/floor2/temp

Why this matters: MQTT broker credentials give you access to the message bus. Depending on the broker's ACL configuration, a compromised credential can allow reading all sensor data from all devices, injecting false sensor readings, or sending commands to actuators. In industrial environments, this can affect physical processes.


AWS IoT Core Credentials

AWS IoT Core uses mutual TLS authentication. Each device has a unique client certificate and private key signed by an AWS-managed CA. These are stored in NVS — either as strings (PEM format) or blobs (DER format).

From a commercial smart home device firmware (pattern found in open-source ESP32-AWS examples):

nvs_handle_t aws_nvs;
nvs_open("aws_iot", NVS_READWRITE, &aws_nvs);
nvs_set_str(aws_nvs, "endpoint",  "a1b2c3d4e5f6g7.iot.us-east-1.amazonaws.com");
nvs_set_str(aws_nvs, "thing",     "SmartPlug-A1B2C3");
nvs_set_blob(aws_nvs, "cert",     cert_pem, cert_len);
nvs_set_blob(aws_nvs, "key",      private_key_pem, key_len);
nvs_commit(aws_nvs);

What you find in NVS output:

Namespace: aws_iot
  endpoint  [string]  a1b2c3d4e5f6g7.iot.us-east-1.amazonaws.com
  thing     [string]  SmartPlug-A1B2C3
  cert      [blob]    -----BEGIN CERTIFICATE-----
                      MIIDWTCCAkGgAwIBAgIUJ3mMKZc...
                      -----END CERTIFICATE-----
  key       [blob]    -----BEGIN RSA PRIVATE KEY-----
                      MIIEowIBAAKCAQEA7h3c8F1...
                      -----END RSA PRIVATE KEY-----

Why this matters: An AWS IoT private key extracted from one device can be used to: 1. Authenticate as that specific device to AWS IoT Core 2. Subscribe to all MQTT topics that device is authorized to read 3. Publish messages on behalf of that device 4. In some configurations, access device shadow state and issue control commands

AWS IoT Core private keys cannot be rotated without firmware update coordination. A device with an extracted key is permanently compromised until the certificate is explicitly revoked in the AWS IoT console.


HTTP API Keys and Tokens

Many ESP32 products call REST APIs to report data or receive commands. API keys and OAuth tokens are stored in NVS for persistence across reboots.

From an ESP32-based environmental monitoring system:

nvs_set_str(http_handle, "api_key",  "sk-prod-a8f3c1d9e2b6704523f1...");
nvs_set_str(http_handle, "endpoint", "https://api.smartsensor.io/v2/data");
nvs_set_str(http_handle, "token",    "eyJhbGciOiJSUzI1NiIsInR5cCI6...");

What you find:

Namespace: http
  api_key   [string]  sk-prod-a8f3c1d9e2b6704523f1a8c9d3e7b2
  endpoint  [string]  https://api.smartsensor.io/v2
  token     [string]  eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...

JWT tokens are especially interesting: A JWT from NVS can be decoded immediately:

# Extract the token from nvs decoded output
TOKEN="eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkZXZpY2UiLCJpZCI6..."

# Decode header and payload (no cryptographic verification needed for reading)
echo $TOKEN | cut -d. -f1 | base64 -d 2>/dev/null | python3 -m json.tool
echo $TOKEN | cut -d. -f2 | base64 -d 2>/dev/null | python3 -m json.tool

Device Identity and Provisioning Data

Manufacturers use Espressif's mass manufacturing (mass_mfg) tool to write unique per-device data to NVS during production. This creates NVS binary images with device-specific content that is flashed during manufacturing.

Typical provisioning data:

// Written during manufacturing, not by application code
nvs_set_str(prov_handle, "serial",     "SN-20240315-A7F2C9");
nvs_set_str(prov_handle, "model",      "SmartSensor-Pro-V2");
nvs_set_str(prov_handle, "secret",     "PRV-5XK9-M2P1-ZRQW-7HBS");
nvs_set_blob(prov_handle, "dev_cert",  device_cert, cert_len);
nvs_set_u32(prov_handle,  "mfg_date",  20240315);

What you find:

Namespace: provisioning
  serial    [string]   SN-20240315-A7F2C9
  model     [string]   SmartSensor-Pro-V2
  secret    [string]   PRV-5XK9-M2P1-ZRQW-7HBS
  dev_cert  [blob]     (PEM certificate data)
  mfg_date  [uint32]   20240315

Namespace: device
  activation  [string]  ACT-7F3K-Q9WX-M4NR

The provisioning secret is often used for device registration APIs — submit this value to the manufacturer's cloud and the device gets linked to your account. Knowing it lets you register someone else's device to your account or enumerate the manufacturer's device database.


Finding NVS References in Firmware Binaries

If you have the application firmware binary but not the NVS dump, you can still identify what namespaces and keys to look for by analyzing the binary.

# Find nvs_set calls by looking for key string references
strings firmware.bin | grep -E "^[a-z][a-z0-9_]{1,14}$" | sort -u
# This finds potential namespace/key names (max 15 chars, lowercase, typical pattern)

# Look for strings that commonly appear near nvs_set calls
strings firmware.bin | grep -iE "ssid|password|passwd|api.key|token|secret|cert|endpoint|broker"

# Find namespace names specifically (usually very short strings)
strings firmware.bin | grep -E "^wifi$|^mqtt$|^aws|^http$|^device$|^prov|^config$"

More targeted approach using binary pattern matching:

The nvs_set_str and nvs_set_blob function calls are compiled as branch-and-link instructions. In the ESP32 Xtensa binary, the key name string is loaded into a register immediately before the call. A strings search sorted by length highlights the short strings (2–15 chars) that are NVS key candidates:

strings -n 2 -n 15 firmware.bin | awk 'length <= 15' | sort | uniq -c | sort -rn | head -50

Cross-reference frequent short strings against the NVS decoded output to understand the full picture of what a device stores.


The "Forgot to Delete Dev Credentials" Problem

This is a documented pattern across shipped IoT products. The development workflow creates it:

  1. During development, the developer hardcodes test credentials in the firmware or writes them to NVS via a provisioning script
  2. Before production, they "clean" the firmware by removing hardcoded strings from the binary
  3. They never clear the NVS partition that was used during development testing
  4. The production device ships with the developer's personal WiFi password, an internal staging MQTT server address, or a test API key in NVS

Forensic evidence of this pattern:

Namespace: wifi
  ssid      [string]  DeveloperHome_5GHz
  password  [string]  PersonalPass123

Namespace: mqtt  (ERASED entries — visible with --include-erased)
  broker    [string]  staging-mqtt.internal.company.com
  user      [string]  dev_test_user
  pass      [string]  staging_only_pass

The active entry shows the production WiFi (set during provisioning). The erased entries (recovered forensically) show staging infrastructure that was "deleted" but is still readable in flash.

How to specifically look for erased entries:

nvs_tool read --include-erased nvs.bin | grep -A2 "\[erased\]"

Practical Reconnaissance Script

Combine everything into a targeted analysis:

#!/bin/bash
# esp32_nvs_recon.sh — complete NVS forensic extraction

PORT="/dev/ttyUSB0"
DUMP="full_dump.bin"

echo "[*] Step 1: Identify chip"
esptool.py --port $PORT flash_id 2>&1 | tee flash_id.txt
FLASH_SIZE=$(grep "Detected flash size" flash_id.txt | grep -oP '\d+MB')
echo "[*] Flash size: $FLASH_SIZE"

echo "[*] Step 2: Full flash dump"
SIZE_HEX=$(python3 -c "
sizes = {'4MB':'0x400000','8MB':'0x800000','16MB':'0x1000000'}
print(sizes.get('$FLASH_SIZE','0x400000'))")
esptool.py --port $PORT --baud 921600 read_flash 0 $SIZE_HEX $DUMP

echo "[*] Step 3: Partition table"
dd if=$DUMP bs=1 skip=$((0x8000)) count=$((0x1000)) of=ptable.bin 2>/dev/null
gen_esp32part.py ptable.bin 2>/dev/null | tee ptable.txt

echo "[*] Step 4: Extract NVS"
NVS_OFFSET=$(grep ",nvs," ptable.txt | head -1 | awk -F, '{print $4}' | tr -d ' ')
NVS_SIZE=$(grep ",nvs," ptable.txt | head -1 | awk -F, '{print $5}' | tr -d ' ')
echo "[*] NVS at $NVS_OFFSET size $NVS_SIZE"
dd if=$DUMP bs=1 skip=$(($NVS_OFFSET)) count=$(($NVS_SIZE)) of=nvs.bin 2>/dev/null

echo "[*] Step 5: Check for nvs_keys"
KEYS_LINE=$(grep "nvs_keys" ptable.txt | head -1)
if [ -n "$KEYS_LINE" ]; then
    echo "[!] nvs_keys partition found — NVS is encrypted"
    KEYS_OFFSET=$(echo $KEYS_LINE | awk -F, '{print $4}' | tr -d ' ')
    dd if=$DUMP bs=1 skip=$(($KEYS_OFFSET)) count=$((0x1000)) of=nvs_keys.bin 2>/dev/null
    nvs_tool read --key-partition nvs_keys.bin --include-erased nvs.bin > nvs_decoded.txt
else
    nvs_tool read --include-erased nvs.bin > nvs_decoded.txt
fi

echo "[*] Step 6: Search for credentials"
echo "=== HIGH-VALUE FINDINGS ==="
grep -iE "pass|key|secret|token|cert|ssid|broker|endpoint|serial|activation" nvs_decoded.txt

echo "[*] Done. Full output in nvs_decoded.txt"

Putting It All Together

The ESP32 NVS is not a secure credential store. It is a convenience mechanism designed for persistence, not confidentiality. When Flash Encryption is disabled — which it is on the vast majority of consumer ESP32 devices — every string, blob, and integer stored in NVS is readable by anyone with a USB cable, esptool, and the knowledge you now have.

The challenge exercises for this course will put these skills to work: one against a provided flash dump image, one against your own ESP32 hardware. Apply the full workflow: partition table first, NVS extraction, automated decoding, erased entry recovery, credential analysis. The flag is in the NVS. Find it.