这一关回答 CPU 是如何"提前做题"的。答案很激进:现代 CPU 从不按程序顺序执行——分支预测器下注、乱序执行抢跑、退休(retire)时才按序提交。赌赢是常态,赌输就丢弃结果、零代价——Spectre 之前,所有人都这么以为。但它证明了一件事:架构状态可以丢弃,微架构状态(缓存)丢不掉。"赌输了没擦干净",就是侧信道的全部秘密。
四个机制支点:投机执行三件套(预测器/BTB/RSB、乱序执行、缓存侧信道)、V1/V2/Meltdown 的三分法、内核四道防线(nospec、retpoline、IBRS/IBPB、KPTI)、代价总账。链回:M15(侧信道的"信道"就是缓存命中/未命中的时间差)、M07(KPTI/IBPB/RSB stuffing 全在上下文切换路径上)、M04(KPTI 是两套页表)。
流水线让取指/译码/执行/访存/写回重叠——但遇到分支时,下一条指令在哪还不知道,流水线不能停。于是 CPU 下注,赌具是三套预测器:
if 的 taken/not-taken)call *%rax、虚函数、C 的 switch 跳表)ret 回到哪——和 call 配对压栈的小栈下注之后是指令的乱序执行:指令按"数据就绪"程度发射,不按程序顺序;退休时才按序提交——架构状态(寄存器、内存)只在退休时更新。赌输的指令,结果丢弃,仿佛没执行过。这套"仿佛没执行过"的保证,就是 Spectre 打碎的东西。
攻击分三步,教科书式的干净:
probe[secret * 64];关键洞察:侧信道读的是"访问模式",不是数据——secret 从未被"架构性地"读取过,只是它"路过"了缓存。量化的部分是 M15 的遗产:命中/未命中的时间差几十纳秒,rdtsc 随便量——上关的"两个数量级",在这里变成了攻击原语。
三个 CVE,三张脸(Documentation/admin-guide/hw-vuln/spectre.rst):
if (i < len) 的检查还没算完,投机已经读了 array[i]——越界下标在投机里畅通无阻。缓解:nospec 宏截断投机依赖链(可疑下标先 sanitize 再用)+ lfence。区分铁证:V1/V2 赌的是"往哪跳",Meltdown 赌的是"能不能读"。前者修预测器(或困住投机),后者只能把数据搬走——缓解策略的分叉点就在这里。
经典序列三句话:call 把 pause 循环的地址压栈 → 把真实目标 mov 到栈顶 → ret。架构上 ret 跳到栈顶的真实目标;投机上 RSB 预测"ret 回到 call 的下一条"——掉进 pause; lfence; jmp 死循环出不来。spectre.rst 的原话:return trampoline 把投机路径"trap"住,不让它跳进 gadget。
内核侧(arch/x86/kernel/cpu/bugs.c):spectre_v2_select_retpoline(),SPECTRE_V2_RETPOLINE = "Mitigation: Retpolines",编译期由 CONFIG_MITIGATION_RETPOLINE 门控。代价:每个间接调用/跳转多几条指令——于是 spectre.rst 写明:有 Enhanced IBRS 的 CPU 应该用它,"more efficient than retpoline"。软件 trick vs 硬件能力,选便宜的。
arch/x86/mm/pti.c,文件头注释写明血统:基于 KAISER——"Kernel/User page tables isolation"。机制粗暴有效:两套页表。用户态运行时 CR3 指向 user 页表——只映射用户空间加少量入口跳板,内核地址空间在页表里根本不存在。Meltdown 的投机读连页表项都找不到,直接 fault。
门控:X86_BUG_CPU_MELTDOWN + pti=on/off/auto(pti.c 的 pti_mode 三态)。代价同样粗暴:每次 syscall/中断进出内核多一次 CR3 切换(M04 的页表切换)+ TLB 压力——syscall 密集型负载的回归可达两位数百分比。这就是 mitigations=off 存在的理由:威胁模型允许时,拿回性能。
前两道防线管"跳去哪"和"读不读",这一道管"上一个进程留下的预测器状态":
x86_spec_ctrl_base / DEFINE_PER_CPU(u64, x86_spec_ctrl_current)——注意,per-CPU,M15 的回链:MSR 本来就是按 CPU 的。PRED_CMD 清空 BTB,放在 switch_mm 路径上(switch_mm_cond_ibpb / switch_mm_always_ibpb)。为什么必须在换进程时清?spectre.rst 原话:BTB 预测"does not fully disambiguate"——不区分进程和特权级。不清=上一个进程的投毒留给下一个进程用。switch_to_cond_stibp)——同一物理核的两个线程共享 BTB 和 L1。代价总账:retpoline 慢间接分支、KPTI 慢 syscall、IBPB/RSB 慢上下文切换。M07 的"切换代价"在本关之后必须重算——切换不再只是 CR3+寄存器,还有预测器状态的打扫费。
为什么 Meltdown 用"两套页表"这种笨办法,而不是修 CPU?
因为已出货的 CPU 改不了微架构。KPTI 是纯软件方案:老 CPU 用页表隔离续命,新 CPU 在硬件里堵住投机越权。双轨制——软件为存量买单,硬件为增量负责。
为什么 retpoline 不干脆在所有间接分支前加 lfence?
lfence 是"别投机",retpoline 是"让你猜错、但架构正确"——前者杀死该分支上的一切投机收益,后者只为难攻击者。缓解设计的核心 trade-off:在"堵住攻击"和"保留性能"之间,选刚好够用的那个。这也是 EIBRS 替代 retpoline 的逻辑。
为什么 IBPB/STIBP 默认只保护"需要保护"的进程(prctl),而不是全开?
因为清 BTB、关超线程推测是全局性能税。内核的威胁模型是分层的(bugs.c 的 attack vector controls):user→kernel 默认防,user→user 按需(prctl 自选),由管理员和程序自己定。安全不是免费的,分层是为了让不需要的人不付税。
switch_mm/switch_to 路径上——M07 的代价模型要重算prctl 按进程开关推测——"进程"是缓解策略的计费单位出题原则:10 题中记忆性题目不超过 3 道,其余为应用/推理/权衡类——场景预测、设计 trade-off 比较、从代码路径找问题、解释"为什么这样设计而不是那样"。难度递进,最后两道需要想一会儿。
V1 赌条件分支的方向(bounds check 被投机越过);V2 赌间接分支的目标(BTB 被投毒,跳进 gadget);Meltdown 赌"权限检查追不上乱序执行"(和分支预测无关,直接读内核内存)。记住"往哪跳 vs 能不能读"的分叉点。
因为退休(retire)只丢弃架构状态,不丢弃微架构状态。投机期间访问过的缓存行还留在 L1 里——"结果可以撤销,缓存擦不掉"。Flush+Reload 读的正是这些残留:secret 的值被编码成了"哪一行在缓存里"。
(1) Flush:清空探测数组的缓存行,建立干净的基线;(2) 诱使受害者投机执行 probe[secret*64]——把 secret 编码为"哪一行被载入缓存";(3) Reload 并计时,命中的行下标即 secret。最终"读"到的是访问模式,不是数据本身——secret 从未被架构性地读取。
靠 RSB(Return Stack Buffer)。RSB 的预测逻辑是"ret 回到最近一次 call 的下一条"——retpoline 的 call 恰好把 pause 循环地址压了进去,RSB 忠实地预测跳回那里。而架构执行时 ret 弹的是被 mov 改写过的栈顶=真实目标。预测器按"惯例"猜,retpoline 给了它一个精心布置的惯例。
KPTI 防的是"读到本不该读的地址空间"——用户页表里没有内核映射,Meltdown 的投机读直接 fault。但 V1 读的是进程自己的内存(越界下标访问同一地址空间内的数据),页表隔离对此毫无作用。V1 要靠 nospec 宏截断投机依赖链——不同的赌,需要不同的封堵点。
因为 BTB 预测"does not fully disambiguate"——它按地址偏移做预测,不区分进程、不区分特权级。上一个进程投的毒(特定偏移的 BTB 项),下一个进程执行到相同偏移的间接分支时会被直接采用。不清=把攻击者的预测器状态继承给受害者。所以 switch_mm_cond_ibpb 长在上下文切换路径上。
spectre.rst 原话:Enhanced IBRS "more efficient than retpoline"。retpoline 是软件 trick——每个间接分支多几条指令(call/mov/ret + pause 循环);EIBRS 是硬件能力——置一下 SPEC_CTRL,CPU 自己限制推测,指令流零改动。同样的安全,硬件方案税更低,所以 spectre_v2_select_mitigation() 有 EIBRS 就优先选它。
当威胁模型里"攻击者"不存在时:(1) 物理隔离的单租户机器,没有不可信代码执行;(2) 虚拟化层已经做了隔离,guest 内无攻击者;(3) 性能敏感且已评估风险(如 HPC 计算节点)。论证框架:缓解防的是"不可信代码与受害者共享 CPU"——如果这条前提不成立,KPTI/retpoline 就是纯税。关缓解不是"不安全",是"威胁模型不支持这笔开销"。
(1) 缓解分层:每个缓解只堵一个攻击面——nospec 堵 V1 的下标、retpoline 堵间接分支、KPTI 堵越权读、IBPB 堵 BTB 残留。攻击者换一条没堵的路(RSB 投毒→RSB stuffing、BHB→BHI_DIS_S),就又有新 CVE。bugs.c 里从 SPECTRE_V1 一路到 ITS、SRSO 的缓解名单,就是这部"打地鼠"史的化石记录。(2) 新变体:只要"投机执行 + 可观测的微架构状态"这两个前提还在,新的预测器结构(分支历史缓冲、返回栈、store buffer……)就是新的攻击面。Spectre 修不完,因为它不是 bug,是性能优化的固有属性。
代价变化:每次 syscall 进出内核多一次 CR3 切换(写 CR3 本身几十周期)+ 用户/内核页表切换带来的 TLB 未命中上升(PCID 缓解了部分,但 entry trampoline 区域的局部性仍差)+ 中断路径同理。100 次 syscall/请求 ⇒ 每请求多 200 次 CR3 切换,约数千周期,syscall 密集型服务整体回归可达两位数百分比。实验:perf stat -e cycles,instructions,dtlb_load_misses.walk_completed,itlb_misses.walk_completed,context-switches,三组对比:(a) pti=on,(b) pti=off,(c) pti=on + PCID 确认开启——看 dTLB walk 次数与 cycles/request 的差值,差值即 KPTI 税。注意固定 CPU 频率、绑核,排除调度噪声。
spectre_v2_select_mitigation()、IBPB/STIBP 的 static keypti=on/off/auto投机执行时间线:赌赢是常态,"擦干净"是谎言
周期 → 取指 → 译码 → [分支! 预测器下注] → 乱序执行 → …
│
┌─────────────────────┘
│ 投机窗口
▼
┌─────────────────────┐
│ 预测正确 → 退休提交 │ 架构状态更新,一切正常
│ 预测错误 → 丢弃结果 │ 架构状态无变化
└─────────────────────┘
│
但 L1 缓存里留下了"访问痕迹" ──→ Flush+Reload 读走
一句话:CPU 保证"算错了当没算过",但没保证"当没来过"。
retpoline:架构上跳对,投机上跳错
call set_up ; 把 pause 循环地址压入 RSB
capture:
pause
lfence
jmp capture ; 投机掉进死循环,出不来
set_up:
mov %reg, (%rsp); 栈顶改写为真实目标
ret ; 架构执行:跳真实目标 ✓
; 投机执行:RSB 预测回 capture ✗ → 被困住
一句话:用 RSB 的"惯例",给投机挖了个陷阱。
内核四道防线对照表 攻击面 缓解 位置/机制 代价 ───────────────────────────────────────────────────────────── V1 条件分支 nospec 宏+lfence 可疑下标先 sanitize 极小(局部) V2 间接分支 retpoline 间接调用变"猜不中的ret" 每个间接分支多几条指令 V2 间接分支 Enhanced IBRS SPEC_CTRL MSR(硬件) 比 retpoline 更便宜 V2 残留 BTB IBPB switch_mm 时清 BTB 上下文切换变贵 SMT 兄弟核 STIBP switch_to 时置位 超线程推测受限 Meltdown 越权读 KPTI 两套页表(pti.c) 每次进出内核多一次CR3切换 总账:没有免费的缓解,只有"选哪种税"。
在页面里每题写下你的答案(关键词即可),卡住的题点一下"卡住了"标记,
点"生成作答汇总"后复制、贴到聊天里——对错由我来判定,我逐题批改并据此
调整下一关的深浅;准备好了说"下一关"。
点下面按钮生成汇总文本,复制后贴到「内核二十关」侧聊里,对错由我判定。