密码学的安全性通常建立在数学难题之上:大整数分解、离散对数、椭圆曲线上的点乘……这些难题在理论上难以破解,但密码算法的实现却可能因为一个微小的物理泄漏而彻底崩溃。侧信道攻击(Side-Channel Attack, SCA)正是利用算法在真实硬件上运行时的物理特征——执行时间、功耗、电磁辐射、缓存行为、甚至分支预测状态——来恢复密钥。2018 年 Spectre 与 Meltdown 的公开,更是把"侧信道"从密码学界推向了整个计算机工业界。
本文将从攻击者与防御者两个视角,系统梳理侧信道攻击的技术谱系,并给出可落地的恒定时间(Constant-Time)编程实践。
一、什么是侧信道
1.1 三个信息泄露通道
传统攻击针对算法的数学结构,侧信道攻击则针对算法的物理实现。可以把泄露通道分为三类:
- 时间(Timing):不同输入导致不同的执行路径长度,攻击者通过测量耗时推断密钥。
- 功耗与电磁(Power / EM):CMOS 电路在翻转时消耗能量,功耗曲线与处理的数据相关,电磁辐射同样携带信息。
- 微架构状态(Microarchitectural):CPU 缓存、分支预测器、TLB、乱序执行等共享资源上的状态差异,可被跨进程、甚至跨安全域观测。
这些通道的共同特点是:它们不改变算法的数学输入输出,只反映计算过程的物理副产物。因此,即便算法在数学上无懈可击,实现仍可能一泄到底。
1.2 为什么侧信道难以防御
侧信道的根源在于一个矛盾:性能优化与常量行为天然对立。现代处理器为了快,引入了分支预测、乱序执行、缓存、投机执行;而密码学为了安全,要求执行行为与密钥无关。几乎所有性能优化都会引入数据依赖的行为差异,从而打开侧信道。
此外,侧信道攻击具有非侵入性:攻击者往往只需要测量时间或读取共享的缓存状态,不需要物理接触设备,也不需要破解算法本身。
二、计时攻击
2.1 经典案例
1996 年,Paul Kocher 提出计时攻击(Timing Attack),指出 RSA 的模幂运算中,平方-乘算法的处理时间依赖于指数(私钥)的比特位。在朴素实现中,遇到比特 1 时额外执行一次乘法,攻击者通过统计大量解密耗时即可恢复私钥。
更贴近日常的例子是字符串比较。考虑如下"校验 token"的写法:
bool check_token(const char *input, const char *secret) {
if (strlen(input) != strlen(secret)) return false;
for (int i = 0; i < strlen(secret); i++) {
if (input[i] != secret[i]) return false; // 提前返回
}
return true;
}
这段代码会在第一个不匹配字符处提前返回。攻击者通过测量响应时间,可以逐字符猜出 secret——每猜对一个字符,响应就慢一点点。这就是所谓的"早期退出泄露"。
2.2 修复:恒定时间比较
恒定时间比较的核心是:无论输入如何,都执行完全相同的操作序列,用位运算累积差异,最后一次性返回。
int ct_compare(const unsigned char *a, const unsigned char *b, size_t len) {
unsigned char diff = 0;
for (size_t i = 0; i < len; i++) {
diff |= (unsigned char)(a[i] ^ b[i]);
}
// 将 diff 归一化为 0(相等)或 1(不等)
return (int)((((unsigned int)(-(int)diff)) >> 31) & 1);
}
注意三个要点:
- 不提前返回:循环次数固定,与数据无关。
- 用位运算而非分支:避免 `if` 造成分支预测差异。
- 归一化也恒定时间:用算术右移把任意非零值压成 1,而不是用 `diff != 0` 的布尔表达式(某些编译器仍可能生成分支)。
2.3 更隐蔽的计时泄露
除了显式分支,还有很多隐蔽来源:
- 数组索引依赖密钥:查表(如 AES 的 S-Box)时,缓存命中与否影响耗时。
- 除法与取模:某些架构上耗时不固定。
- 短路求值:`&&`、`||` 会引入分支。
- 编译器优化:编译器可能把"恒定时间"代码优化回带分支的版本,必须用内联汇编或volatile约束固定。
三、缓存攻击
3.1 缓存为什么成为信道
CPU 缓存是共享资源。一个进程可以通过测量某段内存地址的访问延迟,判断另一个进程(或同一个进程的另一部分)是否最近访问过它——这就是缓存计时(Cache Timing)的基本原理:命中约几十纳秒,未命中则需从内存读取,慢一个数量级。
在云环境中,多个租户共享物理 CPU 的 L3 缓存,缓存攻击成为跨虚拟机攻击的重要手段。
3.2 Flush+Reload
Flush+Reload 是最精准的缓存攻击之一,依赖共享内存(如共享库):
- Flush:用 `clflush` 指令把目标缓存行从所有缓存层级清除。
- 等待:让受害者执行,如果它访问了该地址,数据会重新加载进缓存。
- Reload:攻击者访问同一地址并计时——命中说明受害者访问过,未命中说明没有。
通过监控密码库中特定函数(如 AES 的查表、RSA 的分支)对应的代码页,攻击者可以恢复密钥。Paul Kocher 等人 2019 年的研究显示,Flush+Reload 可以在数秒内从 OpenSSL 的 AES 实现中恢复完整密钥。
3.3 Prime+Probe
Prime+Probe 不依赖共享内存,适用于跨 VM 场景:
- Prime:攻击者用自己的数据填满某组缓存集(cache set)。
- 等待:让受害者执行。
- Probe:攻击者重新访问自己的数据并计时——如果变慢,说明受害者驱逐了部分数据,即访问了同一缓存集。
Prime+Probe 噪声更大,但不需要共享内存,因此在云安全研究中更受关注。
3.4 缓存攻击的实际影响
- AES 查表实现:T-Table 实现通过密钥异或后的索引查表,缓存行访问模式直接泄露密钥字节信息。
- RSA/ECC:条件分支与内存访问依赖密钥位。
- 密码学库:OpenSSL、Libsodium、BoringSSL 都曾修复过缓存相关的侧信道问题。
四、微架构攻击:Spectre 与 Meltdown
4.1 Meltdown:打破内存隔离
2018 年公开的 Meltdown(CVE-2017-5754)利用乱序执行与异常处理的竞态:CPU 在异常触发前会投机执行后续指令,把内核内存读入缓存,随后通过缓存计时把数据带出。攻击者无需任何权限即可读取内核地址空间,打破用户态与内核态的隔离。
核心思想是:异常会被撤销,但缓存不会被撤销。
4.2 Spectre:欺骗分支预测
Spectre(CVE-2017-5753 / CVE-2017-5715)更普遍,也难修。它通过训练分支预测器,诱导受害者投机执行"越界"访问,再通过缓存侧信道读取越界数据。Spectre 有两种变体:
- V1(边界检查绕过):欺骗条件分支预测。
- V2(分支目标注入):污染间接跳转目标。
Spectre 的可怕之处在于它不依赖任何软件 bug,只依赖处理器的投机执行机制。这也是为什么硬件厂商、操作系统、编译器需要多层协同防御。
4.3 对密码学的影响
Spectre 类攻击让"进程内隔离"也失去意义。例如,浏览器中的 JavaScript 可以通过投机执行读取同进程的其他内存——这也是后来所有浏览器都降低了 `performance.now()` 精度、启用站点隔离的原因。
五、功耗与电磁分析
5.1 简单功耗分析(SPA)
直接观察功耗曲线的形状。在 RSA 的平方-乘实现中,乘法操作对应一个明显的功耗尖峰,攻击者从曲线上直接读出密钥比特序列——1 对应"平方+乘",0 对应"仅平方"。这是最直觉的侧信道攻击。
5.2 差分功耗分析(DPA)
SPA 需要实现有明显视觉差异,DPA 则通过统计处理大量功耗轨迹来恢复密钥。核心思路是:对某个密钥比特的假设,把功耗曲线分成"该比特为 0"和"该比特为 1"两组,计算两组均值的差值。正确的密钥假设会产生显著峰值。
DPA 的优势是抗噪声能力强,即使单条曲线淹没在噪声中,平均几千条后仍能提取出信号。这也是智能卡、TPM 等安全芯片必须做 DPA 防护的原因。
5.3 电磁分析(EMA)
芯片内部的电流变化会产生电磁辐射,用近场探头即可捕获。EMA 的空间分辨率更高,可以定位到芯片上特定区域(如 AES 协处理器),在某些场景下比功耗分析更精准。
5.4 防御:掩码与随机化
- 掩码(Masking):把敏感中间值拆成多个随机份额,使单次功耗与密钥不相关,这是工业界对抗 DPA 的标准方案。
- 随机延迟:插入随机空操作,打乱时间对齐,但只是增加攻击成本。
- 平衡逻辑:WDDL 等逻辑风格让每个时钟周期功耗恒定。
- 电流平滑:在芯片级加入去耦电容与调节电路。
六、恒定时间编程实践
6.1 核心原则
恒定时间编程的目标:代码的执行时间、控制流、内存访问模式与密钥无关。具体原则:
- 禁止数据依赖分支:任何 `if`、`switch`、循环条件都不能依赖密钥。
- 禁止数据依赖内存索引:不能用密钥字节做数组下标(这是查表泄露的根源)。
- 固定循环次数:所有循环边界必须是常量。
- 避免短路运算符:用位运算替代 `&&`、`||` 于敏感路径。
- 注意编译器:编译器可能"优化"掉恒定时间构造,需用内联汇编或屏障固定关键序列。
6.2 条件选择的恒定时间实现
需要"根据条件二选一"时,用掩码选择而非分支:
// 若 mask 全 1 选 a,全 0 选 b
static inline uint32_t ct_select(uint32_t a, uint32_t b, uint32_t mask) {
return (a & mask) | (b & ~mask);
}
同理,比较两个整数并得到 0/1 掩码,可用减法与算术右移实现:
uint32_t ct_is_zero(uint32_t x) {
return (uint32_t)(((uint32_t)(-(int32_t)x) | (uint32_t)(-(int32_t)(~x))) >> 31) ^ 1u;
}
这类构造在 OpenSSL、Libsodium 中大量使用,也有专门的库(如 `constant_time` 模块)封装。
6.3 查表类算法的困境
AES 的 S-Box 本质是查表,而查表天然是缓存侧信道。常见对策:
- 位切片(Bitslicing):用位运算模拟查表,完全避免内存访问。性能较好但实现复杂。
- 硬件指令:AES-NI 由专用电路实现,天然抗缓存侧信道(但需验证其恒定时间性)。
- 预加载(Prefetch):把整个表提前读入缓存,使访问全部命中,消除命中/未命中差异——但这在跨 VM 场景下未必可靠。
- 小表拆分:把大表拆成多个小表,配合掩码使用。
6.4 语言层面的支持
- Rust:`subtle` crate 提供 `ConstantTimeEq`、`ConditionallySelectable` 等类型,把恒定时间语义编码进类型系统。
- C/C++:OpenSSL 的 `CRYPTO_memcmp`、BoringSSL 的 `constant_time.h`;也可用 `volatile` 阻止编译器优化敏感序列。
- Go:`crypto/subtle` 包提供 `ConstantTimeCompare`、`ConstantTimeSelect`。
- Java:避免用 `Arrays.equals` 比较密钥,改用 `MessageDigest.isEqual`(已实现恒定时间)。
七、工程落地的检查清单
- 敏感比较全部走恒定时间函数:token、HMAC、密码、密钥派生结果。
- 审查所有数据依赖分支:用静态分析工具(如 `ctgrind`、`dudect`)自动检测时间泄露。
- 验证编译器输出:检查关键函数的汇编,确认没有引入分支。
- 基准测试:用 `dudect`、`mona` 等工具在真实硬件上做统计检验。
- 依赖成熟库:不要自己实现密码学原语,优先使用 Libsodium、BoringSSL、RustCrypto。
- 关注运行时特性:禁用会破坏恒定时间的 JIT 投机、注意 `Math.random()` 等伪随机源的定时差异。
- 云环境特别防护:多租户场景下考虑缓存隔离、核心绑定、禁用超线程。
八、结语
侧信道攻击揭示了一个深刻的事实:密码学的安全性不止取决于算法,还取决于物理世界。一个数学上完美的 RSA 实现,可能因为一次提前返回、一次查表、一次分支预测,就把私钥拱手让人。
防御侧信道的过程,本质上是与处理器的性能优化机制对抗。恒定时间编程不是几行代码的技巧,而是一种"默认怀疑物理世界"的工程思维:假定任何数据依赖的行为都可能被观测,然后用位运算、掩码、固定控制流把密钥与物理世界隔离开。
随着 Spectre 系列漏洞的持续披露、云计算多租户隔离需求的增长,侧信道安全已成为从芯片设计到应用开发全栈的核心议题。对开发者而言,理解这些原理,比记住任何具体 API 都更重要——因为新的侧信道,总在下一个微架构优化中悄然出现。
正如密码学家常说的那句话:"密码学是容易的,难的是把它做对。"(Cryptography is easy to do wrong.) 侧信道,正是"做对"二字最苛刻的注脚。

发表评论 取消回复