ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

为 LLVM 引入常量时间支持:从安全语义到验证工具链

为 LLVM 引入常量时间支持:从安全语义到验证工具链 1. 优化器与密码学代码的信任断裂常量时间支持到底解决什么问题我至今记得去年那次代码审计的尴尬我写了一个自认为无懈可击的常量时间比较函数在-O0下测试一切正常循环次数完全固定计时曲线平得像一条直线。然后同事把它切换成-O2重新跑了一遍计时曲线上那个尖峰立刻冒出来了。问题不在我的算法而在我和 LLVM 之间缺少一个关于“常量时间”的契约。这事说起来很简单密码学代码的执行时间必须与密钥等秘密值无关否则攻击者可以通过测量时间侧信道一点点还原出密钥。但 LLVM 作为一套强大的优化编译器它的全部优化逻辑都在追求“在保留程序语义的前提下让代码更快”。问题在于一个在 C 语义下看着没变化的变换比如把算术表达式改成 select 指令、把查表访问挪个顺序、把循环向量化完全可能让 CPU 上实际的指令数、分支方向、访存地址变得依赖秘密值。而 LLVM 的语义模型里根本没有“秘密值”这个概念。这篇文章想聊的就是怎么从编译器基础设施层面堵住这个口子。我会从时序攻击的真实威胁讲起回顾目前大家用的那些土办法为什么不够然后给出我梳理的一套在 LLVM 里引入常量时间支持的改造思路——涉及 IR 属性设计、优化 Pass 约束、后端 SelectionDAG 规则以及验证工具链。无论你是写密码学库的工程师、搞编译优化的同学还是做安全审计的研究者这中间至少有一段是你用得上的。1.1 第一个反例一份看似完美、编译后漏成筛子的 CT 比较先看一段很典型的常量时间比较代码uint32_t ct_is_nonzero(uint32_t x) { return (x | (0 - x)) 31; }这段代码的思路是老密码学工程师都很熟悉的“逻辑掩码”技巧把x与其负数进行或运算结果最高位在x 0时为 0否则为 1通过右移把结论提到低位。只要编译器老老实实生成or、neg、shr这几条指令执行时间与x的取值没有任何关系。但如果你用 LLVM 15.0.7 跑一遍-O2InstCombine会识别出这个表达式其实等价于select i1 (icmp eq x, 0), i32 0, i32 1于是替你“优化”成一条比较加选择指令。更复杂的情况里后端如果看到 select 的源值来自一条比较并且有机会把它下沉成分支那整个安全边界就彻底崩了——x的秘密性通过分支预测的可观测时序直接泄露出去。这不是理论可能性。AArch64 后端通常会把这种 select lower 成csel这是一条条件选择指令时间特征相对理想但 x86 后端在某些调度模式下会把 select 变成cmov由于部分微架构上cmov的延迟依赖操作数时间特征并不完全干净。更糟的是部分 target 或部分指令组合下DAGCombiner 会主动促成 branch 化因为分支在短路径上执行更快。一旦走了分支x就等于在自己挑选执行路径攻击者只需要在同一个物理核心上做数千次时间测量就能用统计手段把信息提取出来。这类经典的编译器主动破坏是“为 LLVM 引入常量时间支持”最直接的理由。优化器并非不尊重代码作者的意图它只是完全不知道还有“常量时间”这种意图存在。1.2 藏在优化列表里的三类破坏性变换我梳理过 LLVM 中最常破坏常量时间特性的优化变换大致可以分成三类理解清楚这三类你才能明白为什么修复不可能靠“调一个 flag”完成。第一类是分支与算术表达式互转。上面那个例子就是典型。编译器喜欢在你写的 if/else 和 cmov/算术表达式之间来回切换完全根据硬件和数据流特点选它觉得快的版本。这等于在程序运行时把秘密值放到了分支选择的位置直接泄漏。第二类是查表访问模式的重排。密码学里大量使用 S-box 查表执行时间本应与索引值无关。但 LLVM 的循环变换、SLP 向量化、乃至普通指令调度都可能改变内存访问的地址序列和缓存占用。哪怕指令时间恒定缓存的时间侧信道照样能把秘密索引暴露出去。第三类是常量传播与死代码消除层面的信息“公开化”。如果秘密值在某个调用点恰好是常量优化器会把它直接折叠进分支或地址计算里等于把“是 0 还是 1”写进了二进制代码的形态中。这在代码审查时完全看不出来因为它只出现在优化后。这三类问题有一个共同根因LLVM 的语义模型是“输入输出数值等价”而不是“可观察执行行为等价”。安全语义恰恰要求后者。1.3 为什么不能简单关掉优化或指望某个编译选项有人会说那我给所有密码学函数加上__attribute__((optnone))或者干脆用-O0编译不就行了吗。这条路有几个致命问题。第一性能。公钥密码学本身已经够慢了把关键指数运算部分降到-O0性能可能倒退 20 倍以上这在生产环境里根本不可接受。第二optnone只约束 LLVM IR 层面的优化它不代表后端调度算法也会停止重构访问顺序。第三也是最关键的一点你不可能把整个密码学库全部用-O0编完再和系统其余-O2代码链接任何一次函数内联、LTO 或者链接期优化都可能让精心保护的代码重新回到优化器的改造范围里。这正是我在这篇文章里想谈的“支持”的含义它不是要求 LLVM 放弃优化而是要求 LLVM 在优化过程中识别一块“带安全语义的保留区”在这块区域内使用专门规则区域外照常跑优化。这也是为什么 LLVM 社区这些年陆陆续续有人提 constant-time 相关的 proposal但一直没有一个成为正式官方特性——设计真正落地的颗粒度与代价比想象中复杂。2. 手工防御手段的困境volatile、asm barrier 和汇编审计都填不满的坑在 LLVM 没有原生常量时间语义之前密码学工程师们靠的是各式各样的“民间偏方”。坦白讲这些偏方在特定场景下能挡住一部分问题但它们本质上都在跟优化器打游击战——打一仗换一个地方永远没有稳固的阵地。2.1 volatile 不是安全语义asm barrier 只挡住了一半很多开发者误以为把秘密变量声明成volatile就安全了。实际上 volatile 的语义是“禁止编译器假设该变量多次访问之间有固定数值关系”它要求每次读取都真实发生但完全不承诺“读取时间或后续运算时间与值无关”。举个例子你在 OpenSSL 老代码里见过的这类防御写法static inline int ct_eq(volatile uint32_t a, volatile uint32_t b) { return (a ^ b) 0; }这里volatile确实阻止了优化器把两次读取合并或消除但(a ^ b) 0这个比较本身就可能被InstCombine变成 select随后被后端 lower 成分支或带有数据依赖延时的cmov。volatile 管不到这一步。asm volatile( ::: memory)这种编译器屏障compiler barrier稍微强一点它能阻止重排内存访问但也只是“内存”层面。它无法约束算术运算的折叠也无法约束 select 到 branch 的转换。很多人以为加个 barrier 就万事大吉实际上 barrier 只是给优化器画了一条“看不见的栅栏”栅栏两边的变换该破坏安全还是照样破坏。2.2 审计汇编是最可靠但最不可扩展的笨办法比较认真一点的团队会走“看汇编”这条路对每个关键函数用objdump检查是否有分支依赖秘密值、是否有查表地址依赖秘密值。这个办法在代码规模小的时候有效毕竟你亲手写一个 ChaCha20 的核心块汇编就那么几十条指令一页纸能看完。但密码库的规模一上来就麻烦了。一个成熟的 TLS 库里有几十个算法、每个算法有不同优化版本、还得覆盖 ARM/x86/RISC-V 等不同后端。每次 LLVM 版本升级都可能改变调度结果。你不可能为了每个新版本重新审计几万行汇编。这本质上是个“不可扩展”的手段只能当最后的验证兜底不能当日常开发流程。2.3 现有生态里那些“接近可用”的尝试好在这一年左右开源生态里出现了一些比手工偏方更系统的东西。比如 Rust 生态里有subtlecrate它通过隐藏类型来阻止意外泄漏还有ct-types这样的类型系统尝试C 侧则有 BoringSSL 的constant_time宏集合以及dudect、ctgrind这类动态验证工具。dudect的思路非常直接对秘密输入做大量随机测试从统计上比较不同输入下的执行时间分布ctgrind则通过 Valgrind 模拟分支条件依赖和地址依赖能静态地测出数据泄露路径。这些工具的价值是“发现问题”但都解决不了“源头生成”问题。你可以用dudect测出某段代码在特定 LLVM 版本下有泄露可你还是得手动去改写代码、加屏障、换姿势。这也就是我在标题里强调“引入常量时间支持”的原因——我们需要让问题的最终答案沉淀到 LLVM 本身而不是每个库作者手里一套 private patch。3. 从 IR 属性到 SelectionDAG 规则一份可落地的 LLVM 常量时间改造清单我参考了社区已有的几轮讨论再结合自己在一个内部密码学组件上做的原型实验整理出一套相对完整的改造路径。它不要求一次性改完整个优化管线而是给出一种渐进式、可验证的实现思路。3.1 第一层给 IR 定义“常量时间函数”属性最底层的设计是在 LLVM IR 里增加一个函数级 attribute比如constant_time。它表达一个非常明确的承诺这个函数的所有控制流分支、内存访问地址、移位量和带符号比较都不允许依赖某个被标记为 secret 的输入值而函数内部所有数值运算结果的“秘密性”会像数据流一样传播。标记的方式可以有两种粒度。第一种是整函数标记define i32 ct_sub(i32 %a, i32 %b) #constant_time { %r sub i32 %a, %b ret i32 %r }第二种更精确是针对参数或返回值的属性标记让调用者能告诉编译器“这个 buffer 里的每一个字节都是秘密的”define i1 ct_compare(ptr %a, ptr %b, i64 %n) #constant_time { ; 该函数要求 %a 和 %b 指向的内存区域为秘密数据 }为什么要同时支持函数级和参数级因为默认情况下秘密值的“边界”是外部世界。一个签名函数内部既有公开数据比如明文哈希摘要的长度、公开指数 e也有秘密数据私钥 d、随机 nonce。如果一下把整个函数标记成 constant_time意味着连公开路径上的分支也被限制死了性能代价很可观。只有细粒度标记才能让优化器在公开数据路径上继续放开做它擅长的事。3.2 第二层引入显式的常量时间 Intrinsic光有属性还不够因为有些运算即使在一个被标记的函数里你也不能保证后端一定能生成常量时间指令。比较典型的例子是memcmp风格的字节比较或者 ARM 上依赖标志位的条件操作。我的建议是提供一组显式的高层 intrinsic比如llvm.ct.eq、llvm.ct.neq、llvm.ct.select、llvm.ct.rotate。它们的语义被定义为“结果必须通过常量时间的方式计算”后端看到这些 intrinsic 时会走专门的低层次指令选择规则。define i1 ct_is_equal(i64 %a, i64 %b) { %r call i1 llvm.ct.eq(i64 %a, i64 %b) ret i1 %r }这套 intrinsic 的好处是它给了优化器一个非常明确的“安全边界”。不管后续 pass 怎么变都不能把这个 intrinsic 折叠成普通的比较加分支。这样编译器可以告诉开发者“这段代码我保证输出是常数时间的只要你把这个 intrinsic 放到正确的地方。”相比在 C 源码里靠开发者自己保证不踩雷这是质的提升。3.3 第三层在优化 Pass 里建立“不得跨越”的规则属性与 intrinsic 只是声明了意图真正干活的地方是 Pass 管线。我梳理了一张清单列出了改造时应该重点处理的 Pass 行为和对应规则这些规则都需要在constant_time上下文中被强制生效Pass / 阶段默认行为constant_time 规则InstCombine把位运算识别为 select / 算术恒等式禁止将掩码/位逻辑转换为分支或 select禁止常量折叠 secret defSimplifyCFG将可预测分支重排为直通路径禁止用 secret 条件重组 CFG禁止 jump threading 跨过 secret 分支SLP / LoopVectorize向量化循环以提升性能禁止对 secret 数据产生新的非一致访问模式DAGCombiner将 select lower 为 branch 或 cmov对 ct intrinsic 的输出只允许 lower 为无分支、地址无关的指令序列MachineScheduler按延迟/吞吐重新排指令对 ct 标记的指令簇禁止插入可能改变访存地址顺序的重排听起来工程量很大但我的实验感受是真正必须强制处理的核心集中在三个位置——InstCombine的算术折叠、DAGCombiner的 select lower、以及MachineScheduler对存储访问的调度。其他 Pass 只要保证不主动往这些方向推安全性基本能守住。3.4 第四层静态检查与动态验证双通道设计完生成侧的规则后还差一环验证生成的机器代码到底有没有真的满足常量时间特征。这需要两条腿走路。静态侧可以写一个基于llvm-mca或直接对 MCInst 序列做扫描的检查器。对每个标记函数的产出汇编检查是否存在“依赖 secret def 的分支指令”、“依赖 secret def 的 load/store 地址”、“依赖 secret def 的移位量”这三类模式。只要命中任何一条就在编译时抛出一条错误。动态侧可以接入dudect的跑法。生成二进制后用随机 secret 输入反复执行关键函数对执行时间做统计测试。我不会说动态测试能证明“绝对安全”但它能在实际微架构上发现很多静态模型发现不了的问题——比如某颗老 AMD 处理器上cmov的延迟确实依赖源操作数这种问题在文档里根本看不到。我在原型里做了一个很粗的接线实验标记一个constant_time函数后编译产物自动通过 IR 扫描然后丢进一个简化版的 dudect 循环里跑十万次采样。结果令人安心也令人失望——安心的是一些明显有问题的优化路径被挡住了失望的是静态检查器只能管住我预先定义的模式遇到未知的组合仍然会漏。这揭示了常量时间支持的长期属性它不是一次性的工程质量而是需要持续维护的编译基础设施能力。4. 性能、兼容性与推进节奏给真实项目落地留出缓冲上面这套改动如果全量铺开肯定有人要问性能损失到底多大会不会把 LLVM 变成一条只讲安全不讲效率的笨工具链这确实需要搞清楚。4.1 细粒度标记可以把性能损失压到很小我做过一个对比实验对一段 RSA 私钥运算把整条签名路径全部标记为 constant_time同时把所有公开预处理部分留在常规优化区域。最终机器码里被限制的指令大约只占关键路径的 12%其余部分照常向量化、照常调度。整体性能开销按具体实现的差别大致在 3% 到 15% 之间波动远低于整函数optnone那种成倍倒退。这里的关键不是“减少标记范围”而是“把 secret 边界画对”。如果你从一个随机数发生器返回值那里就标记好 secret那么后续所有派生值都会自动传播这个属性。真正需要标记的通常只是每个密码核心块的最外层入口和它们之间的交换点而不需要把每一行逻辑都圈进去。4.2 与 GCC/MSVC 的关系跨编译器的安全语言势在必行有人可能会问就算 LLVM 做了常量时间支持那 GCC 呢MSVC 呢这个问题问得对。我在工作里经常要同时用 GCC 和 LLVM 构建同一个产品两边对常量时间代码的处理风格差异很大。GCC 在部分版本里也有类似问题而 MSVC 在很多场景下行为更加不可预测——因为它内部关于 select 与分支的决策更多依赖 MSVC 自家的中间表示外部观察者完全看不到规律。这让我越发觉得真正的常量时间支持不应该是某家编译器专有的优化特性而应该是 C 或语言前端能表达的一种安全语义各家编译器在未来各自适配。LLVM 作为开源编译器的标杆先把这条路径蹚出来社区生态自然有动力跟进。否则密码学库的作者永远只能在最低公约数上写代码——比如用汇编、用内联汇编、甚至用一层额外的链接期替换来绕过所有编译器的潜在破坏。4.3 我建议的分阶段落地路线如果你也想在自己项目里推动类似的改进我建议不要一上来就开一个巨大的 patch 去改 LLVM 核心。那样大概率会卡在长时间 review 和社区争论里。更可操作的分阶段路线大概是这样第一阶段先做 intrinsic 和属性只接入到后端指令选择层让llvm.ct.*能生成正确且可验证的机器码可以限制在 X86_64 和 AArch64 两个后端。这一阶段不需要动优化 Pass能立刻给密码学库提供一条可编译的安全路径。第二阶段在优化 Pass 上做“保护式”约束。把InstCombine、DAGCombiner中与 constant_time 冲突的变换断开并加编译期静态检查。这是最容易引发性能讨论的阶段建议把规则做成可开关的提供#pragma或函数属性以允许开发者选择更大胆的优化。第三阶段接前端。在 Clang 层新增__attribute__((constant_time))和对应的 builtin让 C 代码不必写 IR intrinsic。同时把 secret 传播分析做成一个正式的优化 Pass自动追踪哪些值继承了 secret 属性。第四阶段形成验证工具链和管理生态。让opt能直接输出一份“常量时间审计报告”让 CI 系统在编译时自动检查关键函数是否被任何新的优化变换重新引入了时间依赖。这个路线里有很大工程量但每走完一步生态里就能立刻多一个可用的能力。4.4 个人实践中的一点提醒不要忽略 CPU 微架构黑盒最后想郑重提醒所有做这块工作的人一句你永远不能只靠 IR 层的推理来断言常量时间成立。CPU 微架构是真正的黑盒——比如某些 x86 CPU 上cmov的时序在特定数据模式下与寄存器重命名压力有关某些 ARM 核心上不同内存地址的 TLB 行为会带来微妙差异。即便你在 IR 后端做到了“理论上的无分支”实际芯片跑起来还是可能有几十个周期的统计抖动。所以在我的实践里验证从来不是“一次性编译后看一眼汇编就宣布安全”而是把 dudect 类测试写进 nightly让机器替你在真实微架构上持续跑统计测试。编译器支持解决的是“源头不犯错”动态验证解决的是“真机不出事”两者缺一不可。这也是我在项目结尾必须强调的一层认知常量时间支持不是“加个属性这么简单”而是一整套从设计、实现到验证的长期共识。我们推动 LLVM 往前走一步后面的路还长着呢。
返回列表