跳到正文
Hacker News· birdculture·· 8 天前AI 评分22

归零化(上):擦除密钥反而可能更不安全

Zeroization, part 1: Wiping can make things worse

AI 导读

在密钥使用后调用 memset 等归零函数,编译器常会将其优化掉,因为该值返回后不再被读取。改用 volatile 字节写入或跨文件 opaque_wipe 后,调用方被迫先把中间值写入栈以提供地址,反而多出一份内存副本,且寄存器中的原值依然保留。

正文 · AI 翻译

处理机密信息时,一个良好的习惯是在使用后将其擦除。

项目会收到无数 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 提供零值。

完美,我们击败了编译器的优化遍,并确实向栈内存写入了八个零字节!

等等……把我们的中间值放入那些栈槽的指令在哪里?

惊喜:没有这样的指令。

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(如今相当普遍),我们试图将实现隐藏在另一个文件中的做法很可能变得无用。

现在让我们比较一个标签

现在让我们做一些非常常见但稍微不那么简单的事情:计算一个标签(HMAC 输出等),比较它,擦除它,返回结果。

在这个例子中,我们将把标签计算为 seed[i] ^ 0x5a。这完全愚蠢且不安全,但很容易在汇编中跟踪。

我们想要安全地将其与应用程序提供的标签进行比较。

#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 进行比较。

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

现在有趣的部分来了:如果我们移除擦除并重新编译呢?

惊喜:计算标签根本不再存储在栈上。一切都保留在寄存器中。

添加擦除创建了我们遗留的副本。

请注意,这里没有编译器错误。返回值是正确的,被寻址的对象被覆盖了,而 C 并不承诺你在这两个事件之间所期望的安全顺序。

你可以在 Godbolt 中比较这两个函数。

在那里,被寻址的对象位于 sp + 32 而不是 sp + 40(我用 Xcode 得到的是后者),但 sp + 16 处的额外副本仍然在擦除后存活。

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

所以有时我们要为存储付出代价,有时要为新的调用和栈副本付出代价,而秘密仍然存在。对于一个本应改进清理的更改来说,这相当令人沮丧。

寄存器不应被忽视

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

而单个 SIMD 寄存器可以容纳整个 256 位密钥,甚至 512 位哈希。它们被回收的频率也往往低于通用寄存器。但传统的清零函数只是设计用来向内存写入零,对实际执行流程一无所知。

保留有用的擦除

这是否意味着我们应该停止擦除密码缓冲区? 不:那些缓冲区已经存在于内存中,在我们完成后清除它们是有用的。对于已分配的密钥调度或我们正在退役的上下文也是如此。

但在上面的例子中,我们关心的值也在其他地方。

因此,在向小型局部变量添加擦除之前,请检查该值是否已经存在于内存中,获取其地址是否会创建存储,以及哪些内容在调用期间保持活跃。 检查你发布的优化构建,包括 LTO 和加固标志。

或者停止盲目地散布对缓冲区擦除函数的调用。有更可靠的技术来擦除秘密。

我们将在第 2 部分中看到。

来源:Hacker News · 00f.net