Krakenセキュリティ研究所が発見、Trezorの致命的欠陥とは?
はじめに
この記事では、2020年1月31日に Kraken Security Labs が発表した記事をもとに当時の Trezor が抱えていた致命的欠陥についてまとめています。
概要
Kraken Security Labs は、Trezor からシードを抽出する方法を考案しました。この攻撃のポイントは以下の通りです。
デバイスへの物理的なアクセスがわずか 15 分程度で可能。
暗号化されたシードを抽出するために電圧グリッチを使用。
暗号化されたシードは、暗証番号(PIN)で保護されているが、ブルートフォース(総当り)攻撃により解読することは容易。
技術的詳細
Trezor ウォレットからシードを抽出することは、新しい領域ではありません。Trezor は、第35回 Chaos Communication Congress での講演で実証されたグリッチ攻撃に対する緩和策を含め、過去の様々なハードウェア攻撃に対する重要な緩和策を実装しています。今回の攻撃は、この緩和策を迂回するための研究を基にしたものです。
この攻撃は、フォールト・インジェクション攻撃により、プロセッサの内蔵ブートローダを再有効化することから始まります。このブートローダは、デバイスのフラッシュコンテンツを読み出す機能を備えていますが、コマンドを実行する際にチップの保護レベルを検証します。2 回目のフォールト・インジェクション攻撃により、このチェックを回避し、デバイスのフラッシュコンテンツを一度に 256 バイトずつ取り出すことが可能です。この攻撃を繰り返すことで、すべてのフラッシュコンテンツを取り出すことが可能です。
Trezor のファームウェアは暗号化されたストレージを使用しているため、ダンプされたデバイスの PIN をクラックするスクリプトを開発しました。このスクリプトは、任意の 4 桁の PIN を 2 分未満でブルートフォースすることができました。この攻撃は、STM32 ファミリーの Cortex-M3/Cortex-M4 マイクロコントローラは、暗号化された形で保存されている場合でも、暗号シードなどの機密データの保存に使用すべきではないことを実証しています。
Trezor の公式 wiki サイトによると、2022年1月31日時点においても、Trezor One では STM32 F2 シリーズ、Trezor T では STM32F4 MCU シリーズを使用しています。これらがこの攻撃に対して対策を行っているのかはよくわかりません。
STM32におけるフラッシュおよびSRAMの読み出し保護
STM32 ファミリーは、Read Protection(読み出し保護)または RDP と呼ばれるセキュリティ・メカニズムを実装しています。ARM Cortex-M デバイスの不揮発性ストレージはフラッシュメモリのみであるため、RDP の値は、アプリケーションコードからの書き込みが不可能なフラッシュメモリの特別なページに保存されます。RDP の値は、Option Byte と呼ばれるマイクロコントローラのコンフィギュレーションビットによって定義されます。STM32 デバイスには 3 つの Option Byte 値が用意されています。
RDP Level | Option Byte 値 | 内容
-----------------------------------------------------------
0 | 0xAA | SRAM, フラッシュへのフルアクセスが可能
1 | 0xAA, 0xCC 以外 | SRAM への読み取りのみ可能
2 | 0xCC | SRAM, フラッシュへのアクセス不可
STM32 マイコンの不揮発性メモリはフラッシュのみであるため、暗号のシードや秘密鍵の不揮発性ストレージもここに保存することになります。そのため、フラッシュは読み出されないように保護する必要があります。Trezor は、初回起動時に RDP をレベル 2 に設定した上で出荷されています。その結果、実際には、ユーザデバイス上の非開発ファームウェアは常に RDP2(RDP Level 2)となり、攻撃者が SRAM やフラッシュにアクセスすることを防いでいます。しかし、様々な研究で実証されたように、RDP2 から RDP1 へのダウングレードは、電圧グリッチによって起動時に実行することが可能です。デバイスが RDP1 になると、その SRAM は ARM SWD デバッグプロトコルを介して読み出すことができます(上記の表を参照)。
STM32 の複雑なパワーオンリセット(POR)ロジックによって、通常のリセットアサートでは、完全なパワーオンリセットと BootROM の再実行が行われません。これは、セキュリティ・コンフィギュレーションを変更する場合(RDP レベルを変更するために Option Byte を変更する場合)、一般にチップのパワーサイクルを必要とすることからもある程度確認することができます。逆に、チップのグリッチに成功し、その結果セキュリティ・コンフィギュレーションのダウングレードが行われると、チップの電源を切るまで、このセキュリティダウングレードが有効であることを意味します。
つまり、攻撃者は BootROM を実行している間、またはアプリケーションコードをロードせずにアプリケーションコードの非常に早い段階で、デバイスのグリッチを繰り返し試行し、グリッチが成功したかどうかを確認できます。その結果、攻撃者はアプリケーションコードを実行する前にグリッチが成功したことを確認できるため、このクラスの攻撃に対して有効な対策が存在しません。
攻撃者がデバイスのグリッチに成功すると、攻撃者はターゲットのソフトリセットを実行するだけで、システムは RDP1 で動作し続け、攻撃者は任意の時点の SRAM メモリの内容を任意に読み取ることができます。暗号通貨取引の署名に必要な暗号を実装するライブラリの多くは、計算のために機密情報を SRAM にロードすることに依存しているため、これは特に問題です。
STM32ブートプロセスとグリッチパラメータ
マイコンの動作の多くは、電源投入時に読み取る値で定義されます。ブート時に読み込まれるストラップピン(BOOT ピン)やセキュリティ・コンフィギュレーション・ビット(Option Byte)などがその例です。ほとんどの Cortex-M マイコンは、ブート時に実行される ROM を含んでおり、一般に BootROM と呼ばれています。BootROM は、チップによって実行されるソフトウェアの最初の部分であり、チップのセキュリティ・コンフィギュレーションなどの重要なパラメタをロードする役割を担っています。その後、ユーザアプリケーションやアプリケーションコードが実行されます(ハードウェアウォレットの場合、これは製造者の実際のファームウェアです)。
STM32F2 は比較的複雑であるためか、チップへの電源投入後、約 1.2 ms ~ 1.8 ms と、起動に非常に長い時間がかかります。ブート時間は、デバイスの消費電力を観察することで測定可能です。最初の 100 us ~ 200 us の間に、チップの BootROM が実行されます。
内臓BootROMブートローダーの再有効化
STM32 の起動時には、アプリケーション・コード(すなわちウォレット・ファームウェア)が実行される前に、内臓 BootROM が実行されます。BootROM は、デバイスのセキュリティ状態を構成するいくつかのチェックを実装しています。STM32 で利用可能な不揮発メモリはフラッシュだけなので、セキュリティ・コンフィギュレーションもフラッシュに保存する必要があります。
フラッシュには、セキュリティとデバイスのコンフィギュレーション(Option Byte)を保存するための特別な専用領域が存在します。デバイス全体のセキュリティにとって最も重要なのは、Option Byte に含まれる読み取り保護レベル (RDP レベル) で、これは事実上デバイスのセキュリティ・コンフィギュレーションに相当します。
BootROM 実行中に、RDP の値がチェックされます。RDP チェック時の値が RDP2 でない場合、BootROM は 2 つの専用 I/O ピンのブートストラップを、あらかじめ定義されたブートパターン(BOOT0 および BOOT1 ピンの論理レベル)と照らし合わせてチェックします。ブートパターンに合致しない場合、通常の実行が継続され、アプリケーションコードがフラッシュから実行されます。しかし、パターンに合致した場合(STM32F2 の場合)、BOOT0 が High で、BOOT1 が Low であれば、内臓 BootROM シリアルブートローダおよび DFU ブートローダが有効になります。STM32 は複数のシリアル・プロトコルをサポートしているため、シリアル・インタフェースの 1 つで有効な同期条件が満たされると、1 つまたは複数のシリアルブートローダが初期化され、無効化されます。いったん同期されると、ブートローダは、コマンドを受信して実行するモードに入ります。
STM32 BootROM ブートローダ
STM32 BootROM ブートローダは、フラッシュの読み書きができるコマンドをはじめ、さまざまなコマンドをサポートしています。Read Memory コマンドを使用すると、デバイスのフラッシュから最大 256 バイトを読み出すことができます。BootROM を解析した結果、このコマンドは呼び出される度に RDP レベルのチェックを行い、RDP レベル 0 の場合にのみフラッシュ・コンテンツの読み出しが可能であることが判明しました。
Read MemoryコマンドのRDPチェックを回避する方法
Trezor One に使用されている STM32 は、製造時に RDP Level 2 に設定されています。これにより、すべてのデバッグ機能が無効になり、内蔵の BootROM ブートローダも無効となります。電圧グリッチにより、Option Bytes から読み取られる RDP 値を破損させることが可能です。これにより、攻撃者は、ターゲットデバイスのセキュリティ設定を RDP Level 2 から RDP Level 1 にダウングレードすることができます。RDP Level 1 から RDP Level 0 へのダウングレードは、RDP Level 0 と RDP Level 2 の間のハミング距離により、実現不可能であるとわかりました。BootROM 実行中に電圧グリッチを行うことで、JTAG および SWD デバッグインターフェイスを再有効化することが可能です。内臓 BootROM ブートローダも、同様の方法で再有効化できることが確認されました。
例えば STM32F205 では、BootROM 実行中の約 170us でグリッチすることで、JTAG と SWD を再有効化することができます。これにより、RDP Lecel 1 と同じ動作で、JTAG と SWD のインターフェイスを実質的に有効にすることができるようになります。BootROM 実行中の約 180us の電圧グリッチにより、RDP Level 1 のブートローダと同じ動作で内臓 BootROM ブートローダが再有効化になることがわかりました。内蔵 BootROM ブートローダとの通信が確立すると、つまり同期が完了すると、RDP Level 1 の BootROM ブートローダで利用できるコマンドを発行できるようになります。
ただし、特定のコマンド「Read Memory」について、BootROM ブートローダ・コマンドハンドラは、デバイスに発行されたコマンドごとに、デバイスの RDP レベルが 0 であるかどうかを確認することが決定されたためです。RDP Level 0 以外の RDP Level が無効なコマンドを処理中に、コマンドハンドラの RDP Level チェックとタイミングを合わせた電圧グリッチが発生すると、RDP Level チェックがバイパスされ、コマンドが成功することがあります。つまり、デバイスの RDP 設定に基づき失敗するはずのコマンドをグリッチする(NACK を返すはずのコマンドハンドラをバイパスする)ことが可能です。その結果、RDP Level 1 や RDP Level 2 では利用できないコマンドを実行することが可能になります。また、Read コマンドに適用すれば、マイコンから任意にフラッシュメモリを読み出すことが可能です。多くの STM32 ベースのウォレットの暗号シードは、STM32 のフラッシュに格納されているため、これらのデバイスのシード格納が危殆化する可能性があります。
フラッシュダンプのPINブルートフォース
Trezor は、PIN とソルトから得られるキーで機密保存を暗号化します。ソルトは、OTP バイトに格納されているハードウェアソルトと、デバイスのプロビジョニング時に生成されるフラッシュからのランダムソルトから構成されています。ハードウェアソルトは 44 バイトの長さで、Option Byte を 1 回読み込むだけで読み込むことができます。
Trezor は、アプリの値とキーの値によって識別できる「エントリ」でストレージを整理しています。ソルト(salt)、暗号化データ暗号化キー(EDEK)、暗号化ストレージ認証キー(ESAK)、PIN 検証コード(PVC)を含むエントリは、アプリ値 0、キー値 2 で見つけることができます。このエントリは、常に 16 進数のバイト「02 00 3C 00」で始まるため、場所を特定することが可能です。エントリ全体の長さは 64 バイトであるため、ハードウェアソルトの読み取りと組み合わせると、2 回の読み取りを成功させるのに十分です。
salt、EDEK、ESAK、PVC が読み出された後、OTP バイトから追加のハードウェアソルトが読み出される必要があります。これは、SWD インタフェースを再有効化するか、BootROM ブートローダを再有効化して、対応する Read Memory コマンドを発行することによって行うことができます。
シングルコア CPU で Python「pycryptodome」ライブラリを使用した場合、約 85 ハッシュ/秒のパフォーマンスが達成されました。この比較的遅い速度でも、4 桁の PIN を 2 分未満でブルートフォースすることができます。
#!/usr/bin/env python3
import hashlib
import sys
import argparse
import binascii
import struct
from Crypto.Cipher import ChaCha20_Poly1305
parser = argparse.ArgumentParser(description=’Crack some wallets.’)
parser.add_argument(‘flash_dump’, help=’The flash dump to parse.’)
parser.add_argument(‘–otp’, help=’Randomness from OTP as hex.’, default=44*”00″)
parser.add_argument(‘–debug’, type=bool)
args = parser.parse_args()
flash_file = open(args.flash_dump, “rb”)
def find_header():
while True:
data = flash_file.read(4)
if data == b”\x02\x00\x3c\x00″:
return
elif data == None:
print(“Couldn’t find header.”)
sys.exit(1)
find_header()
# Salt from flash
salt = flash_file.read(4)
# EDEK + ESAK
edek = flash_file.read(48)
pvc = flash_file.read(8)
# Salt computation:
# hardware_salt (from collect_hw_entropy)
# random_salt (Random buffer generated, stored in flash)
# ext_salt – unused
hardware_salt = hashlib.sha256(binascii.unhexlify(args.otp)).digest()
salt_assembled = hardware_salt + salt
print(f”Hardware salt: {binascii.hexlify(hardware_salt)}”)
print(f”Assembled salt: {binascii.hexlify(salt_assembled)}”)
def trezor_pbkdf(pin, salt):
# PIN is always prefixed with 1
pin_bytes = struct.pack(“<I”, int(“1″ + pin))
if args.debug:
print(f”PIN bytes: {binascii.hexlify(pin_bytes)}”)
dk = hashlib.pbkdf2_hmac(‘sha256’, pin_bytes, salt, 10000, dklen=352/8)
return dk
def chacha_enc(kek, keiv, data):
cipher = ChaCha20_Poly1305.new(key=kek, nonce=keiv)
# Return DEK & TAG
return cipher.encrypt_and_digest(data)
def chacha_dec(kek, keiv, edek):
cipher = ChaCha20_Poly1305.new(key=kek, nonce=keiv)
return cipher.decrypt(edek)
for i in range(1, 9999):
if i % 100 == 0:
print(f”Currently trying: {i}”)
dk = trezor_pbkdf(str(i), salt_assembled)
# First 256 bits are Key Encryption Key (KEK)
KEK = dk[:int(256/8)]
# Remaining 96 bits are Key Encryption Initialization Vector (KEIV)
KEIV = dk[int(256/8):]
if args.debug:
print(f”KEK: {binascii.hexlify(KEK)}”)
print(f”KEIV: {binascii.hexlify(KEIV)}”)
DEK = chacha_dec(KEK, KEIV, edek)
# Encrypt to get the TAG, unfortunately seems to be the only
# way to retrieve it from Pycryptodome.
enc, TAG = chacha_enc(KEK, KEIV, DEK)
if args.debug:
print(f”DEK: {binascii.hexlify(DEK)}”)
print(f”TAG : {binascii.hexlify(TAG)}”)
print(f”PVC : {binascii.hexlify(pvc)}”)
if pvc in TAG:
print(“SUCCESS”)
print(“PIN is:”)
print(i)
sys.exit(0)
※ こちらの Python コードは Kraken の記事に載っていたものですが、インデントがない状態だったため上記のインデントで正しいか確認はできていません。
鍵の導出
エントリーのソルトにハードウェアソルトを付加したものは、PIN(先頭に 1 を付け、32 バイトのリトルエンディアン整数に変換)を組み合わせて、SHA-256 による PBKDF2-HMAC で、362 ビットの派生鍵を導出します。その結果の最初の 256 ビットが鍵暗号化鍵(KEK)として、最後の 96 ビットが鍵暗号化初期化ベクトル(KEIV)として使用されます。
鍵の検証
KEK と KEIV を用いて EDEK を復号化し、EDEK から DEK(Data Encryption Key)を生成します。暗号化アルゴリズムは ChaCha20 と Poly1305 メッセージ認証コードを使用します。PVC の最初の 64 ビットと TAG の最初の 64 ビットが一致すれば、PIN は正しく、DEK の復元に成功したことになります。
参考文献
この記事が気に入ったらサポートをしてみませんか?