
Reverse-engineerte Dokumentation und Tools für die 8BitDo-Firmware-Verschlüsselung
Reverse-engineered Dokumentation und Tooling für die Firmware-Verschlüsselung, die von 8BitDo für mehrere GD32-basierte Produkte verwendet wird.
Siehe 8cryptdo.py für ein Entschlüsselungs- und Wieder-Verschlüsselungs-Tool,
das den in diesem Dokument beschriebenen Algorithmus anwendet.
Das Tool ermöglicht potenziell die Erstellung angepasster Firmware. Dieses Repository enthält jedoch keine der offiziellen Firmware-Daten. Ein Skript zum Herunterladen der neuesten Firmware-Dateien findet sich im Repository fwupd/8bitdo-firmware, in dem auch einige ältere Firmware-Binärdateien archiviert sind.
Betrachtet man eine Firmware-.dat-Datei oberflächlich, so ist ein 28 Byte
großer Klartext-Header vorhanden.
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
Bei neueren Firmware-Dateien steht danach ein unbekannter 32-Bit-Wert. Der Rest des Headers ist null.
Bei bestimmten Firmware-Dateien zeigt sich ein Muster: Es gibt eine Schicht, die jedes Wort in das nächste einmischt.
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))
Diese Verkettung wird an jeder 128-Wort-Blockgrenze zurückgesetzt. Das erste
Wort eines Blocks (pos = 0) hat keinen Vorgängerterm, und pos = 1..127
verkettet sich mit dem jeweils vorherigen Wort.
Eine schlüssellose Schicht bringt keine Sicherheit. Zieht man sie ab, so
offenbart sich ein saubereres Zwischenergebnis (dechained), bei dem das
Einzige, was den Klartext noch verbirgt, der Keystream ist.
P[i] = dechained[i] ^ keystream[i]
Das Repository fwupd/8bitdo-firmware enthält einen Katalog älterer Firmware. Diese Firmwares schienen alle die Verkettungsschicht zu besitzen.
XOR-verknüpft man einige von ihnen nach Entfernen der Verkettungsschicht miteinander, so zeigen sich lange Folgen von Nullen und lesbare Strukturen.
plaintext_diff = [a ^ b for a, b in zip(dechained_a, dechained_b)]
Wenn derselbe Keystream in beiden verwendet wird...
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
hebt sich der Keystream auf.
Dies ist ein klassisches Many-Time-Pad. Ein solcher Keystream ist nur sicher, wenn er einmalig verwendet wird. Aus irgendeinem Grund entschied sich 8BitDo, nicht nur einen Keystream pro Produkt zu verwenden, sondern einen einzigen Keystream über mehrere Produkte hinweg. Dies schwächte die Sicherheit ihrer Verschlüsselung erheblich und ermöglichte den Rest der hier durchgeführten Analyse.
Sobald man weiß, dass zwei Klartexte miteinander XOR-verknüpft sind, kann man
einen Teil des einen erraten und seine Vermutung subtrahieren, um den anderen zu
lesen. Dadurch wird der keystream an dieser Stelle wiederhergestellt.
keystream[i] = dechained[i] ^ P[i]
for fw in all_fw:
P[fw][i] = dechained[fw][i] ^ keystream[i]
Am einfachsten wäre dies mit Zeichenketten, aber der produktivste Trick hier war die versionsübergreifende Verschiebung von ARM-Instruktionen. Eine Funktion kann in zwei Firmware-Versionen an verschobenen Positionen auftauchen. Wo der Keystream in einer Version bekannt ist, kann man den gemeinsamen Code in einer anderen ausrichten und neue Keystream-Werte ernten.
Die Ausnutzung dessen brachte die bekannten Keystream-Wörter von ~1.500 auf mehrere Tausend, etwa 18 % der lesbaren Firmware.
Mit einer größeren Stichprobe des keystream wurden Muster sichtbar. Er hatte
Positionen im Abstand von 16 Wörtern, die um einen nahezu konstanten Betrag
weiterschritten, und jedes Byte bewegte sich um eine vorhersagbare Größe.
Durch Ausprobieren wurde entdeckt, dass jedes Keystream-Wort in einem Block einer Formel folgt:
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
Innerhalb eines Blocks läuft ein einfacher Zähler
(block_base(block) + pos*STEP), und dieser wird mit einer Maske XOR-verknüpft,
die bei block_key beginnt und sich bei jedem Schritt um 18 Bit dreht. Der
gesamte 128-Wort-Block wird durch eine einzige 32-Bit-Zahl, block_key,
bestimmt.
Ein bekanntes Wort entsperrt einen ganzen Block. Durch Umstellen der Formel
erhält man den block_key aus jedem bekannten Keystream-Wort:
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)
Woher kam der Schlüssel jedes Blocks? Er faktorisiert sich in zwei 16-Bit-Hälften, die aus einer Seed-Tabelle mit 256 Einträgen stammen:
def block_key_of(block, seed_table):
hi = seed_table[block]
lo = seed_table[block ^ 0xF9]
return (hi << 16) | lo
Die Seed-Tabelle selbst scheint keine bestimmte Formel zu haben; sie besteht
wahrscheinlich aus 512 konstanten Bytes, die irgendwo im Bootloader jedes Geräts
gespeichert sind. Die XOR-Verknüpfung mit 0xF9 hat jedoch einen wunderbaren
Nebeneffekt: ein Spiegelgesetz. Ein Block und sein Partner block ^ 0xF9
werden aus denselben zwei Tabelleneinträgen aufgebaut, nur vertauscht.
mirror = block_key_of(block ^ 0xF9, seed_table)
assert mirror == rotr(block_key_of(block, seed_table), 16)
Dies knackte zuvor unbekannte Regionen. Anstatt einen vollständigen 32-Bit-
block_key blind zu erraten, kann man zwei 16-Bit-Hälften erraten und bewerten,
welche Wahl beide Blöcke in glaubwürdige Zeichenketten oder ARM-Instruktionen
verwandelt.
Damit wurde eine 100%ige Abdeckung aller Firmware-Daten erreicht, die diese spezifische Verschlüsselung verwenden.
Diese Verschlüsselung scheint die meisten 8BitDo-Produkte um 2020 abzudecken, sowie aktuelle Produkte, die noch GD32-SoCs verwenden (einschließlich des SN30 Pro).
Einige Geräte wie der M30 und Zero 2 scheinen mehrteilig zu sein, wobei einige Firmware-Daten diese Verschlüsselung verwenden und eine andere Firmware ein anderes Verschlüsselungsschema nutzt.
Neuere Geräte, zumindest solche mit einem anderen SoC, scheinen ein stärkeres Firmware-Verschlüsselungsschema zu haben. Eine oberflächliche Analyse deutet darauf hin, dass es möglicherweise gemeinsam genutzt wird, aber das ist unsicher. Dies muss weiter untersucht werden.