Attacking AES on FPGA (CW305 / Artix7 XC7A100T)
(2022/05/07 ChipWhisperer5.6.0 に対応するよう更新)
FPGAに実装したAESに対してCPAする.
注意
現時点でLatestとしてタグ付けされているv5.5.2の場合,WindowsでCW-LiteとCW305を同時にUSBで繋ぐとcw.scope()
した際にCW-Liteにつながらない問題が発生する.
原因は不明だが,回避できそうな方法のメモを最後に記載している.
v5.6.0では修正(というよりUSBライブラリの変更に関連してコードが一通りリファクタリング?)されているため,Pre-releaseではあるがv5.6.0を使用するか,この問題が発生する前のバージョン(おそらくv5.3.1かそれよりも古いもの)を使用するのがよい(と思われるが,Pre-Releaseなので他のバグが発生する可能性がある).
2020年11月以降にリリースされたファームウェアでは,ChipwhispererはWindows上でPlug-and-Playに対応しているためドライバのインストールは不要である.
これ以前のものを使用している場合,ドライバを手動で適用する必要がある.(参考リンク)
動作確認済み環境
- Windows10 Home (21H2/19044.1645)
- ChipWhisperer-Lite (Mouser)
- CW305 Artix7 FPGA Target (XC7A100T-2FTG256) (Mouser)
- ChipWhisperer 5.6.0
なお,Windowsのサポートはあまり熱心にされていないのでLinux上でGitHubから最新のものをcloneしてきて動かすほうが楽である.
インストールと接続
-
ChipWhispererのGitHubからインストーラをダウンロード
今回はChipWhisperer5.6.0,Windowsの場合はChipwhisperer.v5.6.0.Setup.64-bit.exe
をダウンロードしてインストール. -
公式の指示通りにCW305とChipWhisperer-Liteを接続する.
-
PCにChipWhisperer-LiteとCW305の両方をUSBケーブルで接続する.
動かす
ChipWhispererを起動すると,ブラウザ上でJupyter Notebookが開かれる.
適当に作業用フォルダを設定し,空のNotebookを作ったら以下のプログラムを順にNotebookのセル上にコピペして動作させる.
必要なライブラリのインポート
import chipwhisperer as cw
import os
Chipwhisperer-Liteの設定
scope = cw.scope()
scope.gain.db = 25
scope.adc.samples = 129
scope.adc.offset = 0
scope.adc.basic_mode = "rising_edge"
scope.clock.clkgen_freq = 7370000
scope.clock.adc_src = "extclk_x4"
scope.trigger.triggers = "tio4"
scope.io.tio1 = "serial_rx"
scope.io.tio2 = "serial_tx"
scope.io.hs2 = "disabled"
CW305の設定
bitstreamの書き込み
-
force=False
だと,FPGAに回路がロードされていない時のみ書き込みが行われる -
force=True
だと,問答無用で書き込みが行われる
生成済みbitstreamファイルが以下に提供されている.
Artix7 XC7A100Tの場合
chipwhisperer/hardware/victims/cw305_artixtarget/fpga/vivado_examples/aes128_verilog/aes128_verilog.runs/impl_100t/cw305_top.bit
自分でソースからbitstreamを生成する場合: 公式Tutorial (少し古い), Qiita記事 (公式より新しいが,これも古い)
bitstream = os.path.join(
'..', '..',
'hardware', 'victims', 'cw305_artixtarget', 'fpga',
'vivado_examples', 'aes128_verilog', 'aes128_verilog.runs', 'impl_100t',
'cw305_top.bit'
)
target = cw.target(scope, cw.targets.CW305, bsfile=bitstream, force=True)
プロジェクトファイルの作成
projectオブジェクトのメンバ
- project.location: str
- project.waves: Iterable (M samples x N traces)
- project.textins: Iterable (128 bytes x N traces)
- project.textouts: Iterable (128 bytes x N traces)
- project.keys: (128 bytes x N traces)
- project.get_filename: str
- project.trace_manager: trace_manager object
- project.save
- project.export
project_file = os.path.join('.', 'projects', 'AES')
project = cw.create_project(project_file, overwrite=True)
ジャンパピンの設定
-
J16 = 0
オンボードPLLからFPGAにクロックを供給 -
K16 = 1
FPGAのclockをChipWhispererのHS-Inチャネルに接続
target.vccint_set(1.0) # Set VCC-INT to 1V
# we only need PLL1:
target.pll.pll_enable_set(True) # Enable PLL chip
target.pll.pll_outenable_set(False, 0) # Dsable unused PLL0
target.pll.pll_outenable_set(True, 1) # Enable PLL
target.pll.pll_outenable_set(False, 2) # Disable unused PLL2
# run at 10 MHz:
target.pll.pll_outfreq_set(10E6, 1)
# optional, but reduces power trace noise
# 1ms is plenty of idling time
target.clkusbautooff = True
target.clksleeptime = 1 # 1ms typically good for sleep
ADCのロックに失敗する場合があるが,何回か試してるうちに成功する. (公式情報)
Liteだとほぼ失敗しないが,Proはよく失敗する
# ensure ADC is locked:
scope.clock.reset_adc()
assert (scope.clock.adc_locked), "ADC failed to lock"
トレースの取得
from tqdm import tnrange
import numpy as np
import time
from Crypto.Cipher import AES
ktp = cw.ktp.Basic()
ktp.setInitialKey('CA FE BA BE DE AD BE EF FE EB DA ED EB AB EF AC') # Secret key
traces = []
textin = []
keys = []
N = 10000 # Number of traces
# initialize cipher to verify DUT result:
key, text = ktp.next()
cipher = AES.new(bytes(key), AES.MODE_ECB)
for i in tnrange(N, desc='Capturing traces'):
# run aux stuff that should come before trace here
key, text = ktp.next() # manual creation of a key, text pair can be substituted here
textin.append(text)
keys.append(key)
ret = cw.capture_trace(scope, target, text, key)
if not ret:
print("Failed capture")
continue
# HWでの暗号化の結果が合ってるか確認する
assert ( list(ret.textout) == list( cipher.encrypt(bytes(text)) ) ), \
"Incorrect encryption result!\nGot {}\nExp {}\n".format(ret.textout, list(text))
#trace += scope.getLastTrace()
traces.append(ret.wave)
project.traces.append(ret)
Plot
from bokeh.plotting import figure, show
from bokeh.io import output_notebook
output_notebook()
p = figure(plot_width=800)
xrange = range(len(traces[0]))
p.line(xrange, traces[0], line_color="red")
show(p)
project.save()
scope.dis()
target.dis()
Attack
-
cwa.leakage_models.last_round_state_diff
9->10ラウンド目のSbox_inレジスタのHamming Distance
import chipwhisperer as cw
import chipwhisperer.analyzer as cwa
project_file = os.path.join('.', 'projects', 'AES')
project = cw.open_project(project_file)
attack = cwa.cpa(project, cwa.leakage_models.last_round_state_diff)
cb = cwa.get_jupyter_callback(attack)
結果の見方
-
設定した秘密鍵の各バイトが列に対応
-
PGE (Partial Guessing Entropy)は正解鍵のランクに対応 (攻撃成功時に0)
-
0~4の行は候補鍵の相関値Top-5を示しており,赤色(正解鍵)がRank0になればそのバイトは攻撃成功となる
attack_results = attack.run(cb)
保存する
import pickle
pickle_file = project_file + ".results.pickle"
pickle.dump(attack_results, open(pickle_file, "wb"))
答え合わせ
from chipwhisperer.analyzer.attacks.models.aes.key_schedule import key_schedule_rounds
recv_lastroundkey = [kguess[0][0] for kguess in attack_results.find_maximums()]
recv_key = key_schedule_rounds(recv_lastroundkey, 10, 0)
for subkey in recv_key:
print(hex(subkey))
0xca
0xfe
0xba
0xbe
0xde
0xad
0xbe
0xef
0xfe
0xeb
0xda
0xed
0xeb
0xab
0xef
0xac
key = list(key)
assert (key == recv_key), "Failed to recover encryption key\nGot: {}\nExpected: {}".format(recv_key, key)
以下,チュートリアルにはない追加部分
自分でCPAしてみる
# 波形のロード
project_file = os.path.join('.', 'projects', 'AES')
project = cw.open_project(project_file)
9->10 round目のsbox_inのHDベースのCPA
関数定義
from chipwhisperer.analyzer import aes_funcs
from chipwhisperer.analyzer.attacks.models.base import getHW
# HDの計算
# v: previous value of sbox_in register
# k: key candidate
# c: cipher
def hd_model( v, k, c ):
return getHW( aes_funcs.inv_sbox(v ^ k) ^ c )
# レジスタのHDの計算
# 順方向: Round9-output -> sbox -> shift_row -> add_round_key
# 求めたいのは HD(Round9-output, cipher)
def calc_hd(cipher, target_byte):
v = cipher[target_byte]
c = cipher[ np.where(np.array(aes_funcs.inv_shiftrows(list(range(16)))) == target_byte)[0][0] ] # inv_shift_row(cipher)
hd = []
for k in range(256):
hd.append([np.array(hd_model(v, k, c), dtype=np.float32)])
return np.concatenate(hd, axis=0)
# ピアソンの相関係数の計算
# x, yの相関を計算する
# yが2次元の場合,axis=1に沿って相関を計算する
def pearsonr(x, y):
'''
sum( ( x-x.mean ) * ( y-y.mean ) )
r = --------------------------------------------------
root( sum( (x-x.mean)^2 * (y-y.mean)^2 ) )
'''
x = np.asarray(x, dtype=np.float64)
y = np.asarray(y, dtype=np.float64)
# xとyの次元数がともに1のとき
#普通に相関を計算する
if x.ndim == 1 and y.ndim == 1:
mx = x.mean()
my = y.mean()
xm = x-mx
ym = y-my
r1 = np.sum( xm * ym )
r2 = np.sqrt( np.sum(xm**2)
* np.sum(ym**2) )
ret = r1/r2
if np.isnan(ret):
return 0
else:
return ret
# yの次元数が2の時
# yの2次元目に沿って相関を計算する
if x.ndim == 1 and y.ndim == 2:
assert(x.shape[0] == y.shape[1]), "size of axis 1 is must be same size of x, x:{} != y:{}".format(x.shape[0], y.shape[1])
xm = x-x.mean()
ym = y-y.mean(axis=1).reshape(-1,1)
r1 = np.sum( xm.reshape(1,-1) * ym, axis=1 )
r2 = np.sqrt(
np.sum(xm*xm).reshape(-1,1)
* np.sum(ym*ym, axis=1)
)
ret = (r1/r2).reshape(-1)
ret[np.isnan(ret)] = 0
return ret
攻撃
target_byte = 0 # 攻撃対象のバイト鍵
correct_key = project.keys[target_byte] # 正解鍵
outs = project.textouts # Cipher
waves = project.waves # 電力波形
各候補鍵におけるレジスタのHDを計算する
hds = []
for o in outs:
hds.append([calc_hd(o, target_byte)])
hds = np.concatenate(hds, axis=0)
レジスタのHDと電力波形の相関を計算する
ret = []
for i in range(256):
ret.append([pearsonr(hds[:, i], np.array(waves).T)])
ret = np.concatenate(ret, axis=0)
結果のプロット
import matplotlib.pyplot as plt
from chipwhisperer.analyzer.attacks.models.aes.key_schedule import key_schedule_rounds
# Mean waveform
fig1 = plt.figure()
ax1 = fig1.add_subplot(111)
ax1.plot(np.mean(waves, axis=0))
ax1.set_title('Mean waveform')
# CPA result
fig2 = plt.figure()
ax2 = fig2.add_subplot(111)
ax2.plot(ret.T, 'gray')
ax2.plot(ret[ aes_funcs.key_schedule_rounds(correct_key, 0, 10)[target_byte], :], 'red')
ax2.set_title('target_byte: {}'.format(target_byte))
plt.show()
10ラウンド目の計算を行っているタイミングで相関が正解鍵の相関が最大になっていることがわかる.
おわり.
メモ:v5.5.2で発生する接続エラーのバグ探しの途中経過
原因となるコードはだいたいここ(chipwhisperer/hardware/naeusb/naeusb.py).
NAEUSB_Backend
クラスの定義(L252-)内,有効なデバイス一覧を取得する関数get_possible_devices
(L422-)において,以下のようなバグが観測された.
-
L448
dev = list(usb.core.find(**my_kwargs))
にて,変数devに接続可能なデバイスオブジェクトのList(CW-LiteとCW305の2台ぶん)が代入される. -
変数devの要素数が2以上なので,以下のループ(L460-468)が実行される.
for d in dev:
try:
d.serial_number
devlist.append(d)
except ValueError as e:
if "langid" in str(e):
naeusb_logger.info('A device raised the "no langid" error, it is being skipped')
else:
raise
ここで,正常な場合でもd.serial_number
の実行時にValueError例外が発生する.
また,発生する例外にlangid
が含まれないためそのまま無名の例外が発生し,そのままに接続エラーとなる.
なぜか,d.serial_number
をtryの外で一度呼んでやるとCW-Liteとの接続に成功する(が,その後CW305が繋がるかはテストしていない).
for d in dev:
d.serial_number
try:
# d.serial_number # ちなみに,tryの外で一度呼ぶと,tryの中で呼んでも例外は発生しないよう.
devlist.append(d)
except ValueError as e:
if "langid" in str(e):
naeusb_logger.info('A device raised the "no langid" error, it is being skipped')
else:
raise
あとnaeusb.pyを5.6.0のものにそのまま置き換えても動いた.
5.6.0の場合,こんなにややこしいコードにはなっていないのでそもそもこのようなバグには遭遇しそうにない.多分.