
Реверс-инжиниринг документации и инструментов для шифрования прошивки 8BitDo
Документация и инструменты, полученные путём обратной разработки, для шифрования прошивок, используемого 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), и он
объединяется операцией XOR с маской, которая начинается с block_key и поворачивается на 18 бит
на каждом шаге. Весь блок из 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-битные половины, взятые из таблицы seed из 256 элементов:
def block_key_of(block, seed_table):
hi = seed_table[block]
lo = seed_table[block ^ 0xF9]
return (hi << 16) | lo
Сама таблица seed, по-видимому, не имеет конкретной формулы; вероятно, это 512
константных байтов, хранящихся где-то в загрузчике каждого устройства. Однако
XOR с 0xF9 даёт прекрасный побочный эффект: закон зеркала. Блок и
его партнёр 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% покрытие всех данных прошивок, использующих этот конкретный шифр.
Этот шифр, по-видимому, охватывает большинство продуктов 8BitDo примерно 2020 года, а также текущие продукты, которые всё ещё используют SoC GD32 (включая SN30 Pro).
Некоторые устройства, такие как M30 и Zero 2, по-видимому, являются многосекционными: часть данных прошивки использует этот шифр, а другая прошивка использует иную схему шифрования.
Более новые устройства, по крайней мере те, что имеют другой SoC, по-видимому, используют более стойкую схему шифрования прошивки. Некоторый поверхностный анализ показывает, что она, возможно, является общей, но это не точно. Это нужно исследовать дальше.