
1. 从一条缓存命中说起这个实验到底在做什么第一次接触这个实验的人大多会被标题里的词吓一跳。其实把外壳剥掉它要讲的事情非常朴素现代处理器为了跑得快会在你还没明确要求读某个内存地址之前就猜你可能要读然后提前把数据搬进缓存。这个猜的行为本身没问题问题在于——猜错了之后处理器虽然会把结果丢掉但缓存里的痕迹不会立刻消失。于是攻击者可以通过测量哪个地址读得快反推出刚才处理器偷偷碰过哪个地址进而读到本不该读的数据。这个实验就是围绕这条链路设计的先制造一个越权访问的瞬间再通过缓存侧信道把那个瞬间泄露的信息捞出来。它属于体系结构安全里最经典的一类动手实验适合已经学过操作系统、计算机组成原理想真正搞明白CPU 到底怎么泄露秘密的同学。你不需要什么特殊设备一台普通的 x86 机器、一个 Linux 环境、一段 C 代码就够了。我把它拆成三层来理解第一层是原理为什么乱序执行会留下可观测的副作用第二层是手法怎么用 FlushReload 把缓存状态变成可读信号第三层是工程怎么把噪声压下去、把成功率提上来。很多人卡在第二层和第三层之间原理看懂了代码跑出来全是噪声这篇就把这几层的细节都摊开讲。注意本文讨论的所有内容仅用于理解处理器微架构的公开教学原理请在自己的实验环境中进行不要用于任何未授权的系统。2. 原理拆解乱序执行为什么会说漏嘴2.1 现代处理器的先斩后奏机制要理解这个实验得先接受一个反直觉的事实CPU 执行指令的顺序和你写的代码顺序往往不是一回事。编译器会重排处理器自己也会重排。这种能力叫乱序执行Out-of-Order Execution它的目的是填满流水线不让昂贵的执行单元闲着。打个比方你去餐厅点餐厨师不会等你把整张菜单念完才开火。他听到一份炒饭就先把锅热上等你念到再加个汤时炒饭已经在锅里了。如果最后你说算了炒饭不要了厨师把炒饭倒掉就行但灶台已经热过、锅已经动过——这个动过的痕迹就是这个实验要利用的东西。处理器也一样。当它遇到一条需要等待的指令比如要读的内存不在缓存里得等几百个周期它不会干等而是往后看几条指令挑那些不依赖当前结果的先执行。这些抢跑的指令如果最后被证明不该执行处理器会回滚它们的架构状态寄存器、内存但微架构状态缓存、分支预测器往往不会完全回滚。2.2 权限检查与数据搬运的时间差关键点来了。当一条指令试图读取一个它没有权限访问的地址时处理器会做权限检查。但这个检查发生在流水线的某个阶段而数据从内存搬到缓存的动作可能已经在这个检查完成之前就启动了。换句话说权限检查说不行的时候数据可能已经在缓存里躺着了。处理器随后会抛出一个异常程序看起来什么都没读到但缓存里那个不该存在的副本成了攻击者的信标。这里有个容易被忽略的细节并不是所有处理器、所有版本都受这个时间差影响。不同微架构对权限检查的位置、对推测执行的限制都不一样。所以做这个实验时你会发现同一段代码在不同机器上表现差异很大这不是你写错了而是硬件行为本身就有差异。2.3 缓存为什么能当测谎仪缓存的核心特性是快。从 L1 缓存读数据大概几个周期从主存读要一两百个周期。这个数量级的差距用高精度计时器一测就出来了。于是攻击逻辑就变成这样准备一块大缓冲区先把整块缓冲区从缓存里踢出去flush然后触发一次越权访问让处理器猜着去读某个秘密值并用这个秘密值当索引去访问缓冲区里的某个位置。如果秘密值是 42那么缓冲区偏移 42 的那块数据就被带进了缓存。最后攻击者遍历整个缓冲区测量每一块的读取时间哪一块特别快哪一块就是被访问过的秘密值就是它的索引。整个过程里攻击者从来没有合法地读到那个秘密值他读到的只是哪块内存变快了。这就是侧信道攻击的精髓不直接偷内容而是偷内容的影子。3. 实验环境搭建把地基打牢3.1 硬件与系统的选择这个实验对硬件有实打实的要求不是随便一台虚拟机就能跑出漂亮结果的。项目推荐配置说明CPU 架构x86_64该实验的经典版本针对 x86 设计ARM 上行为差异大物理机 vs 虚拟机优先物理机虚拟机的缓存行为被宿主干扰噪声极大操作系统主流 Linux 发行版需要能加载内核模块、能读/proc编译器gcc 或 clang需要能控制优化等级权限能执行特权操作部分步骤需要加载模块或调整配置我踩过的第一个坑就是在云主机上跑结果全是噪声。原因是云主机是多租户共享物理核你的缓存随时被邻居刷掉。所以能上物理机就上物理机实在不行也要找独占核的机器。3.2 关闭干扰源在开始之前有几件事必须先做否则你测出来的时间全是抖动的关闭地址空间随机化echo 0 /proc/sys/kernel/randomize_va_space。地址每次变你的缓冲区偏移就对不上实验没法复现。固定 CPU 频率把调频策略设成 performance避免 CPU 在测量中途降频导致时间基准漂移。绑定 CPU 核心用taskset把进程绑到某个核上减少迁移带来的缓存失效。关闭超线程干扰如果条件允许尽量让测量线程独占物理核别和别的线程共享。这些操作看起来琐碎但每一条都直接影响信噪比。我见过太多人代码写得没问题就是因为没关随机化跑十次错十次。3.3 计时工具的选择测量缓存命中与否靠的是高精度计时。常用的有几种rdtsc指令读时间戳计数器开销极低适合细粒度测量但要注意它可能被乱序执行影响需要配合lfence做序列化。clock_gettime精度到纳秒开销比 rdtsc 大但更稳定。性能计数器通过perf读取缓存命中/未命中事件更直接但需要权限且粒度较粗。在这个实验里主流做法是用rdtsc。原因是它足够快测量本身的开销不会淹没信号。但用rdtsc有个经典陷阱它自己可能被乱序执行提前读导致测出来的时间不准。解决办法是在rdtsc前后加内存屏障强制序列化。static inline uint64_t rdtsc_begin() { uint64_t a, d; asm volatile(mfence\n\t rdtsc\n\t mov %%rax, %0\n\t mov %%rdx, %1\n\t : r(a), r(d) : : %rax, %rdx, memory); return (d 32) | a; } static inline uint64_t rdtsc_end() { uint64_t a, d; asm volatile(rdtscp\n\t mov %%rax, %0\n\t mov %%rdx, %1\n\t mfence\n\t : r(a), r(d) : : %rax, %rdx, memory); return (d 32) | a; }mfence和rdtscp的作用就是保证计时指令本身不被重排否则你测的就不是你想测的那段代码。4. 核心手法实现FlushReload 的完整链路4.1 为什么选 FlushReload缓存侧信道有好几种玩法比如 PrimeProbe、EvictTime、FlushReload。这个实验用的是FlushReload原因是它最简单、最直接、对共享内存的依赖最小。它的逻辑只有三步Flush用clflush指令把目标地址从缓存里踢出去。触发执行那段会越权访问的代码让处理器推测性地把秘密值对应的缓存行拉进来。Reload重新读一遍目标地址测时间。快的就是被拉进来的慢的就是没被碰过的。对比 PrimeProbeFlushReload 不需要去争抢缓存组也不需要复杂的驱逐策略代码量小得多。代价是它要求攻击者能对目标地址执行clflush在真实攻击场景里这个前提不一定成立但在教学实验里完全够用。4.2 缓冲区设计与索引映射缓冲区的大小和索引映射方式直接决定实验能不能成功。核心约束是秘密值的每一个可能取值都要对应缓冲区里一个独立的、不共享缓存行的位置。假设秘密值是一个字节取值范围 0 到 255。那么缓冲区至少要能容纳 256 个槽位每个槽位占一个缓存行x86 上通常是 64 字节。所以缓冲区大小至少是 256 × 64 16384 字节。#define CACHE_LINE_SIZE 64 #define SLOT_COUNT 256 #define BUFFER_SIZE (SLOT_COUNT * CACHE_LINE_SIZE) char probe_buffer[BUFFER_SIZE]; // 访问秘密值对应的槽位 void access_slot(uint8_t secret) { volatile char *addr probe_buffer secret * CACHE_LINE_SIZE; (void)*addr; }这里有个细节为什么要乘CACHE_LINE_SIZE而不是直接probe_buffer[secret]因为如果两个不同的秘密值落在同一个缓存行里它们就无法区分了。乘上缓存行大小保证每个秘密值独占一行测量时才能干净地分辨。4.3 触发越权访问的代码结构实验里通常用一个受害者函数来模拟越权访问。它的结构大致是这样char *secret_data; // 攻击者不该读的数据 char *probe_buffer; // 攻击者的探测缓冲区 void victim_function(size_t index) { if (index secret_size) { // 边界检查 uint8_t value secret_data[index]; // 越权读取 access_slot(value); // 用秘密值索引缓冲区 } }正常情况下如果index越界if判断为假后面的代码不会执行。但处理器在推测执行时可能已经猜if为真提前把secret_data[index]读了出来并用它去访问了probe_buffer。等边界检查的结果真正出来发现猜错了架构状态回滚但probe_buffer里那一行已经进了缓存。这就是整个实验的心脏。你要做的就是反复触发这个函数然后测量probe_buffer哪一行变快了。4.4 测量与统计把噪声压下去单次测量几乎必然被噪声淹没。缓存状态、中断、调度、频率波动任何一个都能让你的信号消失。所以必须做多次采样 统计。常见做法是对每个候选索引重复测量几百到几千次。记录每次的读取时间。用阈值把时间分成命中和未命中两类。统计每个索引命中的次数取最高的那个作为猜测值。#define TRIES 1000 #define THRESHOLD 100 // 周期数需根据机器实测调整 int scores[SLOT_COUNT] {0}; for (int t 0; t TRIES; t) { // 1. flush 整个缓冲区 for (int i 0; i SLOT_COUNT; i) { _mm_clflush(probe_buffer i * CACHE_LINE_SIZE); } _mm_mfence(); // 2. 触发越权访问 victim_function(attack_index); // 3. reload 并计时 for (int i 0; i SLOT_COUNT; i) { uint64_t start rdtsc_begin(); volatile char c probe_buffer[i * CACHE_LINE_SIZE]; (void)c; uint64_t end rdtsc_end(); if (end - start THRESHOLD) { scores[i]; } } } // 4. 找最高分 int best 0; for (int i 1; i SLOT_COUNT; i) { if (scores[i] scores[best]) best i; }阈值THRESHOLD是最需要调的参数。设太小噪声全被当成命中设太大真正的命中被漏掉。我的经验是先跑一次纯 flush reload 的基线看看未命中时的时间分布再跑一次故意命中的看看命中时的时间分布两个分布之间的谷底就是合适的阈值。5. 实操中的坑与排查手册5.1 信号完全出不来怎么办这是最常见的问题。代码看起来没错但scores数组里全是均匀分布没有明显峰值。按下面的顺序排查现象可能原因排查方法所有槽位分数接近推测执行没发生检查编译器是否把if优化掉了用-O0试试分数随机波动噪声太大关随机化、绑核、提高采样次数完全没有峰值越权访问没触发确认index确实越界且secret_data可访问峰值位置漂移缓存行映射问题确认槽位间隔等于缓存行大小时快时慢CPU 降频固定频率策略我遇到过一次特别隐蔽的情况编译器把victim_function里的if判断整个优化掉了因为它证明了index永远在范围内。解决办法是把secret_size声明为volatile或者在编译时加-O0让编译器别自作聪明。5.2 阈值怎么定才靠谱定阈值不要拍脑袋。正确做法是画时间分布直方图先只做 flush不做越权访问测一批 reload 时间这是未命中分布。再故意让某个槽位进缓存测一批 reload 时间这是命中分布。两个分布通常会在某个区间分开取中间值当阈值。如果两个分布重叠严重说明你的测量精度不够要么提高计时精度要么增加采样次数要么换台机器。5.3 为什么换个机器结果就变了这个实验对微架构极其敏感。不同代际的处理器对推测执行的限制不同权限检查的位置不同缓存替换策略也不同。有的机器上成功率 90%换一台可能只有 10%。这不是代码的问题是硬件的问题。所以做这个实验时记录你的硬件型号和微架构代号这样结果才有可比性。如果你在报告里写成功率 80%却不写机器型号那这个数字没有意义。5.4 提高成功率的几个实用技巧增加采样次数从 1000 提到 10000峰值会更明显代价是时间变长。多次攻击取众数对同一个秘密值攻击多次取出现次数最多的结果比单次更稳。优化 flush 顺序先 flush 再触发顺序不能反否则缓存状态不对。减少测量循环里的分支测量代码越简单越好任何多余操作都会引入噪声。用volatile防止优化确保每次 reload 都真的去读内存而不是被编译器缓存到寄存器里。提示如果你发现峰值时有时无先别改代码先看看是不是后台有进程在抢 CPU。用top看一眼往往问题就出在这。6. 从实验到认知这件事到底告诉了我们什么6.1 性能与安全的永恒拉扯这个实验最让人震撼的地方不是它有多复杂而是它揭示了一个根本矛盾处理器为了快必须猜只要猜就可能猜出不该猜的东西。这不是某个厂商的失误而是整个高性能计算范式的固有代价。你不可能既要极致的乱序执行又要绝对的信息隔离。每一代处理器都在这个天平上找平衡点有的偏向性能有的偏向安全但没有人能完全消除这个矛盾。理解了这一点你就不会再问为什么会有这种漏洞而会问在什么条件下这个漏洞可被利用代价有多大。这才是安全思维的正确打开方式。6.2 侧信道思维的迁移价值抛开具体的攻击手法这个实验真正教给你的是一种思维方式当直接读取被禁止时去读它的副作用。这个思路可以迁移到很多地方。比如判断某个文件是否存在不一定非要打开它看它的访问时间、大小变化、错误码差异都能推断出信息。再比如判断某个分支走了哪条路不一定非要看结果看执行时间、功耗、缓存占用都能反推。这种从侧面看的能力是安全分析里最值钱的东西之一。它逼你跳出功能正确的层面去思考实现过程本身泄露了什么。6.3 防御思路的启发既然攻击靠的是缓存痕迹那防御自然就从消除痕迹入手。常见的思路有几类隔离让不同安全域的数据不共享缓存从物理上切断侧信道。填充在关键操作后主动刷新缓存让痕迹来不及被观测。随机化让时间测量失去稳定的基准增加攻击者的噪声。限制推测在权限检查点插入屏障禁止跨边界的推测执行。这些防御各有代价有的损失性能有的增加复杂度。实际系统里往往是组合使用根据威胁模型取舍。理解攻击原理才能理解防御为什么这么设计而不是死记硬背一堆缓解措施的名字。7. 一些个人体会这个实验我前后做过好几轮每轮都有新发现。第一轮卡在环境上虚拟机里怎么跑都是噪声第二轮卡在阈值上拍脑袋定的值把信号全滤掉了第三轮才真正看到那个清晰的峰值那一刻确实有点激动——你亲眼看到了 CPU 说漏嘴。如果让我给后来者一句建议那就是别急着追求高成功率先把单次测量做干净。很多人一上来就想跑出 99% 的准确率结果连基线都没测过根本不知道自己的噪声水平在哪。先把 flush reload 的基线时间测出来把命中/未命中的分布画出来剩下的就是水到渠成的事。另外这个实验的价值不在于复现一个攻击而在于它逼你去读处理器的微架构手册去理解流水线、缓存、推测执行这些平时被抽象掉的东西。做完之后你看代码的眼光会变——你会开始想这行代码在硬件层面到底发生了什么有没有留下不该留的痕迹。这种视角的转变比实验本身的结果重要得多。后续如果想继续深入可以往两个方向走一是换不同的缓存侧信道手法比如 PrimeProbe体会它们各自的适用场景二是研究不同微架构上的行为差异把同一段代码在几台不同机器上跑一遍对比结果。这两个方向都能让你对硬件到底怎么工作有更立体的认识。