土法炼钢兴趣小组的算法知识备份

【密码学百科】侧信道攻击:从时序攻击到功耗分析

文章导航

分类入口
cryptography
标签入口
#side-channel#timing-attack#power-analysis#DPA#SPA#cache-attack#Spectre#constant-time#masking#fault-injection

目录

密码学研究者花费数十年时间构建起数学上可证明安全的加密体系——RSA 的安全性归结于大整数分解的困难性,椭圆曲线密码(ECC)依赖离散对数问题的不可解性,AES 在已知攻击模型下提供充分的安全余量。然而,当这些算法从数学论文走向硅片与代码的那一刻,一个全新的攻击面便悄然打开:算法的物理实现本身会通过执行时间的差异、功耗曲线的波动、电磁辐射的泄露,甚至处理器缓存的命中与否,向攻击者泄露密钥的比特信息。这一类攻击被统称为侧信道攻击(Side-Channel Attack)。

侧信道攻击的核心洞察极为朴素——密码系统并非在真空中运行,它运行在真实的物理设备上,而物理设备的行为不可避免地依赖于它所处理的数据。攻击者不需要破解数学难题,只需要观察计算过程中的”副产品”,就能一比特一比特地还原出密钥。本文将系统地介绍侧信道攻击的各主要类型、经典攻击实例,以及业界多年来积累的防御技术。

侧信道攻击分类:时序、功耗、电磁与缓存攻击

一、侧信道的分类

先看一张图,把这一节的关键关系串起来。

flowchart LR
    A[实现泄露] --> B[时间 功耗 缓存]
    B --> C[统计分析]
    C --> D[密钥恢复]
    D --> E[常量时间与遮蔽]

根据攻击者所观测的物理量不同,侧信道攻击可大致分为以下几类:

时序侧信道(Timing Side Channel)。 攻击者测量密码运算的执行时间。不同的密钥值或不同的明文输入可能导致代码走过不同的分支路径,执行不同数量的循环迭代,或触发不同的缓存行为,从而使得运算时间产生可观测的差异。时序攻击是最早被系统研究的侧信道攻击之一,也是软件实现中最容易引入的漏洞。

功耗侧信道(Power Side Channel)。 CMOS 电路在翻转逻辑状态时消耗的动态功耗与被处理的数据直接相关。攻击者通过高精度示波器采集芯片供电引脚上的电流波形,便能推断出内部寄存器在每个时钟周期的状态变化。功耗分析又可细分为简单功耗分析(Simple Power Analysis,SPA)和差分功耗分析(Differential Power Analysis,DPA)。

电磁侧信道(Electromagnetic Side Channel)。 集成电路中流动的电流会产生电磁辐射,其频谱与功耗信号高度相关,但电磁探测具备更高的空间分辨率——攻击者可以用近场探针(near-field EM probe)定位到芯片上特定功能模块的辐射,从而获得比功耗分析更精细的信息。冷战时期的 TEMPEST 计划便是电磁泄露情报价值的最早实证。

缓存侧信道(Cache Side Channel)。 现代处理器依赖多级缓存来弥合 CPU 与主存之间的速度差距。当密码算法使用依赖密钥的索引去查询查找表(lookup table)时,不同的密钥值会导致不同的缓存行被加载,而攻击者可以通过 Flush+Reload、Prime+Probe 等技术探测这些缓存状态的变化。缓存侧信道在云计算与多租户环境中尤为危险,因为攻击者与受害者可能共享同一物理 CPU 核心。

声学侧信道(Acoustic Side Channel)。 计算设备在运行时产生的声音——电容的啸叫、电源模块的振动——也包含数据相关的信息。研究者已经证明,通过记录笔记本电脑在执行 RSA 解密时发出的高频声音,可以在数秒内提取出完整的 4096 位私钥。

故障注入(Fault Injection)。 严格来说,故障注入是一种主动攻击而非被动观测,但通常也归入侧信道攻击的广义范畴。攻击者通过施加电压毛刺(voltage glitching)、时钟毛刺(clock glitching)或激光照射(laser fault injection)等手段,迫使芯片在计算过程中产生可控的错误,然后利用正确结果与错误结果之间的差异来推导密钥。

以上各类侧信道并非相互排斥,实际攻击中常常综合利用多种信息源。理解每一类侧信道的原理,是构建安全实现的基础。

二、时序攻击

1996 年,Paul Kocher 发表了开创性的论文,系统阐述了时序攻击(Timing Attack)的理论与实践。他展示了一种令人震惊的攻击方式:仅通过精确测量 RSA 私钥解密操作所花费的时间,就能逐比特恢复出完整的私钥。

时序攻击的根源在于密码算法实现中的条件分支依赖于密钥数据。以经典的 RSA 模幂运算”平方—乘”(square-and-multiply)算法为例:算法从高位到低位逐一扫描指数(即私钥)的每一个比特,对于每个比特都执行一次平方操作,而仅当该比特为 1 时才额外执行一次乘法操作。这意味着私钥中 1 的个数直接决定了乘法操作的执行次数,进而影响整体运算时间。如果攻击者能足够精确地测量每次解密的耗时,他就能统计推断出私钥中每个比特的值。

Kocher 的攻击并不要求单次测量就获得完美的信息。即使单次测量中混杂着大量噪声——来自操作系统调度、缓存状态波动、指令流水线的不确定性——攻击者也可以通过大量重复测量并进行统计分析来消除噪声。随着样本数量的增加,信号最终会从噪声中浮现出来,这就是统计侧信道攻击的基本范式。

时序攻击不仅限于 RSA。任何在密钥相关的条件分支上花费不同时间的实现都是易受攻击的目标。一个极为常见的例子是字符串比较函数。标准库中的 memcmpstrcmp 在发现第一个不匹配的字节时立即返回——这意味着比较两个几乎完全匹配的字符串所花的时间,要远长于比较两个首字节就不同的字符串。如果攻击者将这一函数用于验证消息认证码(MAC),他就能逐字节猜测正确的 MAC 值:先固定第一个字节进行枚举,耗时最长的那个值就是正确的第一个字节;然后固定前两个字节继续枚举,依此类推。

以下 C 代码展示了这一问题及其修复:

#include <stddef.h>
#include <stdint.h>

/* 不安全的比较:遇到第一个不匹配字节即提前返回,
   执行时间泄露了匹配前缀的长度 */
int insecure_compare(const uint8_t *a, const uint8_t *b, size_t len)
{
    for (size_t i = 0; i < len; i++) {
        if (a[i] != b[i])
            return 0;           /* 提前返回——时序泄露 */
    }
    return 1;
}

/* 常量时间比较:无论输入内容如何,始终遍历全部字节,
   使用按位或累积差异,避免任何条件分支 */
int constant_time_compare(const uint8_t *a, const uint8_t *b, size_t len)
{
    volatile uint8_t diff = 0;
    for (size_t i = 0; i < len; i++) {
        diff |= a[i] ^ b[i];   /* 累积所有差异位 */
    }
    return diff == 0;           /* 全部字节相同时 diff 仍为 0 */
}

第一个函数 insecure_compare 在发现首个差异字节时立刻返回,其执行时间与匹配前缀的长度呈线性关系,攻击者可以据此逐字节推断出正确的值。第二个函数 constant_time_compare 始终遍历全部 len 个字节,使用按位异或与按位或来累积差异,最终仅在循环结束后做一次相等判断。无论两个输入完全相同、完全不同,还是部分相同,函数的执行路径和执行时间都保持一致。

远程时序攻击同样可行。2003 年 Brumley 和 Boneh 的实验表明,通过互联网测量 SSL 服务器的 RSA 解密时间差异,可以在数百万次请求后提取出服务器的私钥。尽管网络延迟引入了巨大的噪声,但统计方法足以将信号提取出来。这一结果促使 OpenSSL 等主流密码库全面引入了恒定时间的模幂运算实现和 RSA 盲化(blinding)技术。

一个值得深思的现象是:侧信道攻击从根本上挑战了密码学理论大厦的基石假设。可证明安全的整个框架——从 IND-CPA 到 IND-CCA,从 EU-CMA 到模拟安全——都建立在一个隐含前提之上:攻击者只能观察到密码系统的输入(明文、公钥)和输出(密文、签名)。但侧信道攻击表明,现实中的攻击者能观察到的远不止这些——他们可以测量计算过程的耗时、功耗、电磁辐射、甚至声音。这些物理泄露的信息量可能远超密码系统有意暴露的输出。一个在标准模型下被证明为 IND-CCA 安全的方案,在面对能测量解密时间的攻击者时可能毫无抵抗力。这不是证明出了错——而是模型与现实之间存在结构性的鸿沟。正因如此,侧信道防御永远无法被”证明”足够——你防御的是一个你无法完全形式化的敌手。这种不确定性是密码工程区别于密码理论的核心张力之一。

三、缓存侧信道

缓存侧信道(Cache Side Channel)是近二十年来侧信道攻击研究中最活跃的方向之一。与功耗和电磁分析需要物理接触设备不同,缓存侧信道可以纯粹通过软件手段实施,甚至可以跨越虚拟机边界——这使其在云计算环境下具有极高的威胁等级。

现代 CPU 的缓存通常采用组相联(set-associative)结构。主存地址通过特定的映射函数决定它被加载到缓存的哪个组(cache set)。当密码算法使用密钥相关的索引访问查找表时——例如 AES 的 T-table 实现中,每一轮加密都用密钥与明文异或后的值作为索引从预计算表中取值——不同的密钥字节会导致不同的缓存行被加载。攻击者如果能探测到哪些缓存行被访问过,就能反推出密钥信息。

Flush+Reload。 这是最精确的缓存探测技术之一,要求攻击者与受害进程共享物理内存页(例如通过共享库实现)。攻击分为三步:首先,攻击者使用 clflush 指令将目标缓存行从所有缓存层级中驱逐(flush);然后等待受害进程执行密码运算,运算过程中可能会将某些缓存行重新加载;最后,攻击者逐一访问这些缓存行并测量访问延迟(reload)——如果某行命中缓存则说明受害进程曾访问过该地址,否则说明未访问。Flush+Reload 能够以缓存行粒度(通常 64 字节)精确探测受害者的内存访问模式,噪声极低。

Prime+Probe。 当攻击者无法与受害者共享内存时,可以使用 Prime+Probe 技术。攻击者首先用自己的数据填满(prime)目标缓存组的所有路(way),然后等待受害者执行运算——如果受害者访问了映射到同一缓存组的地址,就会将攻击者的某些缓存行驱逐出去。最后攻击者重新访问自己的数据(probe),通过测量哪些缓存行变慢了来推断受害者访问了哪些缓存组。Prime+Probe 的精度低于 Flush+Reload,但适用范围更广,不要求共享内存。

Evict+Time。 这是一种更简单的变体:攻击者在密码运算前驱逐特定缓存组,然后测量整个密码运算的总执行时间。如果运算过程中需要访问被驱逐的缓存行,总时间会因缓存缺失(cache miss)而增加。通过系统性地测试不同缓存组,攻击者可以推断出哪些缓存行在运算中被使用。

AES T-table 攻击是缓存侧信道最经典的应用场景。标准的 AES 优化实现使用四张 1 KB 的查找表(T0 至 T3),每张表包含 256 个 32 位条目。在加密的第一轮中,查表索引为 plaintext[i] ^ key[i],即明文字节与密钥字节的异或。攻击者控制明文并能探测缓存访问模式:如果他观测到 T0 的第 3 条缓存行(覆盖索引 48 至 63)被访问,就知道 plaintext[0] ^ key[0] 的值落在 48 至 63 之间。由于明文已知,密钥字节的可能取值就从 256 种缩减到了 16 种。多次测量、多轮分析后,完整的 128 位密钥便可确定。这一攻击促使现代 AES 实现转向基于位切片(bitslice)的无查找表方案,或使用 AES-NI 等硬件指令,从根本上消除了缓存侧信道。

以下伪代码展示了利用 Flush+Reload 提取 AES 密钥首字节的完整攻击流程:

# Flush+Reload 攻击提取 AES-128 首轮密钥字节 k[0]
# 前提:攻击者与受害者共享 AES T-table 所在的物理页

CACHE_LINE_SIZE = 64           # x86 缓存行大小(字节)
ENTRIES_PER_LINE = 16          # 每条缓存行覆盖 16 个 T-table 条目
THRESHOLD = 120                # 缓存命中/未命中判定阈值(CPU 周期数)

def flush_reload_attack(encrypt_oracle, T0_base_addr):
    """统计不同明文首字节下各缓存行的命中次数"""
    hit_count = [[0] * 16 for _ in range(256)]  # hit_count[p0][line_idx]

    for trial in range(10000):
        for p0 in range(256):
            plaintext = random_plaintext()
            plaintext[0] = p0

            # Flush:将 T0 全部 16 条缓存行逐出
            for line in range(16):
                clflush(T0_base_addr + line * CACHE_LINE_SIZE)

            # 触发受害者加密——T0[plaintext[0] ^ k[0]] 被加载到缓存
            encrypt_oracle(plaintext)

            # Reload:逐行探测,命中说明该行在加密中被访问
            for line in range(16):
                t = timed_access(T0_base_addr + line * CACHE_LINE_SIZE)
                if t < THRESHOLD:
                    hit_count[p0][line] += 1

    # 分析:对每个 p0,命中最集中的缓存行 L 满足
    # p0 ^ k[0] in [L*16, L*16+15],即 k[0] in [p0^(L*16) .. p0^(L*16+15)]
    # 交叉多组 p0 的约束即可唯一确定 k[0]
    return recover_key_byte(hit_count)

攻击的关键在于缓存行粒度带来的信息泄露:每条 64 字节的缓存行覆盖 T-table 的 16 个条目,因此单次探测将 256 种可能的密钥值缩减到 16 种。攻击者只需选取两组不同的明文首字节 \(p_0\)\(p_0'\),使得两次探测命中的缓存行号 \(L\)\(L'\) 不同,就能通过联立约束 \(k_0 \in [p_0 \oplus 16L,\ p_0 \oplus 16L + 15] \cap [p_0' \oplus 16L',\ p_0' \oplus 16L' + 15]\) 唯一确定密钥字节。对全部 16 个密钥字节重复此过程,即可恢复完整的 AES-128 密钥。

四、Spectre 与 Meltdown

2018 年初,Spectre 和 Meltdown 两大漏洞的公开披露震动了整个计算机工业。它们揭示了现代处理器为追求性能而引入的推测执行(speculative execution)机制,可以被利用来绕过几乎所有软件层面的安全边界。

推测执行的原理。 当 CPU 遇到条件分支指令时,它不会停下来等待分支条件的计算结果,而是根据分支预测器(branch predictor)的预测结果提前执行后续指令。如果预测正确,这些指令的结果直接提交,获得性能提升;如果预测错误,CPU 回滚(rollback)这些指令的架构状态——寄存器值恢复、内存写入撤销。然而,回滚并不完美:推测执行期间对缓存的影响不会被清除。这一微架构状态的残留,便是 Spectre 攻击的根基。

Spectre 变体一(bounds check bypass)。 攻击者训练分支预测器使其倾向于预测某个边界检查为”通过”,然后提供一个越界的索引。CPU 在推测执行路径上使用该越界索引读取了不应被访问的内存,并将读取到的值用作第二次内存访问的索引——这第二次访问改变了缓存状态。虽然推测执行最终被回滚,但缓存状态的变化已经发生。攻击者随后通过 Flush+Reload 探测缓存,就能推断出越界读取到的值。通过反复操作,攻击者可以每次泄露一个字节,逐步读取任意内存。

以下 C 代码展示了 Spectre v1 的典型攻击模式——通过推测执行越界读取并利用缓存编码泄露数据:

#include <stdint.h>

/* 受害者数据结构 */
uint8_t array1[16];
uint8_t array2[256 * 512];       /* 探测数组:每 512 字节一个槽位,跨越不同缓存行 */
unsigned int array1_size = 16;
uint8_t secret_data[] = "SECRET KEY MATERIAL";

void victim_function(size_t x)
{
    if (x < array1_size) {
        /* 推测执行窗口内,即使 x >= array1_size,CPU 也可能
           执行此行——越界读取的值通过 array2 的访问模式
           编码到缓存中 */
        volatile uint8_t temp = array2[array1[x] * 512];
    }
}

/* 攻击流程:
   1. 反复以合法索引调用 victim_function,训练分支预测器
   2. 驱逐 array1_size 的缓存行,使边界检查延迟
   3. 以越界索引 x = (secret_data - array1) 调用 victim_function
   4. 推测执行路径读取 secret_data 并将其值编码到 array2 的缓存行
   5. 用 Flush+Reload 逐行探测 array2 的 256 个槽位,
      命中的槽位编号即为泄露的字节值 */

上述攻击之所以成立,本质在于推测执行对缓存的影响在架构状态回滚后仍然残留——这是一种微架构状态泄露。CPU 的设计者当初认为推测执行的”副作用”对正确性无影响(因为架构状态被回滚),却未预见到缓存这一性能结构会被攻击者用作隐蔽信道。

Spectre 变体二(branch target injection)。 Spectre v1 利用条件分支预测器,而 v2 则针对间接跳转的分支目标缓冲区(Branch Target Buffer,BTB)。攻击者在自己的地址空间内训练 BTB,使其将受害者代码中的某个间接跳转预测到攻击者选定的”gadget”地址。当受害者执行该间接跳转时,CPU 在推测路径上跳转到 gadget 并执行攻击者控制的指令序列,在回滚之前已经将秘密数据编码到缓存状态中。v2 的危险在于它不依赖受害者代码中存在显式的越界访问——任何间接跳转都是潜在的攻击面。研究者利用 Spectre v2 从内核或其他虚拟机的地址空间中读取了 RSA 私钥等敏感数据,证明了即使在硬件虚拟化隔离下,同一物理核心上的不同安全域之间的边界也可以被推测执行击穿。

Meltdown。 Meltdown 利用的是另一种微架构缺陷:在某些处理器(主要是早期 Intel 处理器)上,当用户态代码访问内核地址空间时,权限检查与数据读取之间存在竞态——CPU 先将数据加载到内部缓冲区再检查权限。在推测执行窗口内,用户态代码已经能够操纵这些数据并影响缓存状态。Meltdown 使得任意用户进程都能读取内核内存,包括其中可能包含的密码密钥、密码和其他敏感信息。

对密码实现的影响。 Spectre 直接威胁到密码库的安全性。即使密码库本身使用了完美的常量时间代码,如果库代码的分支预测器状态可以被同一进程中的其他代码(例如 JavaScript 引擎中的恶意脚本)操纵,攻击者就可能在推测执行路径上触发密钥相关的内存访问,从而泄露密钥。

防御措施。 针对 Meltdown 的主要防御是内核页表隔离(Kernel Page Table Isolation,KPTI):在用户态运行时,内核地址空间不再映射到进程的页表中,从而彻底阻断了 Meltdown 的攻击路径。针对 Spectre,防御更为复杂,包括在敏感代码的分支处插入序列化指令(如 lfence)以阻断推测执行、使用 retpoline 技术替换间接跳转、以及在编译器层面引入推测安全屏障(speculative load hardening)。新一代处理器也在硬件层面增强了分支预测器的隔离性,但 Spectre 类攻击的变体仍在不断演化,这场攻防博弈远未结束。

五、简单功耗分析与差分功耗分析

功耗分析攻击(Power Analysis Attack)是 Paul Kocher 在 1999 年提出的另一项开创性工作。它利用集成电路在处理不同数据时功耗特征的微小差异来提取密钥,是对嵌入式设备和智能卡安全性影响最为深远的攻击类型之一。

简单功耗分析(Simple Power Analysis,SPA)。 SPA 通过直接观察单条功耗曲线(power trace)来推断密钥信息。以 RSA 的平方—乘算法为例:平方操作和乘法操作在功耗曲线上呈现出明显不同的特征——乘法操作通常比平方操作消耗更多的能量,持续更长的时间。攻击者只需将一条功耗曲线与算法流程对齐,就能通过视觉检查直接读出私钥指数的每个比特:看到”平方”后跟”乘法”,则对应比特为 1;仅看到”平方”,则对应比特为 0。SPA 对实现质量要求不高的设备(如早期智能卡)极为有效。

差分功耗分析(Differential Power Analysis,DPA)。 当 SPA 无法直接从单条曲线中提取足够信息时,DPA 通过统计分析大量功耗曲线来放大微弱的数据依赖信号。DPA 的核心思想是:攻击者猜测密钥的一部分(例如 AES 第一轮子密钥的一个字节),然后根据这个猜测和已知的明文计算出算法内部某个中间值(例如 S 盒输出的某个比特)。接着,他按照这个中间比特的值将所有功耗曲线分为两组,计算两组曲线的平均值之差。如果密钥猜测正确,这个差值信号在中间值被实际处理的时间点上会出现一个明显的尖峰(因为功耗确实与该比特相关);如果猜测错误,差值信号将接近于零的噪声。通过对所有可能的密钥字节值(256 种)逐一测试,正确的猜测会脱颖而出。

相关功耗分析(Correlation Power Analysis,CPA)。 CPA 是 DPA 的改进形式,它不仅关注单个比特的分组,而是将功耗模型(如汉明重量模型或汉明距离模型)与实测功耗曲线做皮尔逊相关系数(Pearson correlation coefficient)分析。相关系数最高的密钥猜测即为正确密钥。CPA 相比经典 DPA 所需的功耗曲线数量更少,收敛速度更快,已成为功耗分析领域的标准方法。

功耗分析的威力在于它几乎不受算法数学强度的影响——无论是 AES、RSA、ECC 还是后量子密码算法,只要其实现在处理不同密钥值时功耗特征不同,就可能被功耗分析攻破。这迫使安全芯片的设计者必须从硬件层面采取对策,而非仅仅依赖算法的数学安全性。

六、电磁泄露分析

电磁泄露分析(Electromagnetic Analysis,EMA)与功耗分析在原理上高度相似,但利用的物理介质不同:它测量的是集成电路在工作时辐射出的电磁信号,而非流过供电引脚的电流。

电磁侧信道的研究可以追溯到冷战时期。美国国家安全局(NSA)的 TEMPEST 计划(约始于 1950 年代)发现,电子设备——从打字机到显示器——在运行时都会辐射出包含所处理信息的电磁信号。在相当长的时间内,TEMPEST 一直是高度机密的情报收集手段。直到 1985 年,Wim van Eck 公开发表论文,演示了从数十米外通过截获 CRT 显示器的电磁辐射来重建屏幕图像的技术,电磁泄露才引起学术界的广泛关注。

在密码分析领域,电磁泄露分析的独特优势在于其空间分辨率。功耗分析测量的是整个芯片的总功耗,是所有电路模块活动的叠加;而电磁分析可以使用微型近场探针(直径可小至数百微米)定位到芯片上特定区域的辐射。这意味着攻击者可以选择性地监听 AES 加密引擎的辐射,而忽略 CPU 核心、内存控制器等无关模块产生的噪声。这种空间选择性使得电磁分析在信噪比上往往优于功耗分析,所需的测量次数也更少。

此外,电磁分析的另一个重要特点是非接触性。功耗分析需要在芯片的供电线路上串接分流电阻或使用电流探头,这要求攻击者对设备有一定程度的物理改造。而电磁探测完全是被动的——攻击者只需将探针靠近目标设备,无需做任何物理连接。对于封装完好的设备(例如信用卡芯片),电磁分析可能是比功耗分析更实际的攻击路径。

现代研究进一步表明,电磁泄露不仅可以从厘米级距离获取,甚至可以从数米乃至更远的距离捕获有用信号。2013 年的一项研究展示了通过电磁辐射从运行 GnuPG 的笔记本电脑中提取 RSA 密钥的可行性,探测距离达到了数米。这些成果不断提醒我们:密码实现的安全性不能仅仅依赖物理隔离或设备外壳的屏蔽,而必须在算法实现层面就消除数据依赖的泄露。

七、故障注入攻击

故障注入攻击(Fault Injection Attack,也称 Fault Attack)是一类主动的侧信道攻击。与前述的被动观测型攻击不同,故障注入要求攻击者有能力在密码运算的特定时刻向目标设备施加外部干扰,迫使其产生计算错误,然后利用正确结果与错误结果之间的数学关系来推导密钥。

电压毛刺(Voltage Glitching)。 攻击者在芯片的供电电压上叠加一个极短时间的脉冲——或是瞬间拉低电压使寄存器无法正确锁存数据,或是瞬间升高电压使逻辑门产生竞争冒险。通过精确控制毛刺的时机和幅度,攻击者可以使计算在特定步骤出错,而其他步骤保持正常。

时钟毛刺(Clock Glitching)。 与电压毛刺类似,但干扰的是时钟信号。攻击者在某个时钟周期上缩短时钟脉冲的宽度,使得组合逻辑电路没有足够的时间稳定在正确状态就被采样,从而引入错误。

激光故障注入(Laser Fault Injection)。 攻击者将聚焦激光束照射在芯片的裸露硅片上(通常需要先去除封装)。激光的光子能量在半导体中产生电子—空穴对,干扰晶体管的正常工作。激光注入的精度极高,可以精确到单个晶体管级别,使攻击者能够选择性地翻转特定寄存器中的特定比特。

故障注入攻击最著名的应用是 Bellcore 攻击(1996 年由 Boneh、DeMillo 和 Lipton 提出),针对使用中国剩余定理(CRT)优化的 RSA 签名实现。RSA-CRT 签名分别计算 \(s_p = m^{d_p} \bmod p\)\(s_q = m^{d_q} \bmod q\),然后通过 CRT 组合得到最终签名 \(s\)。如果攻击者在 \(s_p\) 的计算过程中注入一个故障,使其产生错误值 \(\hat{s}_p\),而 \(s_q\) 的计算仍然正确,那么最终的错误签名 \(\hat{s}\) 满足:\(\hat{s} \equiv m^{d_q} \pmod{q}\)(正确),但 \(\hat{s} \not\equiv m^{d_p} \pmod{p}\)(错误)。此时,\(\gcd(\hat{s}^e - m, N) = p\),攻击者直接获得了 \(N\) 的因子分解,从而完全破解了 RSA 私钥。这一攻击仅需一次错误签名,其效力令人震惊。

对故障注入的防御通常采用冗余计算策略:执行两次相同的运算并比较结果,或在运算后验证签名的正确性。对于 RSA-CRT,一个简单而有效的对策是在输出签名 \(s\) 之前验证 \(s^e \equiv m \pmod{N}\),如果验证失败则拒绝输出。此外,算法层面的感染(infective computation)技术可以确保任何故障都会导致输出完全随机化,使攻击者无法获得有用信息。

八、真实系统中的侧信道案例

侧信道攻击绝非学术象牙塔中的理论游戏——它们在真实系统中已经被反复验证,造成了实际的安全影响。以下三个案例分别代表了缓存时序、声学和频率缩放三种截然不同的侧信道向量,但它们的共同点是:攻击者从密码系统的”正面”(数学算法)无法得逞,于是转向”侧面”(物理实现)。

案例一:OpenSSL AES 缓存时序攻击(Bernstein,2005)

2005 年,Daniel J. Bernstein 发表了一项里程碑式的研究,证明了仅通过网络远程测量 OpenSSL AES 加密的执行时间,就能提取出完整的 AES-128 密钥。当时 OpenSSL 的 AES 实现使用了标准的 T-table 优化——四张预计算查找表 T0 至 T3,每张 1 KB。不同的密钥字节导致查表时访问不同的缓存行,而缓存命中与缺失的时间差异(约 10 至 200 个 CPU 周期)在足够多的采样下是统计可区分的。

Bernstein 的攻击完全在远程进行:攻击者向目标服务器发送大量已知明文并测量每次加密的往返时间。通过将不同明文首字节下的加密时间与参考分布做相关分析,他能够识别出哪些密钥字节值导致了更多的缓存缺失(因而更慢),逐步缩小每个密钥字节的候选范围。完整的 128 位密钥在约 \(2^{25}\) 次加密请求后被恢复——这在网络环境下只需数分钟。

这一攻击的深远影响在于:它证明了缓存时序侧信道不仅仅是本地威胁,而是可以跨越网络的。此后,OpenSSL 逐步将 AES 实现迁移到基于位切片和 AES-NI 硬件指令的方案,从根本上消除了查找表带来的缓存侧信道。

案例二:RSA 密钥的声学提取(Genkin、Shamir、Tromer,2014)

2014 年,Genkin、Shamir 和 Tromer 展示了一种令人惊叹的攻击:通过记录笔记本电脑在执行 GnuPG RSA 解密时发出的高频声音,在一小时内提取出完整的 4096 位 RSA 私钥。声音来源是 CPU 电压调节模块(VRM)中的陶瓷电容和电感在不同计算负载下产生的机械振动——不同的算术操作(如大整数乘法与模约简)在电路上的功耗模式不同,导致电容以不同频率和幅度振动,发出频率在 10 kHz 至 150 kHz 范围内的声音。

攻击者使用一个普通的抛物面麦克风(距离目标约 4 米)或一部放置在目标旁边的手机即可采集到足够质量的声学信号。通过对信号做频谱分析,攻击者能够区分 RSA 解密中”平方”与”乘法”操作的不同声学特征,进而逐比特恢复私钥指数。GnuPG 随后引入了 RSA 盲化和常量时间模幂运算作为对策。

这一案例的意义在于它将侧信道攻击的边界推到了一个极端——攻击者甚至不需要电子设备来探测目标,一个麦克风就够了。它也表明,任何数据依赖的计算行为,无论多么间接,最终都可能通过某种物理介质泄露出去。

案例三:Hertzbleed——频率缩放侧信道(2022)

2022 年,研究者披露了 Hertzbleed 漏洞,揭示了一种全新类别的侧信道:现代 CPU 的动态电压频率缩放(Dynamic Voltage and Frequency Scaling,DVFS)机制本身就是一个信息泄露源。Intel 和 AMD 的处理器都实现了基于功耗的频率调节——当 CPU 执行功耗较高的指令序列时,为了维持在热设计功耗(TDP)范围内,处理器会自动降低时钟频率;反之则提高频率。

Hertzbleed 的核心发现是:不同的数据值导致不同的指令功耗,进而触发不同的频率缩放行为,最终体现为可测量的执行时间差异。这意味着即使密码实现在指令级别是完美的常量时间——相同的指令序列、相同的访问模式——DVFS 仍然可以引入数据依赖的时序变化。研究者利用这一侧信道成功攻击了 SIKE(一种后量子密钥封装方案)的常量时间实现,远程提取出完整密钥。

Hertzbleed 令人不安之处在于:它表明”常量时间”这一概念本身可能不够——在一个时钟频率随数据变化的处理器上,相同数量的时钟周期并不等于相同的墙钟时间。这迫使密码社区重新审视常量时间编程的威胁模型:防御者不仅需要消除指令级别的数据依赖,还需要考虑微架构层面的间接泄露。目前的缓解措施包括禁用 Turbo Boost 等频率缩放特性(以显著的性能代价为代价)或在算法层面确保不同分支的功耗特征一致。

九、防御技术

侧信道攻击的防御是一项系统工程,涉及从算法设计、软件实现到硬件电路的多个层面。以下是业界广泛采用的主要防御技术。

常量时间编程(Constant-Time Programming)。 这是软件实现中最基本也是最重要的防御措施。其核心原则是:程序的控制流和内存访问模式不得依赖于任何秘密数据。具体而言,代码不得在密钥相关的条件上做分支判断,不得使用密钥相关的索引进行表查找,不得在密钥相关的条件下提前返回。所有操作的执行时间必须仅依赖于公开信息(如数据长度),而不依赖于秘密信息的具体值。

掩码/盲化(Masking/Blinding)。 掩码技术通过将秘密值与随机数结合来消除中间值与功耗之间的关联。以布尔掩码为例:将每个秘密值 \(x\) 拆分为 \(x = x' \oplus r\),其中 \(r\) 是每次运算都重新生成的随机掩码。运算过程中始终对 \(x'\)\(r\) 分别处理,仅在最终输出时去除掩码。由于攻击者无法获知 \(r\) 的值,他从功耗曲线中观测到的信息仅与 \(x'\) 相关,而 \(x' = x \oplus r\)\(r\) 均匀随机时是均匀分布的,因此不泄露 \(x\) 的任何信息。在 RSA 实现中,类似的技术称为盲化:在解密前将密文乘以一个随机值的 \(e\) 次方,解密后再除以该随机值,从而使得实际参与运算的值在每次调用时都不同。

乱序执行/洗牌(Shuffling)。 在分组密码的实现中,如果每一轮内的多个 S 盒计算顺序是固定的,攻击者可以利用时间对齐来精确定位每个 S 盒操作对应的功耗峰值。洗牌技术随机化这些操作的执行顺序:例如 AES 的 16 个 S 盒操作在每次加密时以不同的随机顺序执行。这增加了攻击者进行时间对齐的难度,显著提高了功耗分析所需的曲线数量。

冗余计算(Redundant Computation)。 针对故障注入攻击的主要防御手段。通过执行两次相同的运算并比较结果,或者在不同的表示域中执行等价运算并交叉验证,可以检测出故障注入引起的计算错误。检测到故障时,系统应输出随机值或停止运行,而绝不应输出错误的密码结果。

硬件对策。 在芯片层面,去耦电容(decoupling capacitor)和片上稳压器可以平滑功耗波动,降低功耗侧信道的信噪比。双轨逻辑(dual-rail logic)电路设计确保无论处理的数据为 0 还是 1,电路都执行相同数量的逻辑翻转,从而使功耗与数据无关。金属屏蔽层(metal shield)和有源网格(active mesh)则用于检测物理入侵和激光故障注入。这些硬件措施与软件防御互为补充,共同构建起多层次的防护体系。

防御措施的工程代价

安全从来不是免费的。下表列出了主流侧信道缓解措施的典型性能开销,这些数据来自公开的基准测试和厂商文档:

缓解措施              对抗目标               典型性能开销          备注
══════════════════════════════════════════════════════════════════════════════
retpoline             Spectre v2             5%–10%              替换间接跳转为 return trampoline,
                      (间接分支注入)                              阻断 BTB 投毒;内核态开销更高

LFENCE 序列化         Spectre v1             2%–8%               在条件分支后插入 LFENCE 阻断
                      (边界检查绕过)                              推测执行;仅对受影响代码路径

KPTI                  Meltdown               1%–5%               用户态/内核态切换时刷新页表;
                      (内核页面读取)          (syscall 密集型      新处理器已在硬件层面修复
                                              负载可达 30%)

IBRS / STIBP          Spectre v2             ~2%                 限制间接分支预测器的跨域影响;
                      (跨域分支注入)                              微码级别的缓解

AES-NI 替代 T-table   缓存时序攻击           性能提升 3x–10x     硬件指令消除查表依赖,同时
                                                                 反而比软件实现更快

掩码/盲化             DPA / CPA              2x–3x               每个秘密值需要额外的随机化
                      (功耗/电磁分析)                             运算和掩码刷新

位切片 AES            缓存时序攻击           1.5x–2x             将 S 盒查表转为位运算,
                                                                 吞吐量下降但消除缓存依赖

禁用 Turbo Boost      Hertzbleed             10%–40%             牺牲动态频率提升以消除
                      (DVFS 侧信道)          (取决于工作负载)     频率缩放泄露
══════════════════════════════════════════════════════════════════════════════

这张表揭示了侧信道防御的一个核心张力:每一层缓解措施都在安全性与性能之间做取舍。对于高吞吐量的服务器(如 TLS 终结节点),retpoline + KPTI 的叠加开销可能达到 10%–15%,这在大规模部署中意味着数以百万计的额外硬件成本。对于嵌入式安全芯片,掩码带来的 2x–3x 面积和功耗增长可能直接影响产品的可行性。工程师必须根据具体的威胁模型——谁是攻击者、攻击者有什么能力、保护的资产价值有多高——来决定在哪些层面投入防御资源。

从源码到芯片:防御分层全景

将上述防御技术按系统抽象层次组织,可以更清晰地看到侧信道防御的全栈结构:

防御层次          防御措施                         对抗的侧信道类型
══════════════════════════════════════════════════════════════════════════

源代码层          ┌─ 常量时间编程                  → 时序侧信道
(Source Code)     │  (无秘密相关分支/表查找)
                  ├─ 秘密值不做数组索引             → 缓存侧信道
                  ├─ 掩码/盲化                      → 功耗分析、电磁分析
                  └─ 形式化验证工具                  → 全部(预防性检测)
                       (ct-verif, FaCT, dudect)
──────────────────────────────────────────────────────────────────────────

编译器层          ┌─ volatile / 编译器屏障          → 时序侧信道
(Compiler)        │  (阻止优化器消除常量时间逻辑)     (编译器引入的)
                  ├─ -O0 或选择性禁用优化            → 同上
                  └─ 领域特定语言(FaCT → C)        → 全部(编译时保证)
──────────────────────────────────────────────────────────────────────────

操作系统层        ┌─ ASLR                           → 缓存侧信道(降低地址预测性)
(OS)              ├─ mlock(锁定内存页)             → 页表侧信道(防止换页泄露)
                  ├─ 进程隔离 / seccomp              → 跨进程缓存攻击
                  └─ 内核页表隔离(KPTI)            → Meltdown
──────────────────────────────────────────────────────────────────────────

微架构层          ┌─ 缓存分区(CAT / L1D flush)    → Flush+Reload, Prime+Probe
(Microarch.)      ├─ 分支预测屏障(IBRS, STIBP)    → Spectre v1/v2
                  ├─ Spectre 缓解(retpoline 等)    → Spectre 变体
                  └─ 洗牌/乱序执行(S-box 随机化)   → 功耗分析(时间对齐干扰)
──────────────────────────────────────────────────────────────────────────

硬件/物理层       ┌─ 去耦电容 / 片上稳压器          → 简单功耗分析(SPA)
(Hardware)        ├─ 双轨逻辑(dual-rail logic)     → 差分功耗分析(DPA)
                  ├─ 金属屏蔽层 / 有源网格           → 电磁泄露、物理探测
                  ├─ 电磁滤波 / 信号抑制             → 电磁分析
                  ├─ 冗余计算 + 故障检测             → 故障注入攻击
                  └─ 安全元件(SE / TPM)            → 全部(物理隔离执行)
══════════════════════════════════════════════════════════════════════════

在工程实践中,我见过太多团队在某一层投入巨大精力,却对其他层视而不见。有人精心编写了常量时间的 C 代码,编译器却在 -O2 下悄悄把它”优化”成了带分支的版本;有人在芯片上部署了双轨逻辑,操作系统却没有启用 KPTI,让 Meltdown 从内核空间直接读走密钥。侧信道防御本质上是一个全栈问题——它要求从写下第一行源代码的程序员到设计最后一层金属屏蔽的硬件工程师,所有人都对同一个威胁模型保持一致的认知。修好一层而忽视其他层,不是”部分安全”,而是”虚假安全”——它给人以已经防护的错觉,实际上只是把攻击者的入口从正门移到了侧门。

一个从工程实践中反复被验证的不适真相是:常量时间编程本质上是在与 CPU 架构师的全部优化哲学做对抗。现代处理器微架构的每一项性能优化——分支预测、缓存预取、推测执行、超标量乱序发射——其核心思想都是”根据数据的特征动态调整执行路径以提高吞吐量”。而常量时间编程要求的恰恰相反:“无论数据是什么,执行路径必须完全相同”。这两个目标在根本上是对立的。更令人沮丧的是,“在硬件层面修复侧信道”的思路反复被证明是一场打不赢的军备竞赛。Spectre(2018)暴露了推测执行的问题,硬件厂商发布了微码补丁;MDS(2019)暴露了微架构缓冲区的问题,新一轮补丁随之而来;LVI(2020)颠倒了攻击方向,又需要新的缓解措施。每一代 CPU 为了性能引入的新微架构特性,几乎必然会创造新的侧信道——因为性能优化与侧信道泄露在物理层面共享同一个因果机制:处理器的行为依赖于它正在处理的数据。在一个根本矛盾尚未解决的领域,每一个新特性都是一个潜在的新漏洞。

十、常量时间编程与形式化验证

常量时间编程(constant-time programming)是抵御时序侧信道和缓存侧信道的第一道防线。它的原则听起来简单——不要让程序的执行路径依赖于秘密数据——但在实践中却充满了陷阱。编译器优化、处理器微架构特性、甚至编程语言的运行时行为,都可能在开发者不知情的情况下破坏常量时间属性。

“常量时间”的形式化定义

在形式化安全分析中,“常量时间”并非指程序的执行时间是一个常数,而是指程序的可观测行为(控制流轨迹和内存访问模式)不依赖于秘密输入。更精确地说,给定一个程序 \(P\) 和两组输入 \((pub, sec_1)\)\((pub, sec_2)\)——其中 \(pub\) 是公开输入(如明文长度),\(sec_1\)\(sec_2\) 是两个不同的秘密输入(如不同的密钥)——如果 \(P\) 在这两组输入上产生的执行轨迹(instruction trace)完全相同,则称 \(P\) 是常量时间的。所谓执行轨迹包括:每条被执行的指令的地址、每次内存访问的地址、以及每次分支跳转的方向。这一定义排除了三类数据依赖行为:秘密相关的条件分支(secret-dependent branches)、秘密相关的内存访问(secret-dependent memory accesses)、以及秘密相关的变长指令(如某些架构上的变长除法)。

避免基于秘密的条件分支。 这是最基本的规则。以下代码看似无害,实则泄露了秘密比特 bit 的值:

/* 危险:基于秘密比特的分支 */
if (bit)
    result = value_a;
else
    result = value_b;

编译器可能将其编译为条件跳转指令,而条件跳转的执行时间取决于分支预测器的状态——如果 bit 的值可预测,分支预测命中时执行更快,不命中时更慢。正确的做法是使用按位运算实现无分支的条件选择:

#include <stdint.h>

/* 常量时间条件选择:当 condition 为 1 时返回 a,
   当 condition 为 0 时返回 b,不产生任何条件分支 */
uint32_t constant_time_select(uint32_t condition, uint32_t a, uint32_t b)
{
    /* 将 condition(0 或 1)扩展为全 0 或全 1 的掩码 */
    uint32_t mask = -(uint32_t)condition;  /* 0 -> 0x00000000, 1 -> 0xFFFFFFFF */
    return (a & mask) | (b & ~mask);
}

这段代码的关键在于将布尔条件值(0 或 1)转化为全零或全一的掩码。当 condition 为 1 时,mask0xFFFFFFFFa & mask 保留 a 的所有位而 b & ~mask 为零,结果就是 a;当 condition 为 0 时,mask0x00000000a & mask 为零而 b & ~mask 保留 b 的所有位,结果就是 b。整个过程仅使用按位运算,没有条件跳转,执行时间与 condition 的值无关。

避免基于秘密的表查找。 用秘密值作为数组索引会将秘密值映射到特定的缓存行,从而暴露给缓存侧信道攻击。如果必须进行查表操作(例如 S 盒替换),应当遍历整张表并使用上述的常量时间选择操作来提取目标值,或者使用位切片(bitslice)技术将查表操作转化为纯位运算。

编译器屏障(Compiler Barrier)。 现代编译器的优化能力远超开发者的预期。编译器可能会将精心编写的常量时间代码”优化”回带有分支的形式——例如识别出位运算模式等价于条件赋值,然后用条件移动指令甚至条件跳转来替换。为了阻止这种优化,开发者需要使用编译器屏障来告诉编译器”不要对这个值做任何假设”。在 C 语言中,常见的做法是通过内联汇编或 volatile 修饰符来实现。例如 OpenSSL 使用 value_barrier 函数,将秘密值传入一个空的内联汇编块,迫使编译器将该值视为不透明的,从而阻止基于值范围的优化。

形式化验证工具。 鉴于手动审查常量时间属性极易出错,研究者开发了多种自动化验证工具,覆盖从静态分析到动态测试的完整频谱:

在高安全性的密码库(如 HACL* 和 libsodium)中,常量时间属性已经通过形式化方法得到了机器可检查的证明。HACL* 更进一步,使用 F* 定理证明器同时验证了功能正确性(加密结果正确)和侧信道安全性(常量时间属性),为密码实现提供了迄今为止最强的安全保证。

常量时间编程是一种纪律,而非简单的编码技巧。它要求开发者对底层硬件有深入的理解,对编译器行为保持警惕,并且愿意牺牲一定的性能来换取安全性。在密码学实现中,这种纪律不是可选的——它是基本的安全需求。


侧信道攻击深刻地改变了我们对密码系统安全性的认识。它告诉我们,一个密码系统的安全性不仅取决于其数学基础的坚固程度,还取决于每一行实现代码、每一个硬件门电路的细节。从 Kocher 1996 年的时序攻击到 2018 年的 Spectre,从智能卡上的功耗分析到云环境中的缓存侧信道,攻击者不断发现新的信息泄露途径,而防御者则必须在算法、代码、编译器和硬件的每一个层面都保持警觉。密码学的故事永远不只是关于数学的优雅——它同样关于工程的严谨与物理世界的真实。


密码学百科系列 · 第 28 篇

← 上一篇:密钥管理工程 | 系列目录 | 下一篇:密码学实现陷阱

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-03-20 · cryptography / security

密码学工程中最容易犯的 7 个错误

密码学最危险的不是算法被破解,而是正确的算法被错误地使用。本文梳理 7 个真实 CVE 中的密码学工程错误,附代码与修复方案。


By .