Series: 故障注入

使用ChipWhisperer-Nano进行故障注入


使用ChipWhisperer-Nano进行故障注入

2026.10.14:更新ing

实物:

image.png

下图为内置目标原理图,测量点在箭头位置。

目标供电路径中的采样电阻产生电压变化,Nano 的模拟输入电路负责测量它。

image.png


观察电源和电压的变化:

chipwhisperer-current-voltage.gif

只不过Nano会有个反相放大器把下降翻转为上升,所以ADC的度数还是符合常理的。

波形数值 = 原始 ADC 数值 / 256 - 0.5

但是测量到的ACD度数不能直接代表当时的电压。正常来说,直接测量该点的电压为:

稳定的 3.3 V 电源 ── 10 Ω 分流电阻 ── 芯片
                                       ↑
                                在这里直接测电压
                                
如果测到 3.2 V:
电阻压降 = 3.3 − 3.2 = 0.1 V
电流     = 0.1 ÷ 10 = 0.01 A = 10 mA
芯片功率 ≈ 3.2 × 0.01 = 0.032 W

如果另一次测到 3.1 V:
电阻压降 = 3.3 − 3.1 = 0.2 V
电流     = 0.2 ÷ 10 = 0.02 A = 20 mA
芯片功率 ≈ 3.1 × 0.02 = 0.062 W

在这个简化例子里,确实能通过电压比较出功率。前提是电源电压、电阻值已知,且通过电阻的电流就是芯片电流。

但 Nano 实际采集的链路多了几个环节:

芯片供电节点的电压
        ↓
交流耦合:去掉直流成分,主要保留变化
        ↓
反相放大、加入适合 ADC 的偏置
        ↓
ADC 转换成数字
        ↓
软件归一化后画图

对应 Nano:想观察的是芯片执行运算时产生的细小、快速的电流变化。交流耦合让这些变化更容易被放大观察,但也因此丢失了用于计算绝对电流、总功耗的直流水平。


翻译了ChipWhisperer官方文档和Jupyter教程。

image.png

实验一:侧信道分析 101

2.1A:不同指令的功耗差异

芯片执行程序时,耗电电流会发生细微变化;Nano 把这些变化转换成电压,再高速采样成数字。

只是单纯的20次乘法对比无行为,电压变化不是很明显:

image.png


执行0、80、160次字节反转下的电压变化。

image.png

2.1B:利用功耗分析绕过密码

已知密码的第一个字母是h:

在前100个采样点即可看出差异

image.png

相同输入两次对比,不同输入对比:

image.png

测试密码第一位:遍历abcdefghijklmnopqrstuvwxyz0123456789的波形,35条都重叠,只有一条除外:

image.png

单独高亮h:

image.png

为了让差异更明显,让每条波形减去确定错误的波形(空字符\x00):

image.png

让程序自动处理这种差异:

每条差值曲线,先取绝对值,再求和

h明显高于其他:

image.png

编写完整的攻击代码,取求和最高的为密码候选:

image.png

3.1:汉明重量的大幅变化

使用AEC加密第一字节为0x0或者0xF是否有功耗上的区别(其他字节随机)?

测量加密0和F共计100次的功耗,归一化后相减:

image.png

可见加密F的功耗在某一瞬间明显高于0。其他字节随机变化,分组求平均后,它们的影响会部分抵消,因此更容易研究第一字节的影响。


加密全0和全F的区别:

image.png

3.2 从单比特数据恢复 AES 密钥

假设我们可以通过某种方法拿到AES加密后的1bit的密钥结果。

那么我们是否可以利用这1bit的泄露恢复AES密钥?为了方便理解,这里只研究 一个输入字节、一个密钥字节,以及 AES 开始时的一小段运算。密钥字节只有 0~255,所以可以全部尝试。

1.先建立计算模型:知道算法,不知道密钥

我们知道设备内部执行的是:

输入字节 → 与密钥字节异或 → 查 S 盒 → 得到一个内部结果字节

用代码表示:

def aes_internal(inputdata, key):
    return sbox(inputdata ^ key)

这个函数是我们的“预测工具”:只要给它一个输入和一个候选密钥,就能计算对应的内部结果。

2.模拟一个藏着真实密钥的设备

def aes_secret(inputdata):
    secret_key = 0xEF
    return aes_internal(inputdata, secret_key)

这里人为把真实密钥设为 0xEF。虽然你能看到源码,但实验约定:猜测程序不知道这个值,只能使用后面收集到的观察结果。

3.模拟泄漏:只给观察者看一位

假设内部结果是:

00011011

观察器只暴露最低位:

???????1

代码就是:

aes_secret(inputdata) & 1

这里的 1 是代码从结果中提取出来的,不是 Nano 从硬件上读到的。

4.收集很多组“输入—泄漏结果”

随机生成 1000 个输入,每个输入都送进模拟设备:

input_data = [random.randint(0, 255) for _ in range(1000)]
leaked_data = [aes_secret(x) & 1 for x in input_data]

于是我们手里有:

第几次已知的输入观察到的泄漏
1一个字节0 或 1
2一个字节0 或 1
………
1000一个字节0 或 1

输入和泄漏记录是一一对应的。 之后猜密钥时,这批记录保持不变。

5.尝试一个候选密钥,预测同样的 1000 个结果

例如假设密钥是 0x23:

predicted = [
    aes_internal(x, 0x23) & 1
    for x in input_data
]

现在有两份答案:

真实观察:1 0 1 1 0 …
候选预测:1 1 0 1 0 …
          ✓ × × ✓ ✓

num_same() 就是数有多少个位置相同,作为这个候选密钥的得分。

6.遍历所有候选密钥,再按得分排序

依次尝试:

00、01、02、……、FE、FF

在这个无噪声模拟里:

  • 错误密钥:通常只能碰巧猜中一部分,大约在一半附近。
  • 正确密钥:相同输入、相同算法、相同密钥,应该全部匹配。

np.argsort(scores)[::-1] 用来把候选密钥按得分从高到低排列。若设置没改,0xEF 应当得到 1000/1000。

7.如果连泄漏的是哪一位都不知道,就一起猜

用这个函数取出任意一位:

def get_bit(data, bit):
    return (data >> bit) & 1

然后尝试所有组合:

假设泄漏第 0 位 → 尝试 256 个密钥
假设泄漏第 1 位 → 尝试 256 个密钥
……
假设泄漏第 7 位 → 尝试 256 个密钥

总共 8 × 256 = 2048 种组合。每一种都与同一份 leaked_data 比较,寻找完全匹配的组合。

整个流程可以记成:

选择很多输入
      ↓
收集每个输入对应的泄漏信息
      ↓
提出一个密钥猜测
      ↓
用已知算法预测泄漏信息
      ↓
与实际记录比较、打分
      ↓
换下一个猜测
      ↓
按得分筛选候选密钥

它与后续真实功耗实验的连接是:

当前模拟实验后续功耗实验
直接得到准确的 0 或 1得到带噪声的功耗波形
统计预测 bit 与真实 bit 相同的次数检查预测的内部数据与波形是否有统计关系
正确猜测可以全部匹配正确猜测可能表现出更明显的差异或相关性

核心就是:密钥猜对时,你预测的内部行为,应该与实际观察到的信息相符。 单次泄漏只有一点信息,但许多次已知输入对应的泄漏,可以共同帮助排除错误猜测。