针对 8BitDo 在若干基于 GD32 的产品中所使用固件加密的逆向工程文档与工具。
有关应用本文档所述算法的解密与重新加密工具,请参见 8cryptdo.py。
该工具可能使创建自定义固件成为可能。然而,本仓库不包含任何官方固件数据。用于下载最新固件文件的脚本可在 fwupd/8bitdo-firmware 仓库中找到,其中还归档了一些较旧的固件二进制文件。
从表面上看,固件 .dat 文件存在一个 28 字节的明文头部。
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
在较新的固件文件中,这些字段之后还有一个未知的 32 位值。头部的其余部分为零。
在某些固件文件上会出现一种模式:存在一个将每个字混入下一个字的层。
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))
这种链式处理在每个 128 字块边界处重置。块中的第一个字(pos = 0)没有前驱项,而 pos = 1..127 则从前一个字进行链式处理。
无密钥层不会增加任何安全性。将其剥离后会显露出更清晰的中间结果(dechained),此时唯一隐藏明文的就是密钥流。
P[i] = dechained[i] ^ keystream[i]
fwupd/8bitdo-firmware 仓库中有一份较旧固件的目录。这些固件似乎都带有链式层。
在移除链式层后将其中一些固件相互 XOR,会显露出大段连续的零以及可读的结构。
plaintext_diff = [a ^ b for a, b in zip(dechained_a, dechained_b)]
如果两者使用相同的密钥流……
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
密钥流就会相互抵消。
这是一个经典的多次一密。这样的密钥流只有在仅使用一次时才是安全的。出于某种原因,8BitDo 决定不仅使用按产品区分的密钥流,而是在多个产品之间使用单一密钥流。这显著削弱了其密码的安全性,并使此处的其余分析成为可能。
一旦你知道两个明文被 XOR 在一起,你就可以猜测其中一个的一部分,并减去你的猜测来读取另一个。这样就能在该位置恢复出 keystream。
keystream[i] = dechained[i] ^ P[i]
for fw in all_fw:
P[fw][i] = dechained[fw][i] ^ keystream[i]
用字符串来做会最容易,但这里最有效的技巧是 ARM 指令的跨版本重定位。一个函数可能以偏移后的位置出现在两个固件版本中。在某个版本中密钥流已知的位置,你可以在另一个版本中对齐共享代码,并获取新的密钥流值。
利用这一点,已知密钥流字数从约 1,500 个提升到数千个,大约占固件的 18% 可读。
有了更大样本的 keystream 后,模式变得可见。它有一些相隔 16 个字的位置,以几乎恒定的量递增,并且每个字节以可预测的大小移动。
通过反复试验,发现一个块中的每个密钥流字都遵循一个公式:
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
在一个块内,遍历一个简单的计数器(block_base(block) + pos*STEP),并将其与一个从 block_key 开始、每步旋转 18 位的掩码进行 XOR。整个 128 字的块由一个 32 位数字 block_key 决定。
一个已知字就能解锁整个块。重新排列公式可从任意已知密钥流字得出 block_key:
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)
每个块的密钥从何而来?它分解为两个 16 位半部,取自一个 256 项的种子表:
def block_key_of(block, seed_table):
hi = seed_table[block]
lo = seed_table[block ^ 0xF9]
return (hi << 16) | lo
种子表本身似乎没有特定的公式,它很可能是存储在每个设备引导加载程序某处的 512 个常量字节。然而,与 0xF9 的 XOR 提供了一个漂亮的副作用:镜像律。一个块及其配对块 block ^ 0xF9 由相同的两个表项交换构建而成。
mirror = block_key_of(block ^ 0xF9, seed_table)
assert mirror == rotr(block_key_of(block, seed_table), 16)
这破解了此前未知的区域。与其盲目猜测完整的 32 位 block_key,你可以猜测两个 16 位半部,并对哪种选择能将两个块都变成可信的字符串或 ARM 指令进行评分。
由此,使用此特定密码的所有固件数据实现了 100% 覆盖。
此密码似乎覆盖了 2020 年前后的大多数 8BitDo 产品,以及仍在使用 GD32 SoC 的当前产品(包括 SN30 Pro)。
一些设备如 M30 和 Zero 2 似乎是多段的,其中一些固件数据使用此密码,而另一些固件使用不同的加密方案。
较新的设备,至少那些使用不同 SoC 的设备,似乎具有更强的固件加密方案。一些初步分析表明它可能是共享的,但不确定。需要进一步调查。