
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
キーストリームは相殺される。
これは古典的な many-time pad である。このようなキーストリームは一度だけ使用 される場合にのみ安全である。何らかの理由で、8BitDoは製品ごとのキーストリーム だけでなく、複数の製品にまたがる単一のキーストリームを使用することを決定した。 これは彼らの暗号のセキュリティを大幅に弱め、ここでの残りの分析を可能にした。
2つの平文が互いにXORされていることがわかれば、一方の一部を推測し、その推測を
差し引いてもう一方を読むことができる。これにより、その箇所の keystream が
復元される。
keystream[i] = dechained[i] ^ P[i]
for fw in all_fw:
P[fw][i] = dechained[fw][i] ^ keystream[i]
これは文字列を使えば最も簡単だろうが、ここで最も生産的なトリックはARM命令の バージョン間再配置だった。ある関数が2つのファームウェアバージョンで位置をずらして 現れることがある。一方のバージョンでキーストリームが判明している箇所では、もう 一方で共有コードを整列させ、新しいキーストリーム値を収穫できる。
これを活用することで、既知のキーストリームワードは約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ワードのブロック全体が、1つの32ビット数 block_key によって決定される。
1つの既知のワードがブロック全体を解錠する。式を並べ替えると、任意の既知の
キーストリームワードから 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)
各ブロックのキーはどこから来たのか? それは256エントリのシードテーブルから 取り出された2つの16ビット半分に因数分解される。
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 は、
同じ2つのテーブルエントリを入れ替えて構築される。
mirror = block_key_of(block ^ 0xF9, seed_table)
assert mirror == rotr(block_key_of(block, seed_table), 16)
これは以前は未知だった領域を解読した。完全な32ビットの block_key を盲目的に
推測する代わりに、2つの16ビット半分を推測し、どちらの選択が両方のブロックを
信憑性のある文字列やARM命令に変えるかを採点できる。
これにより、この特定の暗号を使用するすべてのファームウェアデータの100%カバレッジが 得られた。
この暗号は2020年頃のほとんどの8BitDo製品、およびGD32 SoCを依然として使用して いる現在の製品 (SN30 Proを含む) をカバーしているようである。
M30やZero 2のようないくつかのデバイスはマルチセクションのようで、一部の ファームウェアデータはこの暗号を使用し、別のファームウェアは異なる暗号化 スキームを使用している。
より新しいデバイス、少なくとも異なるSoCを搭載したものは、より強力なファームウェア 暗号化スキームを持っているようである。いくつかの素朴な分析では、それが共有されて いる可能性があることを示しているが、不明である。さらに調査する必要がある。