缓存时间:
2026/10/08 12:25
# 清零化,第 1 部分:擦除可能适得其反
来源:https://00f.net/2026/10/06/zeroization-1/
处理机密信息时的一个良好习惯是用完后将其擦除。各种项目不断收到大量 PR,要求为任何看起来像机密的内容添加清零化函数的调用。这是一个唾手可得的改进点,也是 LLM 喜欢报告的问题,而且理论上看起来很有用。但不幸的是,情况要复杂一些。盲目地对机密进行清零可能弊大于利。事实证明,为机密添加擦除操作可能会留下**更多**机密副本,甚至比原代码还多。这些副本若非添加了擦除操作就不会存在,而且在擦除函数返回后仍然留在内存中。
代码片段及其编译输出可在 [Godbolt 示例](https://godbolt.org/z/zaMY8WajG)中验证。
## 先从 memset() 说起
经典起点:众所周知的 `memset()` 调用被优化掉了:
```c
#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` 是无用操作,因为函数返回后它再也不会被读取。
但在我们试图修复它之前,先注意汇编输出中的另一个细节:中间值根本就没有进入内存,它一直待在寄存器中。
## 让存储操作存活下来
解决这个问题最常见的做法:用 volatile 字节写入替换 `memset()`:
```c
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` 提供零值。太好了,我们击败了编译器的优化阶段,真正向栈内存写入了八个零字节!
等等……那条将中间值放入这些栈槽位的指令去哪了?
意外的是:根本没有这样的指令。`x8` 持有中间值。我们刚刚向从未接收到我们要擦除的值的地址写入了八个零字节。随后函数从 `x8` 计算结果并返回,`x8` 从未被清零。实际的机密值仍然存在,我们只是白白浪费了 CPU 周期。
## 如果我们把擦除操作藏起来不让编译器看到呢?
让我们试试另一种常见方法:把擦除代码放到一个单独的函数中,放在独立编译的文件里,不启用 LTO。就是那种在密码学库中随处可见的 `secure_zero()` 函数的某种变体:
```c
#include <stddef.h>
void opaque_wipe(void *pointer, size_t length) {
volatile unsigned char *bytes = pointer;
while (length != 0) {
*bytes++ = 0;
length--;
}
}
```
调用者只看到声明,所以在编译这个调用时无法检查实现:
```c
opaque_wipe(&intermediate, sizeof intermediate);
```
很好,让我们看看调用者现在的汇编输出。
有意思:调用之前出现了一条新指令:
```
str x8, [sp, #8]
```
嗯?我们现在把中间值存到栈上了?原来的函数并没有这样做!
是的,擦除需要一个地址。由于调用者看不到实现,它必须允许被调用者读取该地址处的旧内容。因此它先将值写入那里。擦除操作清除了这个新的栈副本,但对 `x8` 置之不理。我们引入了一个值在内存中存在的时段,花费代价将其清除,同时还保留了寄存器副本。
而且,一个被识别为只写操作的函数可以跳过这个特定的存储。如果启用了 LTO(如今相当常见),我们试图将实现隐藏在另一个文件中的做法很可能变得毫无意义。
## 现在来比较一个标签
现在让我们做一件很常见但稍微不那么简单的操作:计算一个标签(HMAC 输出等),进行比较,擦除它,返回结果。
在这个例子中,我们将用 `seed[i] ^ 0x5a` 来计算标签。这完全愚蠢且不安全,但便于跟踪汇编。我们想要将其与应用程序提供的标签进行安全比较。
```c
#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](https://godbolt.org/z/Pba6nrcbz) 中比较这两个函数。在那里,可寻址对象位于 `sp + 32` 而不是 `sp + 40`(我用 Xcode 得到的),但 `sp + 16` 处的额外副本在擦除之后仍然存活。禁用栈保护会生成不同的代码,但这更像是一个偶然的不可靠副作用,而不是修复或好主意。
所以我们有时在为存储买单,有时在为新的调用和栈副本买单,而机密仍然存在。对于一个本应改善清理的改动来说,这相当令人沮丧。
## 不应忽视寄存器
每次上下文切换后,寄存器都会被复制到内存中。寄存器在核心转储、休眠文件和 VM 快照中都是可见的。寄存器还可能通过微架构 bug 泄露(Zenbleed:https://lock.cmpxchg8b.com/zenbleed.html)。而且一个 SIMD 寄存器可以容纳一个完整的 256 位密钥,甚至是一个 512 位的哈希值。它们的回收频率也往往低于通用寄存器。
但传统的清零函数只是被设计为向内存写入零值,对实际的执行流程一无所知。
## 保留有用的擦除
这是否意味着我们应该停止擦除密码缓冲区?不:那些缓冲区已经存在于内存中,用完后清除它们是有用的。已分配的密钥调度或正在退役的上下文也是如此。
但在上面的例子中,我们关心的值也存在于其他地方。因此,在为一个小型局部变量添加擦除之前,检查该值是否已经存在于内存中,取其地址是否会创建存储,以及在调用期间哪些值仍然活跃。检查你实际发布的优化构建,包括 LTO 和强化标志。
或者,停止盲目地到处散布缓冲区清零函数的调用。
擦除机密有更可靠的技术。我们将在第 2 部分中看到。