零化,第一部分:擦除可能使情况更糟

从内存中清除密钥比调用memset更难。优化器有权删除该写入,存储可能不会保留,而向编译器隐藏它也有其代价。关于正确实现零化的系列文章第一部分。

中文
复制

2026 年 10 月 6 日

处理密钥时的一个良好习惯是用完就擦除。

于是项目收到无数 PR,要求给任何看起来像密钥的东西加上清零函数调用。

这是唾手可得的成果,LLM 最爱报告这类问题,理论上也确实有用。但遗憾的是,事情要复杂一些。盲目清零密钥可能弊大于利。

事实证明,给密钥加一次擦除,留下的密钥副本可能比原代码更多。这些副本在没有擦除代码时根本不会存在,而且在函数返回后依然留在那里。

代码片段及其编译产物可以在这个 Godbolt 示例中验证。

从 memset() 说起

经典的起点:老掉牙的 memset() 调用被优化掉了:

我们计算一个中间值,用它得出结果,然后在返回前把它清掉。

#include <stdint.h>
#include <string.h>

uint64_t ordinary_memset(uint64_t input)
{
    uint64_t intermediate = input ^ UINT64_C(0x9e3779b97f4a7c15);
    uint64_t result = (intermediate >> 17) ^
                      (intermediate * UINT64_C(0xd6e8feb86659fd93));
    memset(&intermediate, 0, sizeof intermediate);
    return result;
}

现在去掉 memset(),对比生成的指令。两者完全相同。

编译器足够聪明,明白擦除 intermediate 是无用操作,因为函数返回后它再也不会被读取。

但在动手修复之前,注意汇编输出里的另一件事:这个中间值压根没进过内存,一直待在寄存器里。

让写入真正落到内存

针对这个问题最常见的做法:把 memset() 换成 volatile 字节写入:

volatile unsigned char *bytes =
    (volatile unsigned char *)&intermediate;
for (size_t i = 0; i < sizeof intermediate; i++) {
    bytes[i] = 0;
}

动手试试,看看 arm64 的输出(我用的是 XCode,自带 Clang 21):

eor     x8, x0, x8
strb    wzr, [sp, #8]
strb    wzr, [sp, #7]
strb    wzr, [sp, #6]
strb    wzr, [sp, #5]
strb    wzr, [sp, #4]
strb    wzr, [sp, #3]
strb    wzr, [sp, #2]
strb    wzr, [sp, #1]

八条 strb 指令。wzr 提供零值。

完美,我们击败了编译器的优化 pass,真的往栈内存写了八个零字节!

等等……把中间值放进这些栈槽的指令在哪?

意外吧:根本没有。

中间值在 x8 里。我们刚刚往八个从未存过目标值的位置写了八个零字节。

函数随后从 x8 算出结果并返回,没有清除它。

真正的密钥值还在,而我们白白浪费了 CPU 周期。

如果把擦除操作藏起来,编译器会怎样?

再试一种常见做法:把擦除代码放进单独编译的文件里的专用函数,不开 LTO。

你应该见过这种东西,密码学库里到处都是某个版本的 secure_zero() 函数:

#include <stddef.h>

void opaque_wipe(void *pointer, size_t length)
{
    volatile unsigned char *bytes = pointer;
    while (length != 0) {
        *bytes++ = 0;
        length--;
    }
}

调用方只能看到声明,所以编译这次调用时没法检查实现:

opaque_wipe(&intermediate, sizeof intermediate);

好,现在看看调用方的汇编输出。

有意思:调用之前多了一条指令:

str     x8, [sp, #8]

嗯?现在要把中间值存到栈上了?原来的函数可没这么干!

没错,擦除需要一个地址。而调用方看不到实现,就必须假定被调用方会读取该地址上的旧内容。所以它先把值写进去。

擦除清掉的是这个新的栈副本,x8 原封不动。

结果就是:我们引入了一段值存在于内存中的时间窗口,付出了清除它的代价,寄存器里的副本却还在。

另外,一个可识别的只写操作可以跳过这次存储;而如果开了 LTO(如今相当普遍),我们想把实现藏到另一个文件里的努力多半也就白费了。

再来比较一个 tag

现在做一件很常见但稍微不那么平凡的事:计算一个 tag(HMAC 输出之类),比较它,擦除它,返回结果。

这个例子里,我们把 tag 算成 seed[i] ^ 0x5a。这完全不安全,蠢得很,但方便顺着汇编看下去。

我们要把它和应用程序提供的 tag 做安全比较。

#include <stddef.h>

void opaque_wipe(void *pointer, size_t length);

int compare_and_wipe(const unsigned char seed[16],
                     const unsigned char candidate[16])
{
    unsigned char computed[16];
    for (size_t i = 0; i < 16; i++) {
        computed[i] = seed[i] ^ 0x5a;
    }

    unsigned int different = 0;
    for (size_t i = 0; i < 16; i++) {
        different |= (unsigned int)(computed[i] ^ candidate[i]);
    }
    int equal = different == 0;
    opaque_wipe(computed, 16);
    return equal;
}

把调用方和擦除函数分开编译,不开 LTO:

clang -std=c11 -O3 -Wall -Wextra -Werror -S compare.c -o compare.s
clang -std=c11 -O3 -Wall -Wextra -Werror -c wipe.c -o wipe.o

C 代码里,比较出现在擦除之前。

但仔细看看反汇编输出。

sub     sp, sp, #80
stp     x29, x30, [sp, #64]
add     x29, sp, #64
ldr     q0, [x0]
movi.16b v1, #90
eor.16b v0, v0, v1
str     q0, [sp, #16]
stur    q0, [x29, #-24]
ldr     q0, [x1]
str     q0, [sp]
sub     x0, x29, #24
mov     w1, #16
bl      _opaque_wipe
ldp     q1, q0, [sp]
cmeq.16b v0, v1, v0

等等。XOR 之后为什么有两次 q0 的存储?

还有,比较指令 cmeq 为什么在擦除调用之后?

要弄明白,先梳理一下栈。我们把分配完栈帧之后的栈指针记作 S;帧指针 x29 等于 S + 64。

栈字节内容是否清除
S 到 S + 15候选值否
S + 16 到 S + 31计算标签的额外副本否
S + 40 到 S + 55被寻址的 computed 对象是

啊哈!编译器在调用之前加载了比较操作数,却把比较本身推迟到了调用之后。

为了让计算标签在调用期间保持可用,它又复制了一份,放进溢出槽。

我们的清除操作完美清掉了请求的 16 字节,这里没问题。接着 ldp 重新加载另一份副本,cmeq 对它进行比较。

但在函数返回之前,没有任何东西清除那个溢出槽!

现在有意思的部分来了:如果我们去掉清除操作再编译一次呢?

意外的是:计算标签根本不再存到栈上了。所有东西都留在寄存器里。

加上清除操作,反而制造了我们留下的那份副本。

注意,这里没有编译器 bug。返回值是正确的,被寻址的对象也被覆写了,而 C 并不保证这两件事之间有你想要的安全顺序。

你可以在 Godbolt 上对比这两个函数。

在那里,被寻址的对象位于 sp + 32 而不是 sp + 40(这是我在 Xcode 上得到的结果),但 sp + 16 处那份额外的副本依然在清除操作之后存活下来。

禁用栈保护会生成不同的代码,但这更像是一个偶然且不可靠的副作用,而不是修复方案或好主意。

所以,我们有时为存储付出代价,有时为一次新的调用和栈副本付出代价,而秘密依然留在那里。对于一个本应改善清理的改动来说,这实在令人沮丧。

寄存器不该被忽视

每次上下文切换后,寄存器都会被复制到内存。寄存器在核心转储、休眠文件和 VM 快照中都可见。寄存器还可能通过微架构漏洞泄漏(Zenbleed)。

而一个 SIMD 寄存器就能装下整个 256 位密钥,甚至 512 位哈希。它们被复用的频率通常也低于通用寄存器。但传统的清零函数只是为向内存写入零而设计,对实际的执行流程一无所知。

保留有用的擦除

这是否意味着我们不该再擦除密码缓冲区了?不是:这些缓冲区本来就存在于内存中,用完就清掉是有意义的。为一个已分配的密钥调度或即将弃用的上下文做擦除,也是同样的道理。

但在上面的例子里,我们关心的值同时还存在于别处。

所以在给一个小型局部变量加擦除之前,先检查这个值是否已经在内存里、取它的地址是否会创建存储、以及调用期间哪些东西保持存活。检查你实际发布的优化构建,包括 LTO 和加固标志。

或者,别再盲目地到处撒缓冲区擦除函数的调用。擦除机密有更可靠的办法。

第二部分再讲。

相关文章

来源: 00f.net← 返回首页