
Documentación y herramientas de ingeniería inversa para el cifrado de firmware de 8BitDo
Documentación y herramientas obtenidas mediante ingeniería inversa para el cifrado de firmware utilizado por 8BitDo en varios productos basados en GD32.
Consulta 8cryptdo.py para obtener una herramienta de descifrado y recifrado que aplica el algoritmo
descrito en este documento.
La herramienta potencialmente permite la posibilidad de crear firmware personalizado. Sin embargo, este repositorio no incluye ninguno de los datos de firmware oficiales. Se puede encontrar un script para descargar los archivos de firmware más recientes en el repositorio fwupd/8bitdo-firmware, con algunos binarios de firmware más antiguos archivados.
Si se observa un archivo de firmware .dat en la superficie, está presente una cabecera en texto plano de 28 bytes.
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
En los archivos de firmware recientes hay un valor desconocido de 32 bits colocado después de estos. El resto de la cabecera es cero.
En ciertos archivos de firmware aparece un patrón: hay una capa que mezcla cada palabra con la siguiente.
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))
Este encadenamiento se reinicia en cada límite de bloque de 128 palabras. La primera palabra de un bloque
(pos = 0) no tiene término predecesor, y pos = 1..127 se encadenan desde la palabra
anterior.
Una capa sin clave no añade seguridad. Al eliminarla se revela un intermedio más limpio
(dechained), donde lo único que queda ocultando el texto plano es el flujo de claves.
P[i] = dechained[i] ^ keystream[i]
El repositorio fwupd/8bitdo-firmware tiene un catálogo de firmware más antiguo. Todos estos firmwares parecían tener la capa de encadenamiento.
Al aplicar XOR entre algunos de ellos después de eliminar la capa de encadenamiento se revelan largas secuencias de ceros y una estructura legible.
plaintext_diff = [a ^ b for a, b in zip(dechained_a, dechained_b)]
Si se utiliza el mismo flujo de claves en ambos...
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
el flujo de claves se cancela.
Este es un clásico many-time pad. Un flujo de claves así solo es seguro si se usa una vez. Por alguna razón, 8BitDo decidió usar no solo un flujo de claves por producto, sino un único flujo de claves en varios productos. Esto debilitó significativamente la seguridad de su cifrado e hizo posible el resto del análisis aquí presentado.
Una vez que sabes que dos textos planos están combinados con XOR, puedes adivinar parte de uno y
restar tu conjetura para leer el otro. Esto recupera el keystream en ese
punto.
keystream[i] = dechained[i] ^ P[i]
for fw in all_fw:
P[fw][i] = dechained[fw][i] ^ keystream[i]
Esto sería más fácil con cadenas, pero el truco más productivo aquí fue la reubicación entre versiones de instrucciones ARM. Una función puede aparecer en dos versiones de firmware en posiciones desplazadas. Donde se conoce el flujo de claves en una versión, puedes alinear el código compartido en otra y recolectar nuevos valores del flujo de claves.
Explotar esto llevó las palabras conocidas del flujo de claves de ~1.500 a varios miles, aproximadamente el 18% del firmware legible.
Con una muestra más grande del keystream, los patrones se hicieron visibles. Tenía
posiciones separadas por 16 palabras que avanzaban en una cantidad casi constante, y cada
byte se movía en una cantidad predecible.
Mediante prueba y error, se descubrió que cada palabra del flujo de claves en un bloque sigue una fórmula:
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
Dentro de un bloque, se recorre un contador simple (block_base(block) + pos*STEP), y se aplica XOR
con una máscara que comienza en block_key y rota 18 bits en cada paso. El
bloque completo de 128 palabras está determinado por un único número de 32 bits, block_key.
Una palabra conocida desbloquea un bloque entero. Reordenando la fórmula se obtiene la
block_key a partir de cualquier palabra conocida del flujo de claves:
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)
¿De dónde venía la clave de cada bloque? Se factoriza en dos mitades de 16 bits extraídas de una tabla de semillas de 256 entradas:
def block_key_of(block, seed_table):
hi = seed_table[block]
lo = seed_table[block ^ 0xF9]
return (hi << 16) | lo
La tabla de semillas en sí parece no tener una fórmula específica, probablemente son 512
bytes constantes almacenados en algún lugar del bootloader de cada dispositivo. Sin embargo, el
XOR con 0xF9 proporciona un bonito efecto secundario: una ley de espejo. Un bloque y
su pareja block ^ 0xF9 se construyen a partir de las mismas dos entradas de la tabla intercambiadas.
mirror = block_key_of(block ^ 0xF9, seed_table)
assert mirror == rotr(block_key_of(block, seed_table), 16)
Esto descifró regiones previamente desconocidas. En lugar de adivinar una block_key completa de 32 bits
a ciegas, puedes adivinar dos mitades de 16 bits y puntuar qué elección convierte
ambos bloques en cadenas creíbles o instrucciones ARM.
Con eso, se obtuvo una cobertura del 100% de todos los datos de firmware que utilizan este cifrado específico.
Este cifrado parece cubrir la mayoría de los productos 8BitDo alrededor de 2020, y los productos actuales que todavía usan SoCs GD32 (incluido el SN30 Pro).
Algunos dispositivos como el M30 y el Zero 2 parecen ser multisección, con algunos datos de firmware que usan este cifrado y otro firmware con un esquema de cifrado diferente.
Los dispositivos más nuevos, al menos aquellos con un SoC diferente, parecen tener un esquema de cifrado de firmware más fuerte. Un análisis ingenuo muestra que podría ser compartido, pero no es seguro. Es necesario investigarlo más a fondo.