Go 汇编变异测试:提升加密代码测试覆盖率的创新方法
Go 汇编变异测试
在维护和开发 Go 加密标准库时,我们花在测试上的时间通常远超实现时间。这是好事,也是我们取得优秀安全记录的重要部分。
理想情况下,库中最不安全的部分尤其应该如此。然而,由于汇编核心的恒时性特性,测试它们面临着独特挑战。这一直是一个长期存在的问题。
对于 Go 1.26,我正在为汇编引入一个变异测试框架,它将有效地充当增强的代码覆盖率。这本身不会改进测试,但能让我们看到哪些汇编代码和数据路径未被测试套件覆盖,从而改进测试。
#20040,我的白鲸
加密汇编可以说是我作为 Go 维护者的"起源故事"。早在 2017 年,Cloudflare 的一位同事发现一个证书无法通过 Go 的 crypto/x509 验证。该错误是 P-256 模减法 amd64 汇编实现中进位处理不当导致的。由于在随机输入操作时,该进位标志有 1/2³²的概率被设置,它逃过了所有测试。
Adam Langley 评论说利用它不太可能,而且"会是一篇很酷的论文"。然后 Sean Devlin 和我在巴黎的一家星巴克躲了一整天,而黄背心在外面焚烧警车,我们想出了如何将其变成好莱坞式的密钥恢复攻击。那很有趣,但这是另一个故事了。
快进一年,现在我的工作是防止这种情况再次发生。寻找针对此类错误的稳健对策从此成了我的白鲸。
"Filippo,正常、理智的人不会有白鲸。""好吧,我们什么新东西都没学到,是吧?"
汇编策略(希望)有助于减少引入新的手动编写汇编错误的风险,如果有什么作用的话,那就是因为它使引入新的手动编写汇编变得更加困难。但一个根本问题是我们不知道汇编的测试情况如何,因为代码覆盖率对加密汇编不起作用。
大多数加密代码必须在恒时条件下运行,这意味着无论输入如何,它都执行相同的指令,以避免通过时序侧信道泄露秘密。为了实现这一点,我们通常计算操作的两个"分支"(例如,对于 a - b mod p,同时计算 a - b 和 a - b + p),然后通过恒时选择指令丢弃其中一个结果。问题是,如果运行代码覆盖率,你会看到所有"分支"都被点亮,即使所有测试实际上都丢弃了其中一个的结果。我们可能还有其他未测试的路径,如 #20040,却不知道。
在 2019 年的某个时候,我尝试使用 DynamoRIO 在运行时检测二进制文件,以捕获每个消耗标志的指令之前的标志,以提供更全面的覆盖率报告。它几乎奏效了。"几乎"是决定性的。
变异测试
进入变异测试。变异测试修改程序,例如将!=
变为==
,并检查每个"变异"的测试是否失败。如果不失败,则该行实际上未被测试。
这实际上比常规测试覆盖率更准确,因为它不仅检查代码是否被执行,还检查结果是否影响测试的成功,以至于产生不同的结果会导致测试失败。
它也非常适合恒时汇编!
例如,如果我们将带进位加法变为常规加法,而测试仍然通过,那么我们实际上没有测试进位被设置的情况。
变异汇编
下一个问题是如何以编程方式变异汇编。我原本打算在源代码级别进行,但 Russ Cox 建议修改汇编器,以避免处理宏和解析。
cmd/asm
在解析后、编码前为指令分配虚拟程序计数器。CL 6653751 添加了一个-mutlist
标志,用于在此时将列表打印到标准错误,以及一个-mut
标志,允许用一个或多个其他指令替换任何具有其程序计数器的指令。实现它相当容易,重用了解析器并修补了指令链表。
这些汇编器标志可以在go test
期间使用-asmflags=PACKAGE="-mut=..."
为特定包启用。幸运的是,cmd/go
已经知道将-asmflags
参数折叠到汇编器工件的缓存键中,它甚至缓存 stderr 输出,因此即使使用缓存结果,-mutlist
输出也可用。
测试框架
驱动这些测试相对简单。
首先,我们运行go test -c -asmflags=PACKAGE=-mutlist
以获取潜在目标的列表。
然后,对于每个目标指令的每个变异,我们运行go test -failfast -asmflags=PACKAGE="-mut=file.s:123=MUTATION"
,并确保它失败。为了加速,我们首先使用-short
运行,然后仅在短测试通过时运行不带它的测试。此外,我们首先运行-c
以确保我们的变异能够编译。
变异
最后,我们需要决定变异哪些目标指令以及如何变异。变异将行为因标志而异的指令转变为等效的、行为如同标志始终设置或从未设置的指令。它们不能改变任何其他东西,以避免意外破坏测试运行并导致变异测试假阴性。特别是,我们不能使用任何寄存器,并且需要保持最终标志不变。
让我们看几个 arm64 示例。
ADCS 和 SBCS
ADCS 将两个寄存器和进位相加,并设置输出标志。
将其变异为忽略进位标志的指令很容易,我们只需将其变为 ADDS。
为了向另一个方向变异,我们在前面添加一条设置 C 标志的指令。我们不关心破坏其他标志,因为 ADCS 无论如何都会重置它们。
SBCS 是等效的减法指令,我们以相同的方式变异它,只是 SUBS 的行为如同进位(即"无借位")标志始终设置,因此我们需要在镜像变异中取消设置它。
ADC 和 SBC
ADC 和 SBC 是不设置输出标志的相应指令。
这使事情有点不同,因为我们不能用前置指令破坏标志,但另一方面,我们不需要担心准确地设置它们。
我们不是事先设置进位位,而是在之后对目标加一或减一。
还有一个问题:如果其中一个操作数是零寄存器 ZR,则等效的 ADD 或 SUB 无法编码,因为如果不设置标志,向零加减而不是存储就没有意义。在这些情况下,我们变异为适当的 MOVD。
CSEL
CSEL 是一个恒时选择,根据标志(通常是相等或进位标志)存储一个值或另一个值。
将其变异为 MOVD 很简单。
结果
我最初在 arm64 P-256 汇编上运行了这个,出于白鲸和硬件可用性的原因,它发现了一些未测试的指令,包括……在 p256SubInternal 中,该死。
编写测试来覆盖它们很繁琐,有时非常困难,因为像 P-256 字段溢出深藏在函数中这样的 2^-32 边缘情况很难明确命中。这是另一个迹象,表明这个汇编核心应该被分解为更小、更易于测试的操作。
要了解我与加密汇编斗争的最新情况,请在 Bluesky 上关注 @filippo.abyssdomain.expert 或在 Mastodon 上关注 @filippo@abyssdomain.expert。更多精彩内容 请关注我的个人公众号 公众号(办公 AI 智能小助手)对网络安全、黑客技术感兴趣的朋友可以关注我的安全公众号(网络安全技术点滴分享)
公众号二维码

公众号二维码

评论