差分功耗分析(DPA)原理与实现
说到功耗分析,很多人第一反应是看波形、找特征——那种叫简单功耗分析(SPA)。但实际项目中,你不可能每次都遇到那么“配合”的设备。密钥藏在波形里却看不出来,怎么办?
这时候就该 DPA 上场了。差分功耗分析,说白了就是统计学的魔法。它不依赖肉眼找特征,而是靠一堆采样数据的统计差异把密钥“撬”出来。我最早接触 DPA 时也觉得这东西玄乎,但真正动手写过一次算法后,才发现它其实很朴实。
DPA 的基本思路
你想想看,芯片运行时电流会随数据变化而波动。这个波动很小,单看一条轨迹根本看不出什么。但如果我重复做同一个操作(比如加密同一个字节),每次的数据不同,功耗轨迹就会在某几个时间点出现统计差异。差异的位置和大小,正好反映被处理的数据是谁。
DPA 的核心假设是:功耗与某个中间状态比特位存在相关性。通常是目标设备的输出比特或 S 盒输出。攻击者猜测密钥的一部分,计算这个中间比特,然后根据该比特的取值把功耗轨迹分成两组。
关键结论:如果猜测正确,两组功耗轨迹在某个时间点上的平均差会很明显(出现尖峰)。如果猜错,两组差异几乎为零。
记得我有一次在给一块智能卡做评估时,第一次写 DPA 脚本,分组条件写反了,愣是跑了一天没出结果。后来发现是比特选择方向反了……真的是,有些坑踩一次就记住了。
SPA vs DPA:它俩到底差在哪?
| 对比项 | SPA | DPA |
|---|---|---|
| 轨迹数量 | 单条或少几条 | 成百上千条 |
| 分析方法 | 视觉比对 / 模板 | 统计差值 / 相关系数 |
| 抗噪能力 | 弱(噪声淹没信号) | 强(大量平均抵消噪声) |
| 密钥恢复 | 需已知算法细节 | 可逐位猜测 / 分片攻击 |
| 典型场景 | 识别操作序列 | 提取具体密钥字节 |
简单说,SPA 适合看“形”,DPA 适合看“数”。当我只需要判断芯片在做什么操作(比如你是乘法还是加法),SPA 够了。但如果我要把 128 位密钥一个一个比特抠出来——老老实实上 DPA 吧。
DPA 攻击的标准流程
下面这张图是我当年在项目里画给团队看的,后来它几乎成了我们实验室的 DPA 入门图。你们参考参考:
你看,这个流程是迭代的。从第3步到第8步会跑很多次(比如对每个 S 盒或每半个字节)。我曾经在一个评估项目里,对 AES 最后一轮做 DPA,每个字节需要跑 256 次猜测,8 个字节就是 2048 遍。脚本没写循环?等死吧。
小技巧:采集功耗迹时尽量保证输入数据是随机均匀的。如果输入全都一样,那中间比特分布会偏,差分信号会被淹没。推荐用 PRNG 生成随机的明文或密文。
实现中的关键点和坑
1. 对齐问题
别以为把探头夹上就能出结果。采样触发的抖动会让每条功耗迹的时间轴错开,一平均,尖峰就被抹平了。我遇到过最头疼的一次是某款 MCU 的时钟有 1% 的漂移,乖乖补了 200 行对齐代码才搞定。
- 最简单的对齐:用同步信号做触发(比如加密开始引脚)。
- 更鲁棒的方法:对频率做重采样(插值+归一化),或者用互相关找时延。
2. 中间值选择
不是所有中间比特都适合做 DPA。一般选输出阶段的某个 S 盒输出比特,或者密文的某个汉明重量。但注意:如果中间值是由多个比特的异或构成的(比如 DES 的某个函数),那 DPA 的成功率会下降很多。我个人的经验是——尽量选与密钥有直接非线性关系的比特,比如 AES SubBytes 之后的某一位。
3. 统计量选择
最早 Kocher 用的就是简单的差值均值(Difference of Means)。后来大家发现 皮尔逊相关系数 更稳定,尤其在信噪比低的时候。相关系数本质上是把差值做了归一化,峰值更明显。不过它计算量稍大,嵌入式评估时注意一下性能。
表格:常用统计量对比
| 统计量 | 公式(简化) | 优点 | 缺点 |
|---|---|---|---|
| Difference of Means | Avg(Group1) – Avg(Group0) | 直观、计算快 | 受信号幅度差异影响 |
| Pearson Correlation | cov(X,Y)/(σX·σY) | 抗噪声、归一化 | 计算开销略大 |
| t-test | mean_diff / std_error | 带显著性检验 | 需要样本量较大 |
4. 选点范围
不需要对整个时间轴做差分。如果我知道加密的某个子操作发生在第 1000~1500 个采样点,那我只在这 500 个点上做统计就行。一是省时间,二是不需要的点引入的噪声少。嗯,这个在项目里可以节省几个小时的计算。
注意!千万不要在采集阶段混入非加密操作的功耗数据(比如 IO 传输时的功耗)。那会污染你的分组。我早期有一个版本就是因为忘了过滤掉握手脉冲,导致差分一直不收敛。浪费了整整一天。
代码实践(Python 伪代码)
import numpy as np
# trajectories: shape (N, T) N条功耗迹,每条T个采样点
# input_data: shape (N,) 对应的明文或密文
# key_guess: 猜测的密钥字节 0..255
def dpa_attack(trajectories, input_data, key_guess):
# 假设目标:AES SubByte 输出的一位 (bit 0)
# 计算中间比特:Sbox[input_data XOR key_guess] 的 bit0
sbox_output = sbox[input_data ^ key_guess] # sbox 需要预定义
bit = (sbox_output >> 0) & 1 # 选最低位
group0 = trajectories[bit == 0] # 分组
group1 = trajectories[bit == 1]
# 差分 = 平均之差
diff = np.mean(group1, axis=0) - np.mean(group0, axis=0)
return diff # 长度为T的数组,峰值指示正确密钥
# 主循环
for kg in range(256):
diff = dpa_attack(traces, ciphertext, kg)
peak = np.max(np.abs(diff))
print(f"kg={kg:3d}, peak={peak:.6f}")
这段代码套在一个循环里,你就能看到正确密钥的峰值比其他高出一大截。我第一次跑通时,盯着那个尖峰愣了好几秒——嗯,就感觉这方法真的挺神奇的。
一点避坑心得
- 采样率不是越高越好。太高了数据量暴增,而且高频噪声也会进来。一般用 5~10 倍信号带宽就够了。
- 注意触发抖动。前面说了,对齐是第一优先级。你可以先画一条轨迹看看重复性,抖得太厉害就先解决触发问题。
- 多次尝试不同中间比特。有时候 bit0 没反应,换个 bit3 可能就出来了。别死磕。
我自己踩过最大的坑是什么?是采集器触发时忘了关看门狗。芯片跑到一半被复位了,采到的全是垃圾数据。查了两天才发现……从那以后,我每次做 DPA 前都会先发一条空命令看看波形正不正常。
好了,DPA 的原理和实现就这些。下面你可以去动手试试看,拿一条已知密钥的 AES 实现跑一遍上面的流程,感受一下“差分尖峰出现那一刻”的快乐。