
Documentation et outils d'ingénierie inverse pour le chiffrement du firmware 8BitDo
Documentation et outils issus de l'ingénierie inverse du chiffrement du firmware utilisé par 8BitDo pour plusieurs produits basés sur GD32.
Voir 8cryptdo.py pour un outil de déchiffrement et de rechiffrement appliquant l'algorithme
décrit dans ce document.
L'outil permet potentiellement de créer des firmwares personnalisés. Cependant, ce dépôt n'inclut aucune des données de firmware officielles. Un script pour télécharger les derniers fichiers de firmware se trouve dans le dépôt fwupd/8bitdo-firmware, avec quelques anciens binaires de firmware archivés.
En observant un fichier de firmware .dat en surface, un en-tête en clair de 28 octets est
présent.
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
Avec les fichiers de firmware récents, une valeur 32 bits inconnue est placée après celles-ci. Le reste de l'en-tête est à zéro.
Sur certains fichiers de firmware, un motif apparaît : il existe une couche qui mélange chaque mot dans le suivant.
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))
Ce chaînage se réinitialise à chaque frontière de bloc de 128 mots. Le premier mot d'un bloc
(pos = 0) n'a pas de terme prédécesseur, et pos = 1..127 se chaînent depuis le mot
précédent.
Une couche sans clé n'ajoute aucune sécurité. La retirer révèle un intermédiaire plus propre
(dechained), où la seule chose qui cache encore le texte en clair est le keystream.
P[i] = dechained[i] ^ keystream[i]
Le dépôt fwupd/8bitdo-firmware contient un catalogue d'anciens firmwares. Ces firmwares semblaient tous avoir la couche de chaînage.
Un XOR de certains d'entre eux après suppression de la couche de chaînage révèle de longues séquences de zéros et une structure lisible.
plaintext_diff = [a ^ b for a, b in zip(dechained_a, dechained_b)]
Si le même keystream est utilisé dans les deux...
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
le keystream s'annule.
Il s'agit d'un classique many-time pad. Un tel keystream n'est sûr que s'il est utilisé une seule fois. Pour une raison quelconque, 8BitDo a décidé d'utiliser non pas un keystream par produit, mais un seul keystream pour plusieurs produits. Cela a considérablement affaibli la sécurité de leur chiffrement et rendu possible le reste de l'analyse présentée ici.
Une fois que vous savez que deux textes en clair sont XORés ensemble, vous pouvez deviner une
partie de l'un et soustraire votre supposition pour lire l'autre. Cela permet de récupérer le
keystream à cet endroit.
keystream[i] = dechained[i] ^ P[i]
for fw in all_fw:
P[fw][i] = dechained[fw][i] ^ keystream[i]
Cela serait plus simple avec des chaînes de caractères, mais l'astuce la plus productive ici a été la relocalisation inter-versions d'instructions ARM. Une fonction peut apparaître dans deux versions de firmware à des positions décalées. Là où le keystream est connu dans une version, vous pouvez aligner le code partagé dans une autre et récolter de nouvelles valeurs de keystream.
Exploiter cela a fait passer les mots de keystream connus d'environ 1 500 à plusieurs milliers, soit environ 18 % du firmware lisible.
Avec un échantillon plus large du keystream, des motifs sont devenus visibles. Il avait des
positions espacées de 16 mots qui progressaient d'une quantité presque constante, et chaque
octet se déplaçait d'une taille prévisible.
Par tâtonnements, il a été découvert que chaque mot de keystream dans un bloc suit une formule :
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
Au sein d'un bloc, on parcourt un simple compteur (block_base(block) + pos*STEP), et on le
XOR avec un masque qui commence à block_key et tourne de 18 bits à chaque étape. Le bloc
entier de 128 mots est déterminé par un seul nombre 32 bits, block_key.
Un mot connu déverrouille tout un bloc. En réarrangeant la formule, on obtient le
block_key à partir de n'importe quel mot de keystream connu :
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)
D'où venait la clé de chaque bloc ? Elle se factorise en deux moitiés de 16 bits tirées d'une table de graines de 256 entrées :
def block_key_of(block, seed_table):
hi = seed_table[block]
lo = seed_table[block ^ 0xF9]
return (hi << 16) | lo
La table de graines elle-même ne semble suivre aucune formule spécifique ; il s'agit
probablement de 512 octets constants stockés quelque part dans le bootloader de chaque
appareil. Cependant, le XOR avec 0xF9 offre un magnifique effet secondaire : une loi miroir.
Un bloc et son partenaire block ^ 0xF9 sont construits à partir des deux mêmes entrées de
table échangées.
mirror = block_key_of(block ^ 0xF9, seed_table)
assert mirror == rotr(block_key_of(block, seed_table), 16)
Cela a permis de déchiffrer des régions jusqu'alors inconnues. Au lieu de deviner un
block_key complet de 32 bits à l'aveugle, vous pouvez deviner deux moitiés de 16 bits et
évaluer quel choix transforme les deux blocs en chaînes de caractères ou en instructions ARM
plausibles.
Avec cela, une couverture à 100 % de toutes les données de firmware utilisant ce chiffrement spécifique a été obtenue.
Ce chiffrement semble couvrir la plupart des produits 8BitDo vers 2020, ainsi que les produits actuels qui utilisent encore des SoC GD32 (y compris le SN30 Pro).
Certains appareils comme le M30 et le Zero 2 semblent être multi-sections, avec certaines données de firmware utilisant ce chiffrement et un autre firmware avec un schéma de chiffrement différent.
Les appareils plus récents, du moins ceux avec un SoC différent, semblent avoir un schéma de chiffrement de firmware plus robuste. Une analyse naïve montre qu'il pourrait potentiellement être partagé, mais ce n'est pas certain. Il faut approfondir la recherche.