Énoncé
Un apiculteur primé possède des ruches posées sur des balances connectées qui communiquent avec une passerelle radio. Le voisin a laissé un récepteur radio dans l’herbe une soirée (capture SDR) et a récupéré le micrologiciel de la balance depuis le serveur de mise à jour du fabricant. La « recette » du miel est le secret à retrouver.
Fichiers : balance_flash.bin (firmware), rucher.sigmf-data + rucher.sigmf-meta
(capture SigMF, enregistrée près d’une ruche dans le Val d’Hérens).
Reconnaissance
- SigMF meta :
sample_rate = 4 Msps, fréquence2475 MHz, datatypeci8. → 2475 MHz = Zigbee / IEEE 802.15.4 canal 25, modulation O-QPSK. - Firmware : ESP32-C6 Arduino Zigbee (pile ZBOSS), end device
« BALANCE-R7 » / « Apis Herens ». Le reverse (voir
irom.s,dis.py) donne la logique applicative et la routine de chiffrement custom (fonction0x42001a9c).
Chaîne de résolution
- Démodulation (
demod.py) : discriminateur FM + corrélation des 16 séquences de chips 802.15.4 (2 échantillons/chip), synchro sur le préambule, extraction des trames MAC. CRC16 valide sur 80/80 trames. - Déchiffrement Zigbee (
dec.py) : le join de la balance est capturé. L’APS Transport-Key est chiffrée avec la clé de key-transportHMAC-MMO("ZigBeeAlliance09", 0x00)→ on obtient la clé réseaub8d8e46f82f99446568d2d30e9512c16, qui déchiffre la couche NWK (AES-CCM*, nonce = IEEE source en little-endian ‖ frame counter ‖ sec ctrl niveau 5). - Extraction applicative (
solve_rc4.py) : la préférencerucher/recettelue en NVS est découpée en blocs de 8 octets et émise en ZCL manufacturier0x1bee, cluster0xFC01, cmd01[len+2, idx, total, data]. Chaque bloc est chiffré par RC4 avec la cléIEEE(8 octets, LE = c48746feffca4c40) ‖ "propolis" ‖ idx. En déchiffrant et en réassemblant les blocs paridx, on obtient le flag.
Le point clé
Le secret voyage « en clair » applicativement une fois la couche Zigbee cassée :
la seule protection custom est un RC4 dont la clé est entièrement dérivable de
valeurs publiques (adresse IEEE lisible dans la capture + constante "propolis"
en dur dans le firmware + index du bloc). Aucun secret n’est réellement caché.
Reproduire
./solve.sh
Le script enchaîne demod.py (O-QPSK 802.15.4 → trames MAC), dec.py
(Zigbee Transport-Key → clé réseau → NWK déchiffré) puis solve_rc4.py
(blocs cluster 0xFC01 → RC4) et filtre BA26.
Scripts : demod.py, dec.py, solve_rc4.py, solve.sh.
Code
import numpy as np, sys
fs=4e6; SPC=2 # samples per chip
C0='11011001110000110101001000101110'
def chips(s):
b=[int(c) for c in C0]
k=s&7
b=b[-4*k:]+b[:-4*k] if k else b
if s>=8: b=[v^(i&1) for i,v in enumerate(b)]
return b
CH=[chips(s) for s in range(16)]
def modulate(chipseq):
n=len(chipseq)
L=(n+2)*SPC
I=np.zeros(L);Q=np.zeros(L)
p=np.sin(np.pi*np.arange(2*SPC)/(2*SPC))
for i,c in enumerate(chipseq):
v=2*c-1; st=i*SPC
if i%2==0: I[st:st+2*SPC]+=v*p
else: Q[st:st+2*SPC]+=v*p
return I+1j*Q
def fdisc(x): return np.angle(x[1:]*np.conj(x[:-1]))
# templates of freq per symbol (64 samples), from symbol embedded in random context
rng=np.random.default_rng(1)
T=np.zeros((16,64))
for s in range(16):
acc=np.zeros(64)
for _ in range(20):
a,b=rng.integers(16,size=2)
seq=CH[a]+CH[s]+CH[b]
f=fdisc(modulate(seq))
acc+=f[64:128]
T[s]=acc/20
T-=T.mean(1,keepdims=True)
Tn=T/np.linalg.norm(T,axis=1,keepdims=True)
def demod_from(f,st):
syms=[];pos=st
while pos+64+2<=len(f):
best=None
for d in (-1,0,1):
p=pos+d
if p<0: continue
r=Tn@f[p:p+64]
k=int(np.argmax(r))
if best is None or r[k]>best[0]: best=(r[k],k,p)
r,k,p=best
if r<0.3*np.linalg.norm(f[p:p+64]): break
syms.append(k);pos=p+64
return syms
def parse(syms):
for i in range(1,len(syms)-1):
if syms[i]==7 and syms[i+1]==0xA and syms[i-1]==0:
b=syms[i+2:]
by=bytes((b[j]|(b[j+1]<<4)) for j in range(0,len(b)-1,2))
if not by: return None
L=by[0]&0x7f
return by[1:1+L]
return None
def demod_burst(x):
f=fdisc(x); f=f-np.median(f)
pre=np.tile(Tn[0],4)
n=min(len(f),3500)
c=np.correlate(f[:n],pre,'valid')
c=c/(np.sqrt(np.convolve(f[:n]**2,np.ones(len(pre)),'valid'))+1e-9)
cand=np.flatnonzero(c>0.7*c.max())
tried=set();first=None
for st in cand:
st=int(st)
if any(abs(st-t)<3 for t in tried): continue
tried.add(st)
fr=parse(demod_from(f,st))
if fr is None: continue
if first is None: first=fr
if len(fr)>=2 and crc16(fr[:-2])==int.from_bytes(fr[-2:],'little'): return fr
if len(tried)>40: break
return first
def frames(path):
raw=np.fromfile(path,dtype=np.int8).astype(np.float32)
x=raw[0::2]+1j*raw[1::2]
p=np.abs(x)**2
e=np.convolve(p,np.ones(400)/400,'same')
on=e>np.median(e)*10
idx=np.flatnonzero(np.diff(on.astype(int)))
for s,t in zip(idx[0::2],idx[1::2]):
s=max(0,s-600);t=t+300
yield s/fs,demod_burst(x[s:t])
def crc16(d):
c=0
for b in d:
c^=b
for _ in range(8):
c=(c>>1)^0x8408 if c&1 else c>>1
return c
if __name__=='__main__':
for t,fr in frames(sys.argv[1]):
if fr is None: print(f'{t:.4f} ??');continue
ok=len(fr)>=2 and crc16(fr[:-2])==int.from_bytes(fr[-2:],'little')
print(f'{t:.4f} {"OK " if ok else "BAD"} {len(fr):3d} {fr.hex()}') import sys,re
sys.path.insert(0,'.')
from zb import *
TCLK=b'ZigBeeAlliance09'
keys={'tclk':TCLK,'kt':hmac_mmo(TCLK,b'\x00'),'kl':hmac_mmo(TCLK,b'\x02')}
nwkkeys=[]
def try_aux(buf,off,keyset,aadstart):
sc=buf[off]; fc=int.from_bytes(buf[off+1:off+5],'little'); p=off+5
src=None
if sc&0x20: src=buf[p:p+8]; p+=8
kid=(sc>>3)&3
ks=None
if kid==1: ks=buf[p]; p+=1
sc5=(sc&~7)|5
aad=bytes(buf[aadstart:off])+bytes([sc5])+bytes(buf[off+1:p])
return sc5,kid,fc,src,ks,p,aad
for line in open(sys.argv[1]):
t,st,*r=line.split()
if st!='OK' or len(r)<2: continue
fr=bytes.fromhex(r[1])[:-2]
fcf=int.from_bytes(fr[:2],'little')
if fcf&7!=1: continue
# MAC hdr: panid compression assumed
dm=(fcf>>10)&3; sm=(fcf>>14)&3
p=3+2+(2 if dm==2 else 8)+(2 if sm==2 else 8)
nwk=fr[p:]
nf=int.from_bytes(nwk[:2],'little')
q=8
if nf&0x0800: q+=8
if nf&0x1000: q+=8
if nf&0x0400: print('multicast?')
if nf&0x0200:
sc,kid,fc,src,ks,pp,aad=try_aux(nwk,q,None,0)
enc=nwk[pp:]
ok=None
for k in nwkkeys:
try: ok=ccm_dec(k,src,fc,sc,aad,enc); break
except Exception: pass
if ok is None: print(t,'NWK enc, no key', src.hex(), fc); continue
aps=ok
else:
aps=nwk[q:]
print(t,'NWK src',nwk[4:6][::-1].hex(),'dst',nwk[2:4][::-1].hex(),'APS',aps.hex())
afc=aps[0]
if afc&0x20:
typ=afc&3
a=1
if typ==1: pass # command
elif typ==0:
a=1
if (afc>>2)&3!=2: a+=1 # dst endpoint (unicast)
a+=2+2+1 # cluster, profile, src ep
a+=1 # counter
sc,kid,fc,src,ks,pp,aad=try_aux(aps,a,None,0)
enc=aps[pp:]
print(' APS sec kid',kid,'fc',fc,'src',src.hex() if src else None)
for name,k in keys.items():
try:
m=ccm_dec(k,src if src else bytes(8),fc,sc,aad,enc)
print(' decrypted with',name,m.hex())
if m[0]==5 and m[1]==1:
nk=m[2:18]; print(' NETWORK KEY',nk.hex()); nwkkeys.append(nk)
print(' ascii',m)
except Exception as e: pass import re,sys
fw=open('files/balance_flash.bin','rb').read()
salt=fw[0x1d434:0x1d43c]
def rc4(key,data):
S=list(range(256));j=0
for i in range(256):
j=(j+S[i]+key[i%len(key)])&0xff; S[i],S[j]=S[j],S[i]
i=j=0;out=[]
for b in data:
i=(i+1)&0xff;j=(j+S[i])&0xff;S[i],S[j]=S[j],S[i]
out.append(b^S[(S[i]+S[j])&0xff])
return bytes(out)
chunks={}
for line in open(sys.argv[1] if len(sys.argv)>1 else 'dec.out'):
m=re.search(r'APS 400101fc04010a..15ee1b..01(..)(..)(..)([0-9a-f]+)$',line.strip())
if m:
ln,idx,tot,d=m.groups(); chunks[int(idx,16)]=bytes.fromhex(d)[:int(ln,16)-2]
ieee=bytes.fromhex('c48746feffca4c40')
for name,mac in [('le',ieee),('be',ieee[::-1])]:
pt=b''.join(rc4(mac+salt+bytes([i]),chunks[i]) for i in sorted(chunks))
print(name,pt) #!/bin/sh
# Le Rucher Bavard — pipeline complet
cd "$(dirname "$0")"
.venv/bin/python -I demod.py files/rucher.sigmf-data > frames.txt # O-QPSK 802.15.4 -> trames MAC (CRC verifie)
.venv/bin/python -I dec.py frames.txt > dec.out # Zigbee: Transport-Key (ZigBeeAlliance09) -> cle reseau -> NWK dechiffre
.venv/bin/python -I solve_rc4.py dec.out | grep -a BA26 # chunks cluster 0xFC01 -> RC4(IEEE||"propolis"||idx)