Navigation Menu

[中文正文内容]

本文档从高层次的角度探讨了从单个快照恢复多个 VM 克隆的影响。我们首先概述 Linux 随机数生成 (RNG) 设施,然后讨论与克隆状态相关的潜在问题,最后提出一系列建议。值得强调的是,我们的目标是仅针对内核接口防止过时状态成为问题。一些用户空间应用程序或库保留了自己的 entropy 池的等价物,并在克隆后遇到相同的潜在问题。在当前的编程模型下,没有通用的解决方案,我们所能做的就是建议不要在快照前的逻辑中使用它们。

背景

Linux 内核向用户空间公开三个主要的 RNG 接口:/dev/random/dev/urandom 特殊设备,以及 getrandom 系统调用,这些都在 random(7) 手册页 中进行了描述。此外,Firecracker 支持 virtio-rng 设备,该设备可以为 guest VM 提供额外的 entropy。它从 aws-lc-rs crate 中提取随机字节,该 crate 封装了 AWS-LC 密码库。

传统上,/dev/random 被认为是“真正的”随机性来源,缺点是当 entropy 池耗尽时读取会阻塞。另一方面,/dev/urandom 不会阻塞,这导致人们认为它提供的结果质量较低。

事实证明,在输出质量上的区别实际上很难区分。根据这篇文章,对于 4.8 之前的内核版本,这两个设备都从同一个池中提取输出,唯一的区别是当系统估计 entropy 计数已降低到某个阈值以下时,/dev/random 将阻塞。/dev/urandom 输出被认为对于几乎所有目的都是安全的,但需要注意的是,在系统收集足够的 entropy 进行初始化之前使用它,确实可能会产生低质量的随机数。getrandom 系统调用有助于解决这种情况;默认情况下,它使用 /dev/urandom 源,但会阻塞直到它被正确初始化(可以通过配置标志更改行为)。

较新的内核 (4.8+) 已切换到一种实现,其中 /dev/random 输出来自一个名为阻塞池的池,/dev/urandom 的输出由 CSPRNG(密码学安全的伪随机数生成器)给出,并且还有一个输入池,用于从系统上可用的各种来源收集 entropy,并用于馈入或播种其他两个组件。 此处提供了非常详细的描述。

此文档中使用这种较新的实现的细节来提出建议。也有一些内核接口用于获取随机数,但它们类似于从用户空间使用 /dev/urandom(或带有默认源的 getrandom)。

每当基于快照创建 VM 克隆时,执行都会精确地从先前保存的状态恢复。从 /dev/random/dev/urandom 获取随机字节不会导致从同一快照创建的不同克隆获得相同的结果,因为多个参数(例如计时器数据,或来自 CPU HWRNG 指令的输出,这些指令存在于 Ivy Bridge 或更新的 Intel 处理器上,并在 Firecracker guest 中启用)与每个结果混合在一起。在读取随机值时以及结合 entropy 相关事件(例如中断)时,都会混合额外的位。此外,如果附加了 virtio-rng,guest 内核最终将从其中接收新的 entropy。这里有两个问题:

这两个问题在从快照恢复 VM 后立即特别重要。在 VM 运行“足够”长的时间后,它应该能够自行收集更多 entropy,并且其状态应该与任何其他克隆的状态充分不同。

似乎 CPU HWRNG 总是添加到混合中(如果存在)。更具体地说,第 32 页的第 1 点(在页面顶部) 提到了在 entropy 池输出函数中使用 CPU HWRNG(如果存在)。第 34 页指出 如果已知 Linux-RNG 具有 CPU 随机数生成器,则来自该硬件 RNG 的数据将在第二步中混合到 entropy 池中。关于 /dev/urandom 后面的随机池和 DRNG 状态的初始化。关于第 35 页上的 DRNG 状态的讨论提到 密钥部分、计数器和 nonce 与 CPU 随机数生成器的输出进行异或(如果存在)。如果不存在,则使用内核函数 random_get_entropy word 获取的高分辨率时间戳与密钥部分进行异或CPU HWRNG 还用于 DRNG 状态转换函数(如第 36 页第 1 点所述)和重新设定种子操作期间(第 37 页第 2 点)。该文档明确提到何时必须信任 CPU HWRNG(例如,第 3.3.2.3 节末尾的要点)。

尚不清楚每个克隆恢复后添加的噪声是否足以认为其 RNG 状态对于安全目的而言是不同的。保守的方法是假定陈旧状态对 RNG 输出有重大影响,因此我们应该在每次恢复后基于新数据重新初始化两个源。似乎简单地将数据写入 /dev/urandom 足以混淆 entropy 池,但这些位仅与输入池混合。目前尚不确定此类写入是否对阻塞池产生任何直接影响,并且不太可能导致 CSPRNG 被自动重新设定种子。

与内核 RNG 源进行交互的标准方法记录在 random(4) 手册页 中。它指出,对 /dev/random/dev/urandom 的任何写入都会与输入 entropy 池混合,但不会增加当前的 entropy 估计。还有一个 ioctl 接口,在给定适当的权限的情况下,可以使用该接口将数据添加到输入 entropy 池,同时增加计数,或完全清空所有池。

支持 VMGenID 的 Linux 内核

自 5.18 起,Linux 支持 虚拟机生成标识符 用于 ACPI 系统。自 6.10 起,Linux 还增加了对使用 DeviceTree 而不是 ACPI 的系统的支持。VMGenID 的目的是通知 guest 关于时间偏移事件,例如从快照恢复。该设备在 guest 内存中公开一个 16 字节的密码学随机标识符。Firecracker 实现了 VMGenID。当从快照恢复 microVM 时,Firecracker 会写入一个新的标识符,并将通知注入到 guest 中。Linux,使用此值 作为其 CSPRNG 的新随机性。引用内核的 random.c 实现:

/*
 * Handle a new unique VM ID, which is unique, not secret, so we
 * don't credit it, but we do immediately force a reseed after so
 * that it's used by the crng posthaste.
 */

因此,在内核处理 VMGenID 通知,从同一快照启动的所有 VM 中返回的 getrandom()/dev/(u)random 的值是不同的。这在恢复 vCPU 和 Linux CSPRNG 成功重新设定种子之间留下了一个竞争窗口。在 Linux 6.8 中,我们扩展了 VMGenID,以便在处理通知时向用户空间发出 uevent。用户空间可以轮询此 uevent,以了解何时可以安全地使用 getrandom() 等,从而避免竞争条件。

Firecracker 在 ARM 系统上使用 DeviceTree 绑定支持 VMGenID,该绑定是在 Linux 6.10 中为该设备添加的。但是,Firecracker 支持的最新 Linux 内核是 6.1。因此,为了在 ARM 系统上使用 VMGenID,用户需要使用 6.1 内核,并将 DeviceTree 绑定支持从 6.10 反向移植。对于我们的 CI,我们从 6.10 到 6.1 反向移植了相关的更改。想要在 ARM 上使用该功能的 Firecracker 用户需要确保他们在 guest 内核上反向移植这些更改。

请注意,Firecracker 将始终启用 VMGenID。在没有 VMGenID 驱动程序的内核中,该设备对 guest 无效。

用户空间注意事项

初始化系统(例如 AL2 和其他发行版使用的 systemd)可能会在启动后保存一个随机种子文件。对于 systemd,路径是 /var/lib/systemd/random-seed。为了安全起见,在拍摄快照之前应删除任何此类文件,以防止 guest 将其用于任何目的。还有一个 /proc/sys/kernel/random/boot_id 特殊文件,该文件在启动时使用随机字符串初始化,之后是只读的。从同一快照恢复的所有克隆将隐式地从此文件中读取相同的值。如果这不是期望的结果,则可以通过在 /proc/sys/kernel/random/boot_id 之上绑定挂载另一个文件来更改读取结果。

建议

附件 1 包含一个 C 程序的源代码,该程序实现了前三个步骤。 只要 guest 内核版本切换到 4.19(或更高版本),我们就可以依靠 CONFIG_RANDOM_TRUST_CPU 内核选项(或 random.trust_cpu=on 命令行参数)来使用 CPU HWRNG 自动重新填充 entropy 池,因此不再需要第 3 步。绕过第 3 步的另一种方法是附加一个 virtio-rng 设备。但是,我们无法控制 guest 内核何时会从该设备请求随机字节。

附件 1:清除并重新初始化 entropy 池的源代码

#include <errno.h>
#include <fcntl.h>
#include <linux/random.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ioctl.h>
void exit_usage() {
  printf("Usage: ./rerand [<hexadecimal_string>]\n"
      "The length of the string must be a multiple of 8.\n");
  exit(EXIT_FAILURE);
}
void exit_perror(const char *msg) {
  perror(msg);
  exit(EXIT_FAILURE);
}
int main(int argc, char ** argv) {
  if (argc > 2) {
    exit_usage();
  }
  size_t len = 0;
  struct rand_pool_info *info = NULL;
  if (argc == 2) {
    len = strlen(argv[1]);
    // We want len to be a multiple of 8 such that we have an easier time
    // parsing argv[1] into an array of u32s.
    if (len % 8) {
      exit_usage();
    }
    info = malloc(sizeof(struct rand_pool_info) + len / 8);
    if (info == NULL) {
      exit_perror("Could not alloc rand_pool_info struct");
    }
    // This is measured in bits IIRC.
    info->entropy_count = len * 4;
    info->buf_size = len / 8;
  }
  int fd = open("/dev/urandom", O_RDWR);
  if (fd < 0) {
    exit_perror("Unable to open /dev/urandom");
  }
  if (ioctl(fd, RNDCLEARPOOL) < 0) {
    exit_perror("Error issuing RNDCLEARPOOL operation");
  }
  if (argc == 1) {
    exit(EXIT_SUCCESS);
  }
  // Add the entropy bytes supplied by the user.
  char num_buf[9] = {};
  size_t pos = 0;
  while (pos < len) {
    memcpy(num_buf, &argv[1] + pos, 8);
    info->buf[pos / 8] = strtoul(num_buf, NULL, 16);
    pos += 8;
  }
  if (ioctl(fd, RNDADDENTROPY, info) < 0) {
    exit_perror("Error issuing RNDADDENTROPY operation");
  }
}