清零(Zeroization)第一部分:擦除可能让情况更糟

Lobsters Hottest 新闻

摘要

这篇技术博客文章探讨了在 C 代码中对秘密数据进行清零(memset/secure_zero)的常见做法为何可能适得其反:编译器优化可能会删除 memset 调用,而使用 volatile 逐字节写入或不透明的擦除函数反而可能引入新的秘密副本,并让其残留在寄存器中。

<p><a href="https://lobste.rs/s/4hxxad/zeroization_part_1_wiping_can_make_things">评论</a></p>
查看原文
查看缓存全文

缓存时间: 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 部分中看到。

相似文章

Implementing Undo - Computerphile

Lobsters Hottest

这是一期 Computerphile 视频的中文文字稿,深入讲解了软件中「撤销/重做」功能的实现原理,涵盖撤销粒度的用户预期、用栈(两个栈)实现多级撤销、以及位图编辑中内存开销与快照存储的挑战。

删除19000行编译器死代码——且不盲目信任LLM

Lobsters Hottest

作者分享了在一周内用 AI 辅助但不盲信 AI 的方法,安全删除约 19000 行编译器死代码的经验:先用静态分析界定范围,再让 LLM 标记 RED/BLUE 函数,并用独立的调用图工具交叉验证标记完整性,最终实现零回滚、零修复提交的清理。

每次 512 字节:重建 IBM i 保存文件的校验和

Lobsters Hottest

Silent Signal 博客文章详细介绍了如何逆向工程 IBM i 保存文件(SAVF)的两层校验和机制(每 512 字节负载对应的 16 字节记录尾校验,以及全局校验),用 Python 复现该算法,并演示在修改或删除对象后重新修复校验和、使 IBM i 接受该文件;文章同时指出,这些无密钥的校验和并不具备身份认证能力。