Part 16by Muhammad

ESP32 Flash Memory Extraction: Firmware Security Lab

ESP32 Flash Memory Extraction: Firmware Security Lab

Of all the vulnerabilities in an embedded device, unencrypted flash memory is the one that requires the least skill to exploit and produces the most complete compromise. An attacker with physical access to an unprotected ESP32 and a USB cable can extract the entire firmware image in under 10 minutes using a free, official tool from Espressif. The resulting binary file contains every string constant, every hardcoded credential, every API key, every encryption key and every algorithm the firmware implements — readable with nothing more than Python’s built-in string processing. This lab performs that extraction, demonstrates the recovery of hardcoded secrets, and then explains exactly what flash encryption does to prevent it.

Why Unencrypted Flash Is the Highest-Impact Hardware Vulnerability

Flash memory extraction ranks as the single highest-impact hardware attack on an IoT device for three compounding reasons: it is trivial to execute, it is completely silent (the device logs nothing and continues operating normally during extraction), and it yields every secret the device holds in a single operation.

Compare this to a network-based attack: a network attacker must find an exploitable vulnerability, craft a working exploit, deliver it successfully, and then post-exploit to extract specific secrets. Each step has a probability of failure. Flash extraction has none of these steps. There is no vulnerability to find, no exploit to craft. The “attack” is: plug in USB cable, run one command, wait 10 minutes, search binary for strings. Any developer who has used esptool.py for legitimate purposes already knows how to do it.

The one-device-compromises-all-devices problem multiplies the impact further. In a typical IoT product, every unit ships with identical firmware. That firmware contains the same hardcoded WiFi credentials, the same API keys, the same MQTT broker password, and the same encryption keys. An attacker who extracts secrets from a single device purchased from a retail shelf has extracted the secrets of the entire deployed fleet — potentially hundreds of thousands of devices.

What Attackers Extract from an Unprotected Device

The firmware image is a binary file, but it contains large amounts of readable ASCII data. String constants in C firmware are stored in the flash in their original ASCII encoding, which means any text that appears in the source code also appears in the binary as readable text. The categories of data that are routinely found in IoT firmware dumps include:

CategoryExamplesImpact if Stolen
WiFi credentialsSSID, passphraseNetwork access; lateral movement to other devices
Cloud API keysAWS access keys, OpenAI keys, Stripe secretsUnauthorised cloud service access; billing fraud
Database credentialsHostname, username, password, database nameDirect database access; customer data breach
MQTT credentialsBroker address, username, passwordSubscribe to all device telemetry; publish commands to all devices
Cryptographic keysAES keys in hex, RSA private keys in PEM formatDecrypt all encrypted communication; forge device identity
Admin credentialsWeb interface username/passwordFull device control without physical access
OTA update secretsUpdate server URL, authentication tokenPush malicious firmware to entire fleet
Debug endpointsInternal API URLs, debug tokensAccess to development/staging infrastructure
Device logicAlgorithm implementations, business rulesIntellectual property theft; vulnerability research

Lab Prerequisites and Equipment

Required: ESP32 DOIT DevKit V1, USB cable, Python 3.x installed, and esptool.py. Install esptool.py with:

pip install esptool

# Verify the installation:
./esptool version
# Expected: esptool v4.x.x

You also need Arduino IDE with ESP32 board support to upload the target firmware in Part A. Estimated lab time: 25–30 minutes. The flash extraction itself takes approximately 5–10 minutes depending on your USB-serial adapter speed.

Part A: Create Firmware with Embedded Secrets

The firmware below deliberately hardcodes a comprehensive set of credentials and keys. This mirrors what is found in a surprisingly large proportion of real IoT firmware — developers put secrets in the code “temporarily” for testing and ship that firmware to production without removing them. Upload this firmware to your ESP32 before running the extraction steps.


/*
 * Firmware with Embedded Secrets — Flash Extraction Target
 *
 * This firmware contains hardcoded credentials, API keys and encryption
 * keys as string constants. When flash encryption is disabled (the
 * factory default), all of these are recoverable with esptool.py.
 *
 * Hardware: ESP32 DOIT DevKit V1
 *
 * DO NOT use hardcoded secrets in any production firmware.
 */

#include <Arduino.h>
#include <esp_efuse.h>
#include <soc/efuse_reg.h>

bool isFlashEncryptionEnabled() {
  uint32_t reg = REG_READ(EFUSE_BLK0_RDATA2_REG);
  return (reg & (1 << 15)) != 0;
}

bool isSecureBootEnabled() {
  uint32_t reg = REG_READ(EFUSE_BLK0_RDATA2_REG);
  return (reg & (1 << 4)) != 0;
}

/* ── Hardcoded secrets (all recoverable from unencrypted flash) ─────────── */

const char* PRODUCT_NAME     = "SecureIoTDevice-3000";
const char* FIRMWARE_VERSION = "v2.4.1-production";

/* Cloud API keys */
const char* OPENAI_API_KEY = "sk-proj-1234567890abcdefghijklmnopqrstuvwxyz";
const char* AWS_ACCESS_KEY = "AKIAIOSFODNN7EXAMPLE";
const char* AWS_SECRET_KEY = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY";
const char* STRIPE_SECRET  = "sk_live_51234567890abcdefghijklmnop";

/* Database credentials */
const char* DB_HOST     = "db.production.company.com";
const char* DB_USER     = "admin_production";
const char* DB_PASSWORD = "P@ssw0rd!Pr0duct10n2024";
const char* DB_NAME     = "customer_data";

/* WiFi credentials */
const char* DEFAULT_WIFI_SSID = "CompanyOfficeNetwork";
const char* DEFAULT_WIFI_PASS = "OfficeWiFi2024!Secure";

/* Cryptographic material */
const char* AES_KEY_HEX    = "0123456789ABCDEF0123456789ABCDEF";
const char* RSA_PRIVATE_KEY =
    "-----BEGIN RSA PRIVATE KEY-----\n"
    "MIIEpAIBAAKCAQEA1234567890abcdefghijklmnopqrstuvwxyz...\n"
    "-----END RSA PRIVATE KEY-----";

/* Device identity */
const char* DEVICE_SERIAL     = "IOT-PROD-2024-001337";
const char* DEVICE_AUTH_TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.example";
const char* OTA_UPDATE_KEY    = "ota_secret_key_do_not_share_2024";

/* Admin credentials */
const char* ADMIN_USERNAME = "superadmin";
const char* ADMIN_PASSWORD = "Admin!2024$Secure";
const char* ROOT_PASSWORD  = "r00t_p@ssw0rd_2024";

/* Third-party tokens */
const char* TWILIO_SID    = "AC1234567890abcdefghijklmnopqrstu";
const char* TWILIO_AUTH   = "1234567890abcdefghijklmnopqrstuv";
const char* SENDGRID_KEY  = "SG.1234567890abcdefghijklmnopqrstuvwxyz";
const char* GOOGLE_API_KEY = "AIzaSy1234567890abcdefghijklmnopqr";

/* MQTT broker */
const char* MQTT_BROKER = "mqtt.company.com";
const char* MQTT_USER   = "device_fleet";
const char* MQTT_PASS   = "MqttP@ss2024!";

/* Debug artefacts that should have been removed before production */
const char* DEBUG_ENDPOINT = "https://debug.company.com/api/v1";
const char* DEBUG_TOKEN    = "debug_token_remove_before_production_please";

/* ── Setup ──────────────────────────────────────────────────────────────── */

void setup() {
  Serial.begin(115200);
  delay(2000);

  Serial.println("\n╔════════════════════════════════════════════════════════╗");
  Serial.println("ā•‘   Firmware with Embedded Secrets (Extraction Target)   ā•‘");
  Serial.println("ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•\n");

  Serial.println("Security status:");
  Serial.printf("  Flash Encryption: %s\n",
                isFlashEncryptionEnabled() ? "ENABLED āœ“" : "DISABLED āœ—  ← CRITICAL");
  Serial.printf("  Secure Boot:      %s\n",
                isSecureBootEnabled()      ? "ENABLED āœ“" : "DISABLED āœ—");

  Serial.println("\nThis firmware embeds the following secret categories:");
  Serial.println("  • Cloud API keys  (OpenAI, AWS, Stripe, Google)");
  Serial.println("  • Database credentials (host, user, password, db name)");
  Serial.println("  • WiFi credentials");
  Serial.println("  • Cryptographic keys (AES, RSA private key)");
  Serial.println("  • Admin passwords");
  Serial.println("  • Third-party tokens (Twilio, SendGrid)");
  Serial.println("  • MQTT broker credentials");
  Serial.println("  • Debug endpoints and tokens");

  Serial.println("\nAll of these are recoverable from unencrypted flash.");
  Serial.println("Run the extraction steps from the lab to retrieve them.\n");

  Serial.printf("Flash chip size: %u MB\n",
                ESP.getFlashChipSize() / (1024 * 1024));
  Serial.printf("Free heap:       %d bytes\n\n", ESP.getFreeHeap());

  Serial.println("Extraction command (replace PORT with your serial port):");
  Serial.println("  esptool.py --chip esp32 --port PORT \\");
  Serial.println("    read_flash 0x00000 0x400000 firmware_dump.bin");
}

void loop() {
  static unsigned long last = 0;
  if (millis() - last > 10000) {
    last = millis();
    Serial.printf("[%lus] Device running. Flash encryption: %s\n",
                  millis() / 1000,
                  isFlashEncryptionEnabled() ? "ENABLED" : "DISABLED");
  }
  delay(100);
}

Step 1: Upload the Target Firmware

Copy the code above into Arduino IDE, select “ESP32 Dev Module” as the board and your serial port, and click Upload. Open Serial Monitor at 115200 baud. Verify the output shows “Flash Encryption: DISABLED” — this is the expected state for a new development board and is the condition that makes the extraction possible. Note the flash chip size shown in the output (typically 4 MB).

Step 2: Identify the Serial Port

You need the serial port identifier for esptool.py. Do not use the Arduino IDE during the extraction — close the Serial Monitor first, as it holds the serial port open and esptool.py cannot connect while another program is using it.

# Windows: check Device Manager → Ports (COM & LPT)
# Common ports: COM3, COM4, COM5

# macOS: list available serial ports
ls /dev/cu.*
# Look for: /dev/cu.usbserial-XXXX  or  /dev/cu.SLAB_USBtoUART

# Linux: list USB serial devices
ls /dev/ttyUSB*
# Usually: /dev/ttyUSB0

# Verify esptool.py can see the device (replace PORT):
esptool --chip esp32 --port PORT chip_id
# Expected: Chip is ESP32-D0WDQ6 (revision v1.0)

Step 3: Extract the Flash with esptool.py

Run the read_flash command below. The address 0x00000 is the start of flash and 0x400000 is 4 MB (the full size of the flash chip on the DevKit V1). If your flash is 8 MB, use 0x800000 instead — the Serial Monitor output from Step 1 shows the correct flash size.

# Extract the complete 4 MB flash image.
# Replace PORT with your actual serial port identifier.

# Windows:
esptool --chip esp32 --port COM3 read-flash 0x00000 0x400000 firmware_dump.bin

# macOS:
./esptool --chip esp32 --port /dev/cu.usbserial-0001 read-flash 0x00000 0x400000 firmware_dump.bin

# Linux:
./esptool --chip esp32 --port /dev/ttyUSB0 read-flash 0x00000 0x400000 firmware_dump.bin

Expected output during extraction:

esptool.py v4.7.0
Serial port /dev/ttyUSB0
Connecting....
Chip is ESP32-D0WDQ6 (revision v1.0)
Features: WiFi, BT, Dual Core, 240MHz, VRef calibration in efuse
Crystal is 40MHz
MAC: 24:6f:28:xx:xx:xx
Uploading stub...
Running stub...
Stub running...
4194304 (100 %)
Read 4194304 bytes at 0x00000000 in 367.8 seconds (91.2 kbit/s)...
Hard resetting via RTS pin...

The process takes approximately 5–10 minutes. When complete, verify the output file:

ls -lh firmware_dump.bin
# Expected: 4.0M  firmware_dump.bin

Step 4: Extract Readable Strings from the Binary

The Unix strings utility (available on macOS and Linux; installable on Windows via Git Bash or WSL) extracts all sequences of printable ASCII characters of a minimum length from a binary file. This is the simplest way to find secrets in a firmware dump:

# Extract all strings of 8+ characters and search for security-relevant terms.
# The -n 8 flag sets minimum string length (reduces noise from short matches).

strings -n 8 firmware_dump.bin | grep -iE "password|secret|key|token|auth|admin|api"

# Targeted searches for specific secret categories:
strings firmware_dump.bin | grep "sk-"          # OpenAI / Stripe API keys
strings firmware_dump.bin | grep "AKIA"         # AWS access keys
strings firmware_dump.bin | grep "AIzaSy"       # Google API keys
strings firmware_dump.bin | grep "SG\."         # SendGrid API keys
strings firmware_dump.bin | grep "BEGIN RSA"    # RSA private keys
strings firmware_dump.bin | grep "eyJ"          # JWT tokens (base64 header)

# Search for WiFi credentials by SSID patterns:
strings firmware_dump.bin | grep -E "[A-Z][a-z]+Network|[A-Z][a-z]+WiFi"

# Dump ALL strings for manual review:
strings -n 6 firmware_dump.bin > all_strings.txt
wc -l all_strings.txt    # Count extracted strings

Step 5: Run the Automated Secret Extraction Script

The Python script below performs a more thorough analysis than simple strings searching. It extracts all printable strings from the binary, then applies regex patterns to categorise findings by secret type, calculates a risk score based on what was found, and reports the results in a structured format.

#!/usr/bin/env python3
"""
ESP32 Firmware Secret Extraction Tool
Extracts and categorises secrets from an unencrypted firmware dump.

Usage: python3 extract_secrets.py firmware_dump.bin
"""

import sys
import re

# ── Pattern definitions ──────────────────────────────────────────────────────

SECRET_PATTERNS = {
    "AWS Access Keys": [
        r'AKIA[0-9A-Z]{16}',
    ],
    "AWS Secret Keys": [
        r'[A-Za-z0-9+/]{40}',   # 40-char base64-like strings near "secret"
        r'wJalr[A-Za-z0-9+/]{36}',
    ],
    "OpenAI / Stripe API Keys": [
        r'sk-[a-zA-Z0-9\-_]{20,}',
        r'sk-proj-[a-zA-Z0-9\-_]{20,}',
    ],
    "Google API Keys": [
        r'AIzaSy[A-Za-z0-9_\-]{33}',
    ],
    "SendGrid API Keys": [
        r'SG\.[A-Za-z0-9_\-]{22,}\.[A-Za-z0-9_\-]{43,}',
    ],
    "JWT Tokens": [
        r'eyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+',
    ],
    "RSA / PEM Keys": [
        r'-----BEGIN [A-Z ]+KEY-----',
        r'-----END [A-Z ]+KEY-----',
    ],
    "Passwords (keyword proximity)": [
        r'(?i)(password|passwd|pass|pwd)\s*[=:]\s*[^\s\'"]{6,}',
    ],
    "Tokens (keyword proximity)": [
        r'(?i)(token|auth|secret|key)\s*[=:]\s*[^\s\'"]{8,}',
    ],
    "Hex Cryptographic Keys (32+ hex chars)": [
        r'[0-9A-Fa-f]{32,64}',
    ],
    "Database Credentials": [
        r'(?i)(db_host|db_user|db_pass|db_name|mysql|postgres)\s*[=:]\s*[^\s\'"]+',
    ],
    "MQTT Credentials": [
        r'(?i)mqtt[^\n]{0,60}(user|pass|password)',
    ],
}


def extract_strings(filepath: str, min_len: int = 6) -> list:
    """Extract all printable ASCII strings from a binary file."""
    with open(filepath, "rb") as f:
        data = f.read()
    strings = []
    current = []
    for byte in data:
        if 32 <= byte <= 126:
            current.append(chr(byte))
        else:
            if len(current) >= min_len:
                strings.append("".join(current))
            current = []
    if len(current) >= min_len:
        strings.append("".join(current))
    return strings


def find_secrets(strings: list) -> dict:
    """Apply regex patterns to find secrets in the string list."""
    findings = {category: [] for category in SECRET_PATTERNS}
    full_text = "\n".join(strings)   # Single string for multi-line patterns

    for category, patterns in SECRET_PATTERNS.items():
        for pattern in patterns:
            matches = re.findall(pattern, full_text)
            for match in matches:
                if match not in findings[category]:
                    findings[category].append(match)
    return findings


def keyword_search(strings: list) -> dict:
    """Direct keyword search for common secret-bearing strings."""
    keywords = {
        "password": [],
        "secret":   [],
        "api_key":  [],
        "token":    [],
        "admin":    [],
        "root":     [],
        "BEGIN":    [],   # PEM headers
    }
    for s in strings:
        for kw in keywords:
            if kw.lower() in s.lower() and s not in keywords[kw]:
                keywords[kw].append(s)
    return keywords


def main():
    if len(sys.argv) < 2:
        print("Usage: python3 extract_secrets.py firmware_dump.bin")
        sys.exit(1)

    filepath = sys.argv[1]
    print("=" * 72)
    print("  ESP32 Firmware Secret Extraction Report")
    print("=" * 72)
    print(f"\nTarget file: {filepath}\n")

    print("[*] Extracting strings from binary...")
    all_strings = extract_strings(filepath)
    print(f"[+] Found {len(all_strings):,} readable strings\n")

    print("[*] Applying secret detection patterns...\n")
    findings = find_secrets(all_strings)

    total_secrets = 0
    for category, matches in findings.items():
        if not matches:
            continue
        print(f"  {category}  ({len(matches)} found)")
        print("  " + "-" * 66)
        for match in matches[:8]:   # Show up to 8 per category
            print(f"    {match[:80]}")
        if len(matches) > 8:
            print(f"    ... and {len(matches) - 8} more")
        print()
        total_secrets += len(matches)

    print("[*] Keyword proximity search...\n")
    kw_findings = keyword_search(all_strings)
    for kw, matches in kw_findings.items():
        if matches:
            print(f"  '{kw}' appears in {len(matches)} strings:")
            for m in matches[:5]:
                print(f"    {m[:80]}")
            print()

    print("=" * 72)
    print(f"  TOTAL SECRETS / INDICATORS FOUND: {total_secrets}")
    print("=" * 72)
    print()
    print("  These secrets were extracted from UNENCRYPTED flash memory.")
    print("  Enable flash encryption before production deployment to")
    print("  prevent this class of attack.")
    print()


if __name__ == "__main__":
    main()

Run the script against your extracted firmware dump:

python3 extract_secrets.py firmware_dump.bin

The output will list every API key, password, encryption key and credential found in the binary, categorised by type. Compare the output against the secrets defined in the Arduino code to confirm that all of them were recovered.

Step 6: Assess the Real-World Impact

With the secrets recovered from the firmware dump, consider each attack scenario the extraction enables:

Cloud service takeover: The AWS access key and secret key are credentials for an IAM user in an AWS account. With these, an attacker can call the AWS CLI or API with full privileges of that IAM user — reading S3 buckets, accessing DynamoDB tables, invoking Lambda functions, or provisioning new resources at the account owner’s expense. There is no further exploitation required; the keys are directly usable.

Fleet-wide device control: The MQTT broker credentials (MQTT_USER, MQTT_PASS) allow the attacker to connect to the broker and subscribe to all device telemetry topics or publish commands to all devices. If the MQTT broker uses a wildcard topic structure (# subscribe), a single connection gives visibility into every message from every device in the fleet.

Database access: The database host, username and password are direct credentials to a production database server. No network-level exploit is required — the attacker simply connects with a standard database client using the extracted credentials.

OTA firmware update compromise: The OTA update key allows the attacker to sign and push firmware updates to the device. Without secure boot enabled, unsigned firmware is accepted anyway. With both the OTA key and knowledge of the firmware’s update mechanism, the attacker can push malicious firmware to the entire fleet via the legitimate update channel.

How Flash Encryption Works on ESP32

ESP32 flash encryption uses AES-256 in XTS mode to encrypt the contents of the external SPI flash. The encryption key is generated randomly by the ESP32 itself during the first boot after encryption is enabled, and stored in an eFuse register called BLOCK1. eFuse registers are one-time-programmable — once written they cannot be changed or erased, and the contents of BLOCK1 cannot be read out even with JTAG access. The encryption and decryption happen in hardware, transparently to the firmware: the CPU reads plaintext, the hardware encrypts before writing to flash, and decrypts after reading from flash.

The practical consequence for an attacker: extracting the flash with esptool.py now gives them an AES-256 encrypted binary. Without the key — which is physically inside the chip in a register that cannot be read — the binary is indistinguishable from random noise. The secrets are protected even if the device is physically disassembled and the flash chip is desoldered and read directly with a flash programmer.

# What esptool.py read_flash produces from an ENCRYPTED device:
# (hypothetical output — this is what the attacker gets)

strings firmware_dump_encrypted.bin | grep -i "password"
# Returns: nothing

strings firmware_dump_encrypted.bin | grep "AKIA"
# Returns: nothing

strings firmware_dump_encrypted.bin | head -20
# Returns: binary garbage — no readable strings

# The entire file is high-entropy AES-XTS ciphertext.
# Without the eFuse key, this is computationally infeasible to decrypt.

Enabling Flash Encryption: What Happens and What to Expect

Flash encryption on the ESP32 is a permanent operation. Once enabled, it cannot be reversed. The process is triggered through the ESP-IDF menuconfig and burns the relevant eFuses on first boot. For production devices, this should be done during manufacturing programming, not in the field.

The high-level sequence is:

# Step 1: Enable flash encryption in the project configuration.
# In ESP-IDF:
idf.py menuconfig
# Navigate to: Security features → Enable flash encryption on boot
# Select: Development mode (for testing) or Release mode (for production)
# IMPORTANT: Release mode is permanent. Development mode can be re-flashed
# a limited number of times, which is useful for testing.

# Step 2: Build and flash normally.
idf.py build flash monitor
# On first boot with encryption enabled, the ESP32 will:
#   1. Generate a random 256-bit AES key using its hardware RNG
#   2. Store the key in eFuse BLOCK1
#   3. Re-encrypt the flash contents with the new key
#   4. Burn the FLASH_CRYPT_CNT eFuse to record the encryption status
#   5. Reboot and run the encrypted firmware

# Step 3: Verify encryption is active.
espefuse.py --port PORT summary | grep -E "FLASH_CRYPT|ENCRYPT"
# Expected: FLASH_CRYPT_CNT = 0x01 (or higher for development mode)

# Step 4 (production only): Burn to Release mode to prevent re-flashing.
espefuse.py --port PORT burn_efuse FLASH_CRYPT_CNT 0x7f
# This uses the maximum value of FLASH_CRYPT_CNT, preventing any further
# plaintext firmware from being flashed. The device will only accept
# firmware encrypted with its own key.

After encryption is enabled, development and debugging workflows change. Over-the-air updates still work because the OTA mechanism writes the new firmware image to flash in plaintext, and the hardware encrypts it transparently on write. Arduino IDE uploads via USB still work in development mode. What does not work is reading flash back out and getting useful data: esptool.py read_flash gives you the encrypted image, which cannot be decrypted without the eFuse key.

What Flash Encryption Protects Against and What It Does Not

Flash encryption is highly effective against physical flash extraction attacks. It does not protect against all attack classes:

AttackProtected by Flash Encryption?Notes
esptool.py flash extractionYes — ciphertext onlyNo key = no plaintext
Desoldering flash chip + direct readYes — ciphertext onlyKey is in eFuse on the MCU die, not in the flash chip
Network-based credential theftNoSecrets in RAM at runtime — TLS required for network protection
Side-channel key recovery (DPA/SPA)Partial — raises the barHardware AES is more resistant than software AES
Fault injection to bypass encryption checkNoVoltage / EM glitching can bypass eFuse reads on some silicon revisions
Runtime secrets extraction via JTAG (if not disabled)NoDecrypted data is in RAM — readable via JTAG; burn JTAG_DISABLE
Malicious firmware via insecure OTANoSecure boot required for firmware authenticity

Best Practices: Firmware Secrets Management

Flash encryption addresses the extraction of secrets from the binary. The deeper fix is to not hardcode secrets in firmware at all. Secrets that are never present in the firmware image cannot be extracted from it, regardless of encryption status. The correct pattern for IoT secrets management is:

Provisioning at manufacturing time: The firmware contains no secrets. During manufacturing programming, unique per-device credentials (a device certificate, a device-specific API key) are injected into the device’s NVS (Non-Volatile Storage) partition as part of the programming workflow. The NVS partition is encrypted by flash encryption, but more importantly, even if an attacker extracts one device’s credentials, they do not have the credentials for any other device in the fleet.

Secure element storage: For the most sensitive keys (private keys for TLS mutual authentication, code signing keys), use a dedicated secure element like the ATECC608B. The private key is generated inside the secure element and never leaves it — the ESP32 sends data to the secure element for signing, but the key material is never accessible to the application firmware regardless of flash encryption status.

/*
 * Correct pattern: load credentials from NVS at runtime.
 * Never hardcode credentials as string constants in firmware.
 */
#include <Preferences.h>

Preferences prefs;
String wifi_ssid, wifi_pass, api_key;

void loadCredentials() {
  prefs.begin("device_cfg", /* readOnly= */ true);

  wifi_ssid = prefs.getString("wifi_ssid", "");
  wifi_pass = prefs.getString("wifi_pass", "");
  api_key   = prefs.getString("api_key",   "");

  prefs.end();

  if (wifi_ssid.isEmpty() || api_key.isEmpty()) {
    Serial.println("Device not provisioned. Enter provisioning mode.");
    enterProvisioningMode();
  }
}

/*
 * Provisioning mode: credentials are entered once (via BLE, serial,
 * or a one-time web interface) and stored in NVS. After provisioning,
 * the provisioning channel is disabled and the device reboots into
 * normal operation mode.
 */
void enterProvisioningMode() {
  /* ... provisioning implementation ... */
}

Conclusion

This lab demonstrated that extracting the complete firmware from an unprotected ESP32 requires no specialised skills, no expensive equipment and approximately 10 minutes. The resulting binary contains every secret the firmware holds in plaintext, recoverable with a single strings command or a short Python script. Flash encryption converts this trivial attack into a computationally infeasible one by storing the AES-256 key in a hardware register that cannot be read out even with JTAG access. The combination of flash encryption (protect the binary), secure boot (prevent unsigned replacements), NVS-based credentials (eliminate hardcoded secrets), and JTAG disable (prevent runtime memory access) forms the complete hardware security baseline for any ESP32 device before production deployment. Lab 2 identified flash memory as the highest-risk hardware attack surface. This lab demonstrated exactly why that rating is correct.