使用Linux用户命名空间发展容器安全性

2020-12-24 21:26:47

孔碧波(Fabio Kung),萨贡·迪伦(Sargun Dhillon),安德鲁·史派克(Andrew Spyker),凯尔(Kyle),罗伯·古列维奇(Rob Gulewich),纳比尔·谢尔(Nabil Schear),梁安德(Andrew Leung),丹尼尔·穆伊诺(Daniel Muino)和玛纳斯·阿莱卡尔(Manas Alekar)

如之前在Netflix Tech Blog上讨论的那样,Titus是Netflix容器编排系统。它运行着公司各个部门的各种工作负载-从netflix.com的前端API到机器学习培训工作负载到视频编码器,应有尽有。在Titus中,运行负载的主机是从我们的用户中抽象出来的。 Titus平台维护着大量的同构节点容量池来运行用户工作负载,而Titus调度程序则放置了工作负载。这种抽象使计算团队可以通过调度程序影响机队的可靠性,效率和可操作性。运行工作负载的主机称为Titus“代理”。在本文中,我们描述了Titus代理如何利用用户名称空间来提高Titus代理团队的整体安全性。

Titus代理团队在用户看来是同质的能力库。 Titus在内部采用蜂窝式隔板结构来实现可扩展性,因此,舰队由多个单元组成。许多隔板架构将其单元划分为租户,其中租户被定义为一个团队及其应用程序集合。我们不采用这种方法,而是对单元进行分区以平衡负载。我们这样做是出于可靠性,可伸缩性和效率的原因。

Titus是一个多租户系统,允许多个团队和用户在系统上运行工作负载,并确保它们可以共存,同时仍提供有关安全性和性能的保证。其中大部分归结为隔离,形式多种多样。这些形式包括性能隔离(确保工作负载不会降低彼此的性能),容量隔离(确保给定的租户在需要时可以获取资源),故障隔离(确保系统的一部分故障不会造成损害)。导致整个系统发生故障),安全隔离(确保一个租户的工作负荷受到损害不会影响其他租户的安全)。这篇文章重点介绍我们的安全隔离方法。

Titus对多租户的最大担忧之一是安全隔离。我们希望允许来自不同租户的不同种类的容器在同一实例上运行。容器中的安全隔离一直是一个有争议的话题。尽管存在风险,我们还是选择利用容器作为安全边界的一部分。为了抵消集装箱安全边界带来的风险,我们采用了一些附加保护措施。

多租户的构建块是Linux名称空间,这是使LXC,Docker和其他类型的容器成为可能的技术。例如,PID名称空间可以使进程只能在其自己的名称空间中看到PID,因此不能向主机上的随机进程发送终止信号。除了默认的Docker名称空间(挂载,网络,UTS,IPC和PID),我们还使用用户名称空间来增加隔离层。不幸的是,如CVE-2015–2925这样的CVE中所见,这些默认的名称空间边界不足以防止容器转义。这些漏洞的出现是由于名称空间之间的交互的复杂性,内核开发过程中的大量历史决策以及诸如Linux中的proc文件系统之类的泄漏抽象所致。正确组合这些安全隔离原语很困难,因此我们已经寻求其他层的额外保护。

在主机上多租户运行许多不同的工作负载,需要进行横向移动预防,这种技术是攻击者破坏了在系统容器中运行的单个软件,并利用该软件破坏了同一系统上的其他容器。为了减轻这种情况,我们将容器作为非特权用户运行-使其无法使用“ root”用户。这一点很重要,因为在Linux中,UID 0(或root的特权)并非仅来自用户是root的事实,而是来自功能。这些功能与当前流程的凭据有关。可以通过权限升级(例如sudo,文件功能)来添加功能,也可以通过移除(例如setuid或切换名称空间)来删除功能。各种功能控制着root用户可以做什么。例如,CAP_SYS_BOOT功能控制给定用户重新启动计算机的能力。还授予用户更常见的功能,例如CAP_NET_RAW,它使进程能够打开原始套接字。用户通过文件功能执行特定文件时可以自动添加功能。例如,在现有的Ubuntu系统上,ping命令需要CAP_NET_RAW:

CAP_SYS_ADMIN是Linux中最强大的功能之一,实际上等效于具有超级用户访问权限。它使用户能够执行所有操作,从装入任意文件系统到访问可以公开有关Linux内核重要信息的跟踪点。其他强大的功能包括CAP_CHOWN和CAP_DAC_OVERRIDE,它们可以操纵文件权限。

在内核中,您经常会看到功能检查遍及整个代码,如下所示:

请注意,该函数不会检查用户是否是root用户,而是会在允许任务执行之前检查该任务是否具有CAP_SYS_ADMIN功能。

Docker采用允许列表定义容器接收哪些功能的方法。这些可以由用户扩展或减弱。在某些情况下,甚至Docker概要文件中定义的默认功能也可能被滥用。当我们以没有这些功能中许多功能的非特权用户身份查看正在运行的工作负载时,我们发现它不是一个入门者。各种软件都在FUSE,低级数据包监视和性能跟踪等其他用例中使用了增强的功能。程序通常从功能开始,执行任何需要这些功能的活动,然后在流程不再需要它们时“删除”它们。

幸运的是,Linux有一个解决方案-用户命名空间。让我们回到前面的内核代码示例。 pcrlock函数调用有能力的函数来确定任务是否有能力。此函数定义为:

这将检查任务相对于init_user_ns是否具有此功能。 init_user_ns是最初产生进程的名称空间,因为它是内核启动时存在的唯一用户名称空间。用户名称空间是一种用于分割init_user_ns UID空间的机制。设置映射的接口是通过/ proc显示的“ uid_map”和“ gid_map”。映射如下所示:

这允许将用户命名空间容器中的UID映射到主机UID。发生了各种翻译,但是从容器的角度来看,所有内容都是从映射的UID范围(也称为范围)的角度来看的。这在几个方面都很强大:

它允许您对容器设置某些UID禁区-如果未在用户名称空间中将UID映射到真实的UID,并且您尝试使用它检查磁盘上的文件,则该文件将显示为overflowuid / overflowgid, / proc / sys中指定的UID和GID,以指示无法将其映射到当前工作空间中。此外,容器不能将setuid设置为可以访问该“外部uid”拥有的文件的UID。

从用户名称空间的角度来看,容器的根用户似乎是UID 0,并且容器可以使用映射到该名称空间的整个UID范围。

然后,内核子系统可以继续使用绑定到资源的特定用户名称空间来调用ns_capable。现在,相对于要处理的资源,对用户名称空间进行了许多功能检查。反过来,这允许进程行使某些特权,而在初始化用户名称空间中没有任何特权。即使在许多不同的名称空间上映射是相同的,功能检查仍相对于特定的用户名称空间进行。

理解权限如何工作的一个关键方面是每个名称空间都属于一个特定的用户名称空间。例如,让我们看一下负责控制主机名的UTS命名空间:

命名空间与特定的用户命名空间有关系。用户操作主机名的能力取决于该进程在该用户名称空间中是否具有适当的能力。

我们可以检查名称空间和用户之间的交互作用。要在UTS命名空间中设置主机名,您需要在其用户命名空间中使用CAP_SYS_ADMIN。我们可以在这里看到这一点,其中无特权的进程没有权限设置主机名:

这样做的原因是该进程没有CAP_SYS_ADMIN。根据/ proc / self / status,此过程的有效功能集为空:

现在,让我们尝试设置用户名称空间,然后看看会发生什么:

马上,您会注意到命令提示符说当前用户是root用户,并且id命令同意。现在可以设置主机名吗?

我们仍然无法设置主机名。这是因为该过程仍在初始UTS名称空间中。让我们看看是否可以取消共享UTS名称空间,并设置主机名:

现在已成功完成,并且该过程位于主机名为“ foo”的隔离UTS名称空间中。这是因为该过程现在具有传统root用户将拥有的所有功能,除了它们相对于我们创建的新用户名称空间而言:

如果我们从外部检查此过程,则可以看到该过程仍以非特权用户身份运行,并且原始外部名称空间中的主机名未更改:

从这里,我们可以做各种事情,例如挂载文件系统,创建其他新的名称空间,实际上,我们可以创建整个容器环境。请注意,没有任何特权升级机制用于执行任何这些操作。这种方法被某些人称为“无根容器”。

我们从2017年初开始启用用户名称空间。当时,我们有一个更简单的天真模型。之所以如此简单是因为我们在没有用户名称空间的情况下运行:

这种方法反映了现代容器编排系统的过程布局和边界。我们在机器上有一个共享的度量标准守护程序,该守护程序到达了容器并从容器中轮询了度量标准。用户访问是通过公开SSH守护程序并代表用户自动执行nsenter并将其放入容器中来完成的。要将文件暴露给容器,我们将使用绑定安装。使用相同的机制来公开配置,例如机密。

这样做的好处是,我们的许多软件都可以安装在主机名称空间中,并且仅管理该名称空间中的文件。然后,容器运行时管理系统(Titus)负责配置Docker,以通过绑定安装将正确的文件公开给容器。除此之外,我们可以在主机上使用我们的标准指标守护程序。

尽管此模型易于推理和编写软件,但它存在一些缺点,我们通过将所有内容转移到在容器的非特权用户名称空间中运行来解决。第一个缺点是现在所有主机守护程序都需要了解UID转换,并执行适当的setuid或chown调用以跨容器边界过渡。其次,这些过渡中的每一个都存在安全风险。如果SSH守护程序仅通过更改为容器的pid名称空间而部分过渡到了容器名称空间,它将使其/ proc可访问。然后,恶意攻击者可以使用它来逃脱。

借助用户名称空间,我们可以通过在容器的非特权用户名称空间中运行这些守护程序来改善我们的安全状况并降低系统的复杂性,从而无需跨越名称空间边界。反过来,这消除了正确实现跨命名空间过渡机制的需要,从而减少了引入容器转义符的风险。

为此,我们将容器运行时环境的各个方面移入了容器。例如,我们为每个容器运行一个SSH守护程序,并为每个容器运行一个指标守护程序。它们在容器的名称空间内运行,并且具有与容器中的工作负载相同的功能和生命周期。我们称此模型为“系统服务”-可以将其视为Pod的原始版本。到2018年底,我们已经将所有容器移至可在非特权用户名称空间中成功运行的位置。

看起来这似乎是间接的另一层次,它引入了复杂性,但相反,它允许我们利用一个极其有用的概念-“无特权的容器”。在无特权的容器中,root用户从一个基准开始,在该基准下,他们无法自动访问整个系统。这意味着DAC,MAC和seccomp策略现在是防止访问系统特权方面的额外防御层-而不是唯一的一层。添加新特权后,我们不必将其添加到排除列表中。这使我们的用户可以编写软件,在其中可以控制自己容器中的低级系统详细信息,而不必将所有复杂性强加到容器运行时中。

Netflix内部使用了专门构建的名为MezzFS的FUSE文件系统。该文件系统的目的是为各种编码工具提供对我们内容的访问。这些编码工具中的大多数旨在与POSIX文件系统API进行交互。我们的媒体云工程团队希望利用容器构建他们正在构建的名为Archer的新平台。反过来,Archer使用需要FUSE的MezzFS,并且当时FUSE要求用户在初始用户名称空间中具有CAP_SYS_ADMIN。为了适应内部合作伙伴的用例,我们必须在专用集群中运行它们,在这些集群中它们可以运行特权容器。

2017年,我们与合作伙伴Kinvolk合作,将补丁添加到Linux内核中,使用户可以安全地从非初始用户名称空间使用FUSE。他们能够成功地将这些补丁上传到上游,并且我们一直在生产中使用它们。从用户的角度来看,我们能够将它们无缝地移动到更安全的非特权环境中。由于不再将这种工作负载视为例外,因此可以简化操作,并且可以与常规节点池中的所有其他工作负载一起运行。反过来,由于部署的同质性,这使媒体编码团队可以从共享群集访问大量计算能力,并提高可靠性。

在过去的几年中,已经发布了许多与授予容器意外特权有关的CVE:

就像在任何复杂,快速发展的系统中所预期的那样,将来肯定会存在更多漏洞。我们已经使用了Docker提供的默认设置,例如AppArmor和seccomp,但是通过添加用户名称空间,我们可以实现高级的深度防御安全模型。这些CVE不会影响我们的基础架构,因为我们正在为所有容器使用用户名称空间。初始化用户名称空间中的功能衰减已按预期执行,并阻止了这些攻击。

内核中仍有许多部分正在获得对用户名称空间或增强功能的支持,从而使用户名称空间更易于使用。剩下要做的许多工作都集中在文件系统和容器编排系统本身上。其中一些更改将在即将发布的内核版本中发布。正在完成将无特权的挂载添加到overlayfs的工作,以允许在具有层的用户命名空间中构建嵌套容器。未来的工作将使Linux内核VFS层本身了解ID转换。这将使具有不同ID映射的用户名称空间能够通过在绑定安装中移动UID来访问同一基础文件系统。我们在Kinvolk的合作伙伴还致力于将用户名称空间引入Kubernetes。

如今,各种容器运行时都支持用户名称空间。 Docker可以按照其文档中的概述,为每个容器使用单独的用户名称空间设置机器范围的UID映射。任何符合OCI的运行时,例如Containerd / runc,Podman和systemd-nspawn,都支持用户名称空间。各种容器编排引擎还通过其基础容器运行时来支持用户名称空间,例如Nomad和Docker Swarm。

作为我们迁移到Kubernetes的一部分,Netflix一直在与Kinvolk合作,以使用户名称空间在Kubernetes下工作。您可以通过此处的KEP讨论来跟踪此工作,并且Kinvolk在其博客上的Kubernetes下具有有关运行用户名称空间的更多信息。我们期待与Kubernetes社区一起发展容器安全性。