Aller au contenu
Tous les challenges
Misc Résolu

Le Rucher Bavard

Capture SDR Zigbee et firmware ESP32 : la recette du miel en RC4.

ID CTFd
4
Scripts
4 fichiers

É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équence 2475 MHz, datatype ci8. → 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 (fonction 0x42001a9c).

Chaîne de résolution

  1. 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.
  2. Déchiffrement Zigbee (dec.py) : le join de la balance est capturé. L’APS Transport-Key est chiffrée avec la clé de key-transport HMAC-MMO("ZigBeeAlliance09", 0x00) → on obtient la clé réseau b8d8e46f82f99446568d2d30e9512c16, qui déchiffre la couche NWK (AES-CCM*, nonce = IEEE source en little-endian ‖ frame counter ‖ sec ctrl niveau 5).
  3. Extraction applicative (solve_rc4.py) : la préférence rucher/recette lue en NVS est découpée en blocs de 8 octets et émise en ZCL manufacturier 0x1bee, cluster 0xFC01, cmd 01 [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 par idx, 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()}')