零化,第一部分:擦除可能使情况更糟
从内存中清除密钥比调用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 和加固标志。
或者,别再盲目地到处撒缓冲区擦除函数的调用。擦除机密有更可靠的办法。
第二部分再讲。
相关文章
- 2026年9月25日 你的员工真的在用你的产品吗?
- 2026年9月9日 半公开的 Git 仓库
- 2026年9月9日 Base84 值得在文件名中占一席之地
- 2026年8月19日 为什么把 Rust 编译到 WebAssembly 很慢
- 2026年7月30日 对 7 轮 AES 的改进攻击