Reverse engineering del protocollo Bluetooth di un lightstick da concerto

Come si ricostruisce il protocollo Bluetooth di un lightstick per comandarlo da PC senza l'app ufficiale: cattura del traffico, analisi statica dell'app Android, formato dei pacchetti e checksum.

Reverse engineeringBLEAndroidJADXPython
Indice
  1. L'app e il Bluetooth Low Energy
  2. Primo metodo: registrare il traffico
  3. Confrontare i pacchetti
  4. Secondo metodo: analisi statica dell'app
  5. Il controllo da PC
  6. Perché usare tutti e due i metodi
  7. Limiti

In breve

  • Il lightstick si comanda da un'app sul telefono, via Bluetooth Low Energy. Volevo capire i comandi per usarlo senza l'app.
  • Ho registrato il traffico Bluetooth del telefono mentre usavo l'app con colori noti, e ho confrontato i pacchetti tra loro.
  • Il confronto spiega quasi tutto: colore, effetto, inizio e fine del pacchetto. Il resto si chiarisce con l'analisi statica dell'app: decompilazione con apktool e JADX, e lettura del codice che costruisce i pacchetti.
  • Il penultimo byte è un codice di controllo (checksum): va ricalcolato per ogni comando.
  • Alla fine il lightstick si comanda da un PC con poche righe di Python.

Il lightstick è il bastoncino luminoso che il pubblico agita ai concerti. Questo si collega al telefono e dall'app si scelgono colore, luminosità ed effetto. L'obiettivo era comandarlo senza l'app, da un PC. Per farlo serve un lavoro di reverse engineering: ricostruire un protocollo non documentato osservando come l'app parla con il dispositivo e leggendo il codice dell'app stessa. È lo stesso procedimento, in piccolo, che si usa per capire come un'app gestisce dati e comunicazioni in una verifica di sicurezza.

L'app e il Bluetooth Low Energy

Al primo avvio l'app chiede di inquadrare il QR code sotto il lightstick. Da lì in poi il collegamento è Bluetooth Low Energy (BLE), la variante del Bluetooth pensata per dispositivi piccoli e a basso consumo: sensori, braccialetti, oggetti «smart».

Come parla un dispositivo BLE. Un dispositivo BLE espone dei servizi, ognuno con delle caratteristiche: piccole caselle identificate da un codice (UUID). Il telefono scrive byte in una casella per mandare un comando, e ne legge un'altra, o si fa avvisare quando cambia, per ricevere dati. Il sistema si chiama GATT. Capire il protocollo significa capire cosa scrivere e in quale casella.

Il lightstick ha un servizio con tre caratteristiche. Quella che conta per i comandi è una sola, in scrittura:

UUID (forma breve)Ruolo
FFF0servizio
FFF2scrittura: qui arrivano i comandi
FFF1lettura e notifiche: le risposte del lightstick

Dall'app si possono fare poche cose: scegliere un colore, regolare la luminosità e scegliere un effetto. Gli effetti sono luce fissa, lampeggio, respiro, candela, «party», arcobaleno, multicolore e cuore. C'è anche il comando di spegnimento.

Per capire cosa manda l'app ci sono due strade: ascoltare il traffico Bluetooth oppure leggere il codice dell'app. Le ho usate entrambe, e ognuna ha coperto quello che l'altra lasciava in sospeso.

Log Bluetooth HCI snoop del telefono App Android APK del produttore Wireshark cosa viene inviato apktool + JADX perché ha quella forma Formato del pacchetto campi, lunghezza, checksum Controllo da PC in Python
Il traffico dice cosa viene inviato; il codice dell'app spiega i campi che dal traffico non si capiscono.

Primo metodo: registrare il traffico

Android ha un'opzione, tra le impostazioni sviluppatore, che registra in un file tutto il traffico Bluetooth del telefono: si chiama «log di snoop HCI Bluetooth». HCI è l'interfaccia tra il sistema operativo e il chip Bluetooth, quindi nel log finisce ogni pacchetto scambiato con il lightstick.

La cosa importante è sapere esattamente cosa si sta registrando. Ho attivato il log e ho usato l'app con valori facili da riconoscere, annotando l'ordine: rosso pieno, verde pieno, blu pieno, bianco, poi alcuni effetti. Più i valori sono semplici, più è facile ritrovarli nei byte.

Su alcuni telefoni il file non si trova direttamente: finisce dentro il bug report di Android, compresso e codificato in un formato chiamato btsnooz. Il progetto Android fornisce uno script che lo estrae e lo riporta al formato standard btsnoop. A quel punto il file si apre in Wireshark, e filtrando le scritture GATT verso la caratteristica FFF2 restano solo i comandi mandati dall'app.

Confrontare i pacchetti

Ogni comando è una stringa di 14 byte. Questo è quello del bianco, preso dalla cattura:

55 AA 06 00 01 5B 5B 5B 01 80 80 13 FE EF

Mettendo i pacchetti uno sotto l'altro, alcune parti restano sempre uguali e altre cambiano insieme a quello che si fa nell'app:

  • 55 AA all'inizio e FE EF alla fine non cambiano mai: sono i delimitatori del pacchetto;
  • tre byte cambiano con il colore: sono rosso, verde e blu. Nel bianco valgono tutti e tre 5B;
  • un byte cambia con l'effetto scelto: 01 è la luce fissa;
  • 06 00 01 e 80 80 restano uguali tra un colore e l'altro;
  • il penultimo byte, 13 nel bianco, cambia da un colore all'altro ma non in modo ovvio.

Una cosa strana: il bianco non era FF FF FF ma 5B 5B 5B, cioè 91 su 255. Il motivo si è chiarito dopo.

Secondo metodo: analisi statica dell'app

La cattura dice cosa viene inviato, ma non perché. Per dare un nome ai campi serve l'altra metà del reverse engineering: leggere l'app. È un'analisi statica, cioè si studia il codice senza eseguirlo e senza modificarlo, con lo stesso procedimento che si usa in una verifica di sicurezza su un'app Android.

Cos'è un APK. Un'app Android è un archivio ZIP con estensione .apk. Dentro ci sono il manifest (nome del pacchetto, permessi, componenti), le risorse (testi, immagini, layout) e il codice, compilato in bytecode Dalvik nei file classes.dex. Il bytecode non è leggibile direttamente, ma conserva abbastanza struttura da poter essere riportato a una forma comprensibile.

Estrarre e decompilare

L'APK si recupera dal telefono con adb oppure da un archivio di APK pubblici. Da lì si lavora con due strumenti complementari:

  • apktool estrae il manifest e le risorse in forma leggibile e disassembla il bytecode in smali, una specie di assembly per la macchina virtuale di Android. Lo smali è fedele al bytecode, riga per riga: se qualcosa non è chiaro nel codice decompilato, lì c'è la verità.
  • JADX decompila il bytecode in Java. Il risultato non è il sorgente originale, ma è molto più veloce da leggere. Ha anche un'interfaccia grafica con ricerca nel testo e riferimenti incrociati: si clicca su un metodo e si vede chi lo chiama.

In pratica si legge in JADX e si torna allo smali quando il decompilatore sbaglia qualcosa, per esempio con cicli o conversioni tra tipi numerici.

Orientarsi nel codice

Un'app moderna contiene decine di librerie: statistiche d'uso (analytics), pubblicità, componenti grafici. Il codice scritto dal produttore è spesso una piccola parte, e se l'app è offuscata classi e metodi hanno nomi come a, b, c. Serve un punto d'appoggio da cui partire.

Qui i punti d'appoggio erano due, entrambi già noti dalla cattura:

  1. Gli UUID. Le caselle GATT FFF0 e FFF2 devono comparire nel codice come stringhe, nella forma estesa 0000fff2-0000-1000-8000-00805f9b34fb. Cercandole si arriva subito alla classe che gestisce la connessione Bluetooth.
  2. Le API Bluetooth di Android. Qualunque app BLE, prima o poi, chiama writeCharacteristic sulla classe BluetoothGatt del sistema. Le API di sistema non possono essere offuscate, quindi cercando le chiamate a quel metodo si trovano tutti i punti in cui l'app manda dati al dispositivo.

Seguire il flusso

Trovato il punto in cui i byte escono, si risale all'indietro: chi chiama la funzione di invio, e con quali dati. In questa app la catena era corta:

  1. la schermata del colore, quando l'utente sposta il cursore, chiama un metodo che riceve colore, luminosità ed effetto;
  2. quel metodo passa i valori a una classe che costruisce il pacchetto;
  3. il pacchetto finisce nella coda di invio e poi in writeCharacteristic.

La classe del punto 2 è quella che interessa, ed era facile da trovare anche perché non era offuscata: aveva un nome esplicito e le costanti avevano nomi come intestazione, coda, comando della luce. Spesso succede, perché gli strumenti di offuscamento lasciano intatto ciò che l'app usa per riflessione o che è condiviso con altre librerie.

Andare nella direzione opposta è altrettanto utile: dai pulsanti dell'interfaccia (i cui testi stanno nelle risorse, e quindi si trovano cercando la parola che si legge sullo schermo) si scende fino al codice che reagisce al tocco. Così si scopre anche quali comandi esistono ma non si vedono nell'uso normale, come quelli per i concerti descritti più avanti.

Cosa si ricava

Dalla classe che costruisce i pacchetti si legge il formato completo:

ByteNel biancoSignificato
1–255 AAinizio pacchetto
3–406 00lunghezza dei dati: 6 byte, byte meno significativo prima
501comando: controllo della luce
6–85B 5B 5Brosso, verde, blu, già scalati per la luminosità
901effetto: luce fissa
1080sensibilità della reazione (bassa, media, alta), con il bit alto sempre acceso
1180valore fisso, «nessuna modalità»
1213checksum
13–14FE EFfine pacchetto

Il mistero del bianco a 91 si risolve così: l'app non manda la luminosità in un campo separato, ma moltiplica i tre colori per la luminosità scelta, con un minimo del 5%. Nella cattura la luminosità doveva quindi essere intorno al 36%: 255 × 0,36 fa circa 91.

Il checksum

Il 13 è un checksum: la somma del byte di comando e di tutti i byte di dati, tenendo solo gli ultimi 8 bit. Per il bianco:

01 + 5B + 5B + 5B + 01 + 80 + 80 = 0x213  →  13

Copiarlo dalla cattura funziona solo per quel bianco a quella luminosità: un generatore di pacchetti deve ricalcolarlo per ogni comando.

A cosa serve un checksum. È un numero calcolato dal contenuto del messaggio e spedito insieme a lui. Chi riceve rifà il calcolo: se il risultato non coincide, il messaggio si è rovinato per strada. È lo stesso principio del carattere di controllo alla fine del codice fiscale.

Il resto del protocollo

La stessa struttura serve anche per altri comandi, che l'app usa soprattutto ai concerti: la lettura del livello della batteria, l'impostazione del posto a sedere e la versione delle animazioni. Il posto a sedere serve probabilmente a far sapere al lightstick dove si trova nella sala, per gli effetti coordinati. Le richieste di lettura hanno il codice del comando corrispondente più A0: 01 imposta la luce, A1 ne chiede lo stato.

Il controllo da PC

Con il formato chiaro, comandare il lightstick da un computer è semplice. Ho usato Python con la libreria bleak, che funziona su Windows, macOS e Linux:

  1. si cercano i dispositivi Bluetooth vicini e si prende quello che si annuncia via Bluetooth con un nome contenente «DreamCatcher»;
  2. ci si collega;
  3. si costruisce il pacchetto con colore, luminosità ed effetto, e si scrive nella caratteristica FFF2.

Per provare ho scritto una dissolvenza che cicla tra rosso, verde, blu e bianco variando la luminosità, con un comando ogni 20 millisecondi circa. Se un invio non riesce entro 5 secondi, lo script considera il lightstick scollegato e si ferma, invece di restare appeso.

Perché usare tutti e due i metodi

Con la sola cattura avevo capito gran parte del protocollo, ma non avrei saputo dire cosa fossero 06 00 o il 13, e avrei scritto un generatore di pacchetti funzionante solo per i casi che avevo registrato. Con il solo codice avrei avuto i nomi dei campi, ma non la conferma che il lightstick si comporta davvero come dice l'app.

Messi insieme, il traffico dice cosa succede e il codice spiega perché.

Limiti

  • Lo script implementa solo il controllo della luce: i comandi per i concerti li ho individuati nel codice dell'app, ma non li ho provati sul dispositivo.
  • Non ho toccato il firmware del lightstick: tutto quello che si vede qui passa dall'interfaccia che l'app già usa.

← Tutti gli articoli