LWN:非特权容器中的系统调用拦截机制!
关注了就能看到更多这么棒的文章哦~
System call interception for unprivileged containers
By Jake Edge
June 29, 2022
LSSNA
DeepL assisted translation
https://lwn.net/Articles/899281/
在德克萨斯州奥斯汀举行的 2022 年北美 Linux 安全峰会(LSSNA)的第一天,Stéphane Graber 和 Christian Brauner 发表了关于使用系统调用拦截来实现安全容器(container security)的演讲。这是为了让没有特权的容器,即那些在 host 上没有提升过权限的容器,如果需要执行一些需要特权的任务的话,也仍然能够完成。目前已经做了相当多的工作来实现可行性,但仍有更多工作需要继续完成。
Graber 首先说,他在为 Canonical 工作,负责 LXD container manager 项目,而 Brauner 为微软工作,负责 Linux 安全的各个领域。Graber 说,现在有两种类型的容器,带有特权的和非特权的,"一种是无法接受的,另一种可以"。他指出,特权容器就是 Docker 容器、Kubernetes 等等, "很不幸的是大家都在使用的东西"。
Unpriviledged containers
LXD 默认使用非特权容器(unprivileged container);用户命名空间就是这些容器中 "主要的安全屏障了"。特权容器一直在进行一个持续的打地鼠式的工作方式,也就是使用 Linux 安全模块(LSM)、seccomp()过滤器以及其他一些机制来逐个关闭那些导致容器内的进程在 host 上获得权限的漏洞。他以及其他一些开发者希望能实现一个人人都使用无特权容器的世界;"有特权的容器不应该存在",他说。
[Stéphane Graber]
但是有很多东西在非特权容器中是无法正常工作的。因为工作容器实际上是作为 host 系统上的很普通的用户在运行;"我们不会允许我们系统上的随便一个用户能做很多越界的事情"。使用其他类型的命名空间和添加新的命名空间就可以让非特权容器绕过其中的一些限制,但这种做法是有限制的,不确定能走多远。人们也并不喜欢在内核中增加更多的命名空间类型。
所以 LXD 项目开始研究 seccomp()过滤器,尤其是想看看用户空间的系统调用拦截是否可以利用。它可以提供一种方法,来允许容器做那些需要特殊权限的事情,但以一种受控的方式进行,由容器管理器(container manager)来管束。
Brauner 说,seccomp()在系统调用的特定代码被调用之前就已经在系统调用入口路径上了。在一些系统调用中,即使容器没有必需的权限,也应该能够成功地进行调用。例如,mknod()应该被允许用于某些类型的设备节点,如/dev/zero、/dev/null、/dev/console 等等。这些是 "没有什么大问题的设备节点",但是内核的权限模型要么允许创建任意的设备节点,要么就任何一个都不可以创建。
例如,没有特权的进程(或容器)不应该能够创建/dev/kmem 或一些随便什么 block 设备,因为这可能导致主机被攻破。但是,有几个简单的设备节点是容器中所需要的,它们目前是由 host 来 bind-mount 过来的。并没有什么理由说不可以直接在容器中创建。
Brauner 说,我们可以设想在内核中设置某种允许列表(allowlist),指定哪些设备节点是不需要权限来创建的。这种做法 "有点 hack",所以他尝试了其他解决方案。在这一过程中,他发现已经有一个功能比较有限制的 allowlist,那就是 Overlay 文件系统中使用的 whiteout 机制,用来标记在上层被删除的文件的 "留白 ",这实际上是具有特殊设备号(0/0)的字符设备节点。这些节点不需要额外的权限就可以被创建。他说,这削弱了那些反对在内核中为 mknod() 设置允许列表的论点,但这一方案并没有被采纳。
[Christian Brauner]
还有一些尝试是允许无特权的进程来创建设备节点,但不可以 open 这些节点。Brauner 说,这几乎破坏了所有的容器运行机制。有一个根深蒂固的假设是,如果一个进程可以创建一个设备节点,它就可以打开它。所以事实证明,允许创建无法打开的设备节点 "并不是一个好主意"。
但所有这些都集中在了一个唯一的系统调用上,事实上有必要支持系统调用的其他 "safe" 使用的情况。因此,系统调用拦截的想法在 2017 年的 Linux Plumbers 大会(LPC)上就诞生了,Brauner 认为。一个能够检查系统调用参数的机制就可以做一些工作了,例如拒绝对 block 设备和不在批准列表中的字符设备进行 mknod()调用。与其在内核中制定一些关于允许或拒绝的静态规则,不如将决定权下放给用户空间进程。
他说,因此 seccomp() 就被进行了扩展来支持这种用法。其中增加了一种新的 filter,以便在进行系统调用时就收到用户空间的通知;然后容器管理器进程可以取得一个文件描述符,它可以轮询系统调用 event。当容器管理器收到系统调用的通知时,可以使用 ioctl()命令来查询调用的参数,这些参数可以用来做决定。然后会把决定通过写入文件描述符的方式来返回给内核。
seccomp() filter 只能告诉内核继续调用,还是让调用失败并返回一个特定的错误代码给调用者,或者直接返回成功。如果容器管理器认为一个没有特权的容器应该可以成功进行系统调用,那么它不能直接通知内核来继续执行该系统调用,因为这个调用的发起者其实并没有相应的权限。因此,容器管理器必须执行该动作来模拟系统调用的发生,就好像该任务拥有相应的权限一样。在它完成这些工作并将结果提供给容器之后,它就可以告诉内核来直接返回成功。
他问与会者,是否能看到这个方案中有没有什么安全问题;有人很快提到了检查时间到使用时间的问题(TOCTTOU,time-of-check-to-time-of-use)的问题。Brauner 说,mknod()是一个 "相当无聊的系统调用,因为它只有整数参数"。其他的系统调用可能带有指针参数,也许会欺骗容器管理器在判定其安全之后就修改了这个地址上的参数。seccomp()过滤器是用 classic BPF 编写的,而不是扩展 BPF(eBPF),这意味着它们不能对指针进行解析和检查。因此,为了检查一个以指针引用方式传递的参数,管理器需要直接从进程的内存中读取数据(使用该地址作为/proc/PID/mem 的 offset)。这种做法 "可行",但它受到 TOCTTOU 的竞态冲突问题的影响。
在 seccomp()通知机制被加入之后,人们立即开始思考如何创建一个安全框架(security framework),例如,查看 open()系统调用的路径名参数来决定是否允许或拒绝访问某一个特定的文件。然后,如果文件名没有问题,它可以告诉内核继续进行系统调用。被过滤的进程可能已经拥有打开文件所需的权限,但如果过滤进程决定它不应该访问该文件的话就可能会被拒绝。不过,在检查完成后,该进程可以简单地修改这个参数,而内核也会很高兴地直接打开该文件。
这就限制了能够从 filter 来继续进行系统调用这个方案的用处。只有在最终的安全边界(也就是内核本身)无论如何都会拒绝这个动作的情况下,才能做到这一点,就像来自非特权容器的 mknod() 的处理那样。这意味着 seccomp() 通知机制不能用于给特权容器实现安全策略的场景。为了警告人们不要这样做,Brauner 说,他们在 seccomp.h 中加了注释来描述这些问题。
一般来说,seccomp()系统调用拦截需要在 host 上有一个受信任的、有特权的进程来监督这个调用。例如,在嵌套运行非特权容器的情况下,让外部容器的容器管理器监督来自内部容器的调用是没有什么用的,他说。在规划这一安全设施的用途时需要记住这一点。
Target system calls
Graber 在这时接手描述了他们一直在努力拦截的系统调用,这与他们在洛杉矶 LPC 开始时的 list 完全不同。这并不奇怪,因为即使在那个时候,他们也早就知道 list 上的一些东西很难或不可能处理好。目前的列表是 mknod(),如前所述,setxattr(),bpf(),sched_setscheduler(),mount(),和 sysinfo()。这些都是为 LXD 实现的。其他项目一直在使用 LXD 所做的工作,并且可能正在努力拦截其他系统调用。
拦截 mknod()/mknodat() 允许 LXD 在非特权容器中运行 debootstrap 等工具。这意味着可以在这些容器中构建发行版镜像了。这些调用需要被拦截的另一个原因是允许容器为 overlayfs 创建 whiteouts。例如,这允许 Docker 将其 layer 解压到一个无特权的容器中。Graber 说,他认为在 LXD 的限制下拦截 mknod() 是 "相对安全"的。他没有发现任何问题,但 LXD 容器中默认不启用该功能。不过项目组认为大多数容器都可以启用该功能。
setxattr()在 overlayfs 中提供了一种标记已删除目录的方法,所以 LXD 也需要支持它。有一个扩展属性(xattrs)的允许列表,可以在无特权的容器中设置。显然,只有一些属性是可以 allow 的,因为在某些命名空间设置这些属性,如 "security.*" 这些 xattrs "会是非常糟糕的",Graber 说。
Brauner 随后描述了 mount() 调用的情况。他说,在 mknod()的情况下,没有必要在 supervisor/manager 中 "对权限级别或安全级别进行任何调整"。它可以直接访问容器的 mount 命名空间并在其中创建设备节点。对于 mount()来说,事情并不那么简单。
在代表容器来执行 mount()时,有许多安全属性需要处理,如 Linux capability、LSM profile、UID 和 GID 等用户 ID、各种命名空间(如 mount、PID 或 user 命名空间)等等。管理器中模拟的调用需要对容器中请求进程的身份进行假设,这样在执行 mount 时就不会出现额外的权限。他说:"要做到这一点真的很棘手"。
鉴于此,他问道:"为什么要拦截 mount()系统调用?" 在有些情况下,host 为容器提供了一个文件系统,而容器管理器可以证明这一点。在这些很有限的情况下,允许文件系统被 mount 是确实有用的。然而,你不能允许在容器内任意进行 mount,因为有可能出现恶意的文件系统映像(malicious filesystem image)。
容器管理器可以模拟 mount()调用,所以它可以避免可能发生的 TOCTTOU 竞争问题,因为大多数参数都是指针。mount()系统调用也有问题,因为它是一个 "可怕的多路复用的功能",除了 mount 一个 block 设备上的文件系统外,还可以执行各种各样的操作:bind-mount、mount 一个伪文件系统、改变 mount 或改变 superblock 属性等等。拦截系统调用目前是有价值的,尽管如果能在虚拟文件系统(VFS)层实现 "delegated mounting" 功能在未来可能是一个更好的解决方案。
Graber 说,LXD 允许容器内的 mount 自动实现用户和组 ID 的重新映射。它也有一种模式可以拦截 mount,并将其变成使用用户空间的文件系统(FUSE)的等效 mount。这使得它 "相当安全",因为文件系统实际上没有直接通过内核 mount,而是由容器内的一个用户空间进程来处理的。
Brauner 说,他已经实现了一个 bpf()拦截的原型验证,其中使用了他在过去几年中完成的 pidfd 工作。要模拟那些返回文件描述符的系统调用(如 open()和 bpf())会有一个困难,因为文件描述符需要与请求进程共用。pidfd API 就可以将描述符安全地提供给另一个 task 了。LXD 限制了容器中可以运行的程序;它允许的一个程序可以让容器进一步限制对其中设备的访问。
Graber 说,sched_setscheduler() 拦截在 LXD 看来并不安全;"我觉得它很可疑",Brauner 说。但是,Graber 说,Android 经常使用这个调用,所以当在非特权容器中运行 Android 时,它就可以被启用。然而,这可能会导致各种问题,所以应该谨慎使用,当然尽量别用。
最近添加了 sysinfo()拦截,从而可以进一步支持 LXCFS 的一个功能,它可以根据容器的 cgroup 限制,而不是根据系统范围内的相应数值来报告有多少内存可用等信息。这很好,但有很多工具还在使用 sysinfo()来获取报告值,所以它们仍然会显示 host 范围的全局数值。通过拦截这个调用,就可以在容器内报告出来通常的运行时间、内存数量等信息。
Graber 随后演示了 LXD 容器中的各种拦截。作为一个例子,他展示了 sysinfo()的拦截。他以 256MB 的内存限制启动了容器,在容器内,free 命令确实正确显示了这个信息。这是因为 LXCFS 被挂载在/proc/meminfo 上,所以它可以拦截对该文件的读取。但是,运行一个查询 sysinfo() 的二进制文件,报出来的却是他的笔记本上的 16GB。重新启动带有拦截功能的容器之后,就可以解决这个小问题了。
Brauner 说,sysinfo() 拦截所使用的所有信息都来自 LXCFS 已经收集的信息却不通过系统调用报告,这导致了多个 bug report。例如,Java 通过 sysinfo()查看可用的内存,并会在此基础上进行内存预分配。此外,Graber 说, Alpine Linux 中的 free 使用(或曾经使用过)sysinfo(),从而导致了 LXD cgroup limit 相关的 bug report。
最后,他们对未来的工作提出了一些想法。Brauner 说,他想探索在 seccomp() filter 中至少添加一些有限的对 eBPF 的支持。长期以来,带有指针参数的新系统调用都被拒绝了,因为 seccomp()不能解析指针。这种情况已经改变了,所以一些多功能的 API,如 io_uring 以及新的可扩展系统调用方案(extensible system call scheme)并没有被阻止。但这导致了另一个问题。
GNU C 库(glibc)想转而使用 clone3()系统调用,但触犯了许多容器中安装的 seccomp() filter 的限制。这些 filter 根本不允许 clone3(),因为所有的参数都在指针之后,不能被检查到。旧的 clone()系统调用有一个直接传递的 flags 参数,因此可以用来决定系统调用是否应该继续。所以 Brauner 希望看到一些机制来检查指针后的那些参数,而如果能有限地支持 eBPF 就可以符合这一要求。在过去,seccomp() 的维护者 Kees Cook 一般都反对这样做,但 Cook 今年没有出席 LSSNA 会议。
除此之外,Graber 说,可能会对内核 module 加载来提供某些有限的支持。这个想法让很多人感到害怕,却是也应该害怕,但它将会是严格限制在对 init_module()/finit_module() 的拦截。并不会允许容器实际加载 module;相反,容器将传入它想加载的内容,如果该 module 通过了一些检查,容器管理器将加载该 module 的 host 版本。这方面的一个应用是容器中需要各种网络模块的防火墙。他说,现在,有一个 module 列表会在容器启动时被加载,但如果能按需加载 module 就更好了。
关于 seccomp() filter 的一个有趣的事情是,拦截甚至在查询系统调用表之前就已经完成了,这意味着新的系统调用可以完全在用户空间创建。新的系统调用将简单地被定义为一个未被使用的系统调用编号,它将被 filter 拦截从而可以调用新的代码。这可以用来作为新系统调用的实现原型方案了。他还没有看到有人真的这样做,但这是一种可能性。
[作者要感谢 LWN 的用户支持才能去奥斯汀参加 LSSNA。]
全文完
LWN 文章遵循 CC BY-SA 4.0 许可协议。
长按下面二维码关注,关注 LWN 深度文章以及开源社区的各种新近言论~