
Documentazione e strumenti di reverse engineering per la crittografia del firmware 8BitDo
Documentazione e strumenti ottenuti tramite reverse engineering per la cifratura del firmware utilizzata da 8BitDo per diversi prodotti basati su GD32.
Vedere 8cryptdo.py per uno strumento di decifratura e ri-cifratura che applica l'algoritmo
descritto in questo documento.
Lo strumento potenzialmente abilita la possibilità di creare firmware personalizzati. Tuttavia, questo repository non include alcuno dei dati firmware ufficiali. Uno script per scaricare i file firmware più recenti può essere trovato nel repository fwupd/8bitdo-firmware, con alcuni binari firmware più vecchi archiviati.
Osservando superficialmente un file firmware .dat, è presente un header in chiaro di 28 byte.
import struct
raw = open("sn30-v2_07.dat", "rb").read()
version, addr, payload_len = struct.unpack("<III", raw[:12])
# version = 207 -> firmware == v2.07
# addr = 0x08003400 -> destination (probably)
# payload_len = 99328 -> section payload length in bytes
Con i file firmware recenti c'è un valore a 32 bit sconosciuto posizionato dopo questi. Il resto dell'header è zero.
Su alcuni file firmware compare un pattern: c'è un livello che mescola ogni parola nella successiva.
def rotr(x, r):
return ((x >> r) | (x << (32 - r))) & 0xFFFFFFFF
dechained = [words[0]]
for i in range(1, len(words)):
dechained.append(words[i] ^ rotr(words[i - 1], 3))
Questo concatenamento si azzera a ogni confine di blocco di 128 parole. La prima parola di un blocco
(pos = 0) non ha termine predecessore, e pos = 1..127 si concatena dalla parola
precedente.
Un livello senza chiave non aggiunge sicurezza. Rimuoverlo rivela un intermedio più pulito
(dechained), dove l'unica cosa che nasconde ancora il plaintext è il keystream.
P[i] = dechained[i] ^ keystream[i]
Il repository fwupd/8bitdo-firmware ha un catalogo di firmware più vecchi. Questi firmware sembravano tutti avere il livello di concatenamento.
Facendo lo XOR di alcuni di essi tra loro dopo aver rimosso il livello di concatenamento si rivelano lunghe sequenze di zeri e struttura leggibile.
plaintext_diff = [a ^ b for a, b in zip(dechained_a, dechained_b)]
Se lo stesso keystream è usato in entrambi...
a = dechained_a[i] ^ dechained_b[i]
b = (P_a[i] ^ keystream[i]) ^ (P_b[i] ^ keystream[i])
c = P_a[i] ^ P_b[i]
assert a == b == c
il keystream si annulla.
Questo è un classico many-time pad. Un tale keystream è sicuro solo se usato una volta. Per qualche motivo, 8BitDo ha deciso di usare non solo un keystream per prodotto, ma un singolo keystream su diversi prodotti. Questo ha indebolito significativamente la sicurezza del loro cifrario e ha reso possibile il resto dell'analisi qui presentata.
Una volta che sai che due plaintext sono XOR-ed insieme, puoi indovinare parte di uno e
sottrarre la tua ipotesi per leggere l'altro. Questo recupera il keystream in quel
punto.
keystream[i] = dechained[i] ^ P[i]
for fw in all_fw:
P[fw][i] = dechained[fw][i] ^ keystream[i]
Sarebbe più semplice con le stringhe, ma il trucco più produttivo qui è stata la rilocazione cross-version delle istruzioni ARM. Una funzione può apparire in due versioni firmware in posizioni spostate. Dove il keystream è noto in una versione, puoi allineare il codice condiviso in un'altra e raccogliere nuovi valori di keystream.
Sfruttare questo ha portato le parole di keystream note da ~1.500 a diverse migliaia, circa il 18% del firmware leggibile.
Con un campione più ampio del keystream, i pattern sono diventati visibili. Aveva
posizioni distanti 16 parole che avanzavano di una quantità quasi costante, e ogni
byte si spostava di una dimensione prevedibile.
Attraverso tentativi ed errori, è stato scoperto che ogni parola di keystream in un blocco segue una formula:
STEP = 0x92A753FA
A_MUL = 0x80000301
ROT = 18
UINT32_MAX = 0xFFFFFFFF
def rotr(x, r):
r &= 31
return ((x >> r) | (x << (32 - r))) & UINT32_MAX if r else x
def block_base(block):
return (block * A_MUL + block // 2) & UINT32_MAX
def keystream(block, pos, block_key):
counter = (block_base(block) + pos * STEP) & UINT32_MAX
mask = rotr(block_key, ROT * pos)
return counter ^ mask
All'interno di un blocco, si percorre un semplice contatore (block_base(block) + pos*STEP), e lo si fa XOR
con una maschera che parte da block_key e ruota di 18 bit a ogni passo. L'intero blocco di 128 parole è determinato da un singolo numero a 32 bit, block_key.
Una parola nota sblocca un intero blocco. Riordinando la formula si ottiene il
block_key da qualsiasi parola di keystream nota:
def block_key_from_known(block, pos, known_keystream):
counter = (block_base(block) + pos * STEP) & UINT32_MAX
return rotr(known_keystream ^ counter, -ROT * pos & 31)
Da dove veniva la chiave di ogni blocco? Si fattorizza in due metà a 16 bit prese da una tabella di seed di 256 voci:
def block_key_of(block, seed_table):
hi = seed_table[block]
lo = seed_table[block ^ 0xF9]
return (hi << 16) | lo
La tabella dei seed stessa sembra non avere una formula specifica, probabilmente sono 512
byte costanti memorizzati da qualche parte nel bootloader di ogni dispositivo. Tuttavia, lo
XOR con 0xF9 fornisce un bellissimo effetto collaterale: una legge speculare. Un blocco e
il suo partner block ^ 0xF9 sono costruiti dalle stesse due voci della tabella scambiate.
mirror = block_key_of(block ^ 0xF9, seed_table)
assert mirror == rotr(block_key_of(block, seed_table), 16)
Questo ha decifrato regioni precedentemente sconosciute. Invece di indovinare un intero block_key
a 32 bit alla cieca, puoi indovinare due metà a 16 bit e valutare quale scelta trasforma
entrambi i blocchi in stringhe o istruzioni ARM credibili.
Con questo, è stata ottenuta una copertura del 100% di tutti i dati firmware che utilizzano questo specifico cifrario.
Questo cifrario sembra coprire la maggior parte dei prodotti 8BitDo intorno al 2020, e i prodotti attuali che usano ancora SoC GD32 (incluso l'SN30 Pro).
Alcuni dispositivi come l'M30 e lo Zero 2 sembrano essere multi-sezione, con alcuni dati firmware che usano questo cifrario e un altro firmware con uno schema di cifratura diverso.
I dispositivi più recenti, almeno quelli con un SoC diverso, sembrano avere uno schema di cifratura del firmware più robusto. Un'analisi ingenua mostra che potrebbe potenzialmente essere condiviso, ma non è certo. Bisogna indagare ulteriormente.