特权分离守护进程

很难以命令行粒度充分描述安全策略。因此,许多 rootwrap 条目实际上授予了任何被允许运行 rootwrap 的人完全的 root 访问权限。

本规范提出了一种替代方案(命名为“privsep”),它既更具表现力,又在授予权限方面更加有限制。

问题描述

OpenStack 的特权机制随着时间的推移从简单的 sudoers 文件发展到 rootwrap。最近的“rootwrap-daemon”工作通过避免重新执行 python 来大大提高了性能。纵观整个历史,基本的 API 模式仍然是作为 root 执行命令行(几乎总是)。

rootwrap 安全策略围绕着通过各种“过滤器”的配置来白名单特定的命令行。正确配置这些内容很困难,因为过滤器表达能力有限,命令行工具通常不被期望作为特权边界,并且在该级别已经失去了原始操作的“上下文”。

例如,随附的 nova/rootwrap.d/compute.filters 包含

chown: CommandFilter, chown, root

这允许调用用户以任何参数运行 chown,作为 root - 实际上授予了调用者 root 访问权限(考虑 chown $user /etc/shadow)。实际需求是 nova 需要将其 UID 设置为 VM 生成的各种文件的所有者,但这不能通过当前的 rootwrap 过滤器表达。

每次调用都通过 sudo(或类似 rootwrap-daemon)进行,限制了使用更严格的特权机制(如 Linux capabilities 或 SELinux)的能力,因为对 sudo 的调用有效地将权限重置为“完全权限”在调用堆栈的中间。

生成命令行和解析工具的文本输出速度慢且容易受到工具版本之间不一致的影响,因为通常这些输出并非设计为程序化 API。尤其是在 Neutron 中,命令行通常是平凡的 ip(8) 命令的重复调用,并且开销与应该廉价的 AF_NETLINK 交换相比非常大。

为什么需要特权机制?

也称为“为什么我们不直接以 root 身份运行代理?”

以尽可能少的权限运行是一种常见的防御性安全设计。假设有可能通过公开的网络协议远程利用您的服务,因此您希望以减少/无权限的代码运行大部分代码,只有在绝对必要时才获得特殊权限。如果攻击者控制了非特权代码,那么他们不会获得任何有趣的访问权限,并且仍然必须尝试第二次利用非特权->特权边界才能获得有用的权限。

拟议政策

本规范提出了一种新的特权机制,该机制基于 python 函数调用而不是命令行。目的是允许更多的代码进入特权部分 - 足够多,以便我们现在拥有足够的“上下文”来做出更好的安全决策。例如,从“运行 chown”转变为“接管 VM 输出文件的所有权”。

设计优先级,大致按重要性排序

  1. 安全性 * 尽可能避免 root * 安全接口应易于审计

  2. 易于开发者使用 * 只需添加一个带有装饰器的函数

  3. 性能 * 允许使用库而不是解析命令行工具的输出

rootwrap-daemon 类似,privsep 运行两个进程 - 一个具有权限,一个没有权限。特权进程尽可能地最小化,并且被编写为假设它可能受到非特权进程的攻击。

为了限制潜在利用的影响,本规范建议特权进程支持使用 Linux capabilities,以便该进程可以放弃广泛的 root (uid=0) 超能力,但保留有限的子集。有关概述,请参阅 capabilities(7) manpage。例如,neutron 代理可以配置为以非 root 用户身份使用 privsep,但具有 CAP_NET_ADMIN - 这允许更改几乎所有内核网络选项,但被破坏的进程无法读取 /etc/shadow 或加载任意内核模块。

使用 capabilities 的设计限制是特权进程仅限于这些 capabilities。例如:Neutron 的大部分只需要 CAP_NET_ADMIN 和 CAP_SYS_ADMIN(用于网络命名空间),但有些操作需要额外的权限。将此推到极端,最终特权进程会累积所有必需的 capabilities,并有效地再次成为全能的 root。为了对抗这种情况,privsep 允许一个多样化的服务实例化多个 privsep 守护进程,每个守护进程都有自己的一组权限和特权代码。

rootwrap-daemon 不同,本规范建议特权进程共享命运与主(非特权)进程。具体来说:特权进程应在非特权进程退出时退出,并且一旦启动,如果它退出,则不应尝试重新启动特权进程。如果特权进程由于某种原因退出,则可能是由于错误造成的,并且可能当前正在受到攻击 - 重新启动该进程会给攻击者提供另一个机会。如果特权进程退出,则非特权进程将无法执行许多功能,并且需要由管理员重新启动 - 这基本上类似于未捕获的异常破坏关键工作线程并留下不一致状态。

特权运行时环境

设置完成后,有两个不同的进程通过通信通道连接:原始进程没有特殊权限,以及以 root 身份运行或具有额外 Linux capabilities 的特权进程。

在特权进程中运行的项目提供的 python 代码将使用

  • 受信任的 oslo.config 环境。

  • 受信任的 python 模块搜索路径。

  • uid/gid 设置为配置的值(默认值:root)。

  • Linux capabilities 限制为配置的集合(默认值:项目提供的)。

  • stdinstdout 已关闭并重新打开到 /dev/null

  • oslo.log 配置为将日志记录到 stderr。非特权代码预计会将此代理到正确的最终位置。

  • 已打开与非特权调用者的通信通道。

受信任的 python 模块路径和 oslo.config 环境假定必须由最初授予提升权限并执行 python 解释器的内容提供(例如:systemd 环境、sudoers 配置等)。根据找到的配置,特权启动代码将配置其余部分,并在任何步骤失败时中止。

与特权进程通信

通信通道必须安全。特别是,python “pickle” 和许多其他序列化库不合适,因为它们包含允许在反序列化期间执行意外代码的便利功能。为了简单起见,本规范建议使用 json 并将函数参数/返回值限制为基本的 JSON 数据类型(32 位整数、32 位浮点数、unicode 字符串、布尔值、数组、字典),以及添加一个字节字符串类型。在返回方向(特权到非特权)中,还将支持捕获和重新引发大多数异常对象(假设该类可以在非特权端找到,并且使用常见的 .args 约定)。

底层通信通道不得远程暴露 - Unix sockets 或 pipes 是显而易见的选择。

请注意,通信通道仅在 privsep 的特权和非特权部分之间。特定的序列化和通信选择是实现细节,可以随着时间的推移而更改,而不会影响兼容性。

当前原型提供几种产生相同结果的替代方案:通过本地通信通道连接的两个进程。

如果第一个 privsep 客户端存根函数没有调用特定的“start”方法,则默认使用第二个选项(sudo/rootwrap)。我们可能希望随着 OpenStack 安全部署故事的演变重新审视这些选择。

  1. 基本 socketpair()fork()

    这只是创建一对匿名的连接 Unix sockets,然后 fork 新的特权进程。假设原始进程至少以所需的权限启动(可能来自 systemd 之类的东西),并且此“start”函数在进程启动的早期调用 - 在常规非特权进程放弃所有权限之前。

    这旨在镜像 Unix 守护进程的“正常”工作方式,并且在任何时候都不使用 sudo。它需要在 main() 中插入一个额外的调用并更改初始进程环境,因此提出了最困难的迁移。

  2. 使用 sudorootwrap 和 Unix socket

    这旨在与 sudorootwraprootwrap-daemon 一起使用。这很复杂,因为 sudo 会关闭所有打开的文件描述符,除了 stdin/stdout/stderr,并且 rootwrap-daemon 不允许长时间运行的命令,也不允许通过 stdin/stdout 流式传输数据。

    此方法在非特权端打开一个新的 Unix socket,并通过 rootwrap(或 sudo)执行一个辅助命令,并将 Unix socket 的路径作为参数。辅助命令(现在以 root 权限运行)连接回此 socket,然后 fork 并退出,允许 rootwrap-daemon(如果使用)看到及时的进程退出。非特权进程接受其侦听 socket [#unpriv_socket] 的第一个连接,并继续。

    请注意(与 rootwrap-daemon 不同),连接是从特权端到非特权端建立的。在任何时候,特权进程都不会暴露一个访问点,其他进程可以尝试连接到它。简单地接受非特权 socket 上的第一个连接是安全的,因为文件系统权限只允许相同的 uid 或 root - 并且以相同的 uid 运行的进程已经受信任通过 sudo/rootwrap 启动自己的特权守护进程,因此这不会授予额外的权限。

    这种方法是默认方法,因为它不需要更改现有的 OpenStack 部署(除了更新的 rootwrap 过滤器)。

无论使用哪种方法创建通信通道,特权进程都会继续处理请求,直到通信通道关闭。此时,特权进程退出。由于这是一个本地 IPC 通道,因此不应有“合法”的通道下降原因,并且双方都不尝试重新创建连接。

开发者的视角

从 python 开发者的角度来看,目标是尽可能简单地添加一个常规 python 函数。本规范提出以下 API(以 Neutron 为例,最终函数名称可能会更改)

# In (eg) neutron_privileged/foo.py
import os
from neutron_privileged import privsep

@privsep.entrypoint
def example_task_that_requires_privileges():
    return os.getuid()

要使用此函数,非特权代码只需调用它即可。

from neutron_privileged import foo

def bar():
    uid = foo.example_task_that_requires_privileges()
    print "privsep is running as %s" % uid

魔术在于 neutron_privileged/__init__.py。此文件需要在导入时调用一些 oslo.privsep 代码来创建用于特权入口点的装饰器

# In neutron_privileged/__init__.py (once per project)
from oslo_privsep import capabilities as c
from oslo_privsep import priv_context

CFG_SECTION = 'privsep'  # important with multiple privsep daemons
DEFAULT_CAPS = [c.CAP_SYS_ADMIN, c.CAP_NET_ADMIN]  # eg
privsep = priv_context.PrivContext(
    __name__, cfg_section=CFG_SECTION,
    default_capabilities=DEFAULT_CAPS,
)

装饰器内部将像这样包装每个函数(伪代码)

# Resulting pseudo code, after decorator is applied
def example_function(*args, **kwargs):
    if in_unprivileged_mode:
        privsep_channel.send((CALL, 'example_function', args, kwargs))
        result = privsep_channel.read()
        if result.raised_exception():
            raise result.exc_class(result.exc_args)
        return result.value
    else:
        # privileged_mode
        return _real_example_function(*args, **kwargs)

非特权“客户端存根”函数将序列化任何参数,与 privsep 进程通信,并反序列化返回值。请注意(出于选择)仅接受基本的“json-ish”python 类型作为参数或返回值 - 没有用户定义的对象。如果特权代码引发异常,它将被捕获并在非特权端重新引发(使用 .args 属性)。

如前所述,特权守护进程将在调用第一个存根时启动,除非守护进程已经启动。启动后,将重用相同的通道,并且特权守护进程将持续存在,直到通道关闭(可能在主进程退出时)。

未用 privsep 装饰器标记的函数在 privsep 通道上不可用。导入的模块否则可用,因此模块级常量等可用如预期。请注意,非特权进程是一个单独的进程,因此修改导入的全局变量不会影响特权代码。

即使在非特权进程中,也可以将装饰器设置为“特权模式”,在这种情况下,它会将调用传递给真实的包装函数。该函数将在没有特殊权限的情况下运行,并可能失败。这很少有用的地方,除了具有模拟环境的单元测试之外。

导入 foo.bar.baz 涉及加载(因此信任)foo/__init__.pyfoo/bar/__init__.py。因此,本规范建议项目在其常规 git 存储库中创建一个新的顶级 python 包来保存打算通过 privsep 使用的模块(例如,创建 neutron.git/neutron_privileged/... 如上面的示例所示),尽管这在技术上不是必需的。

调试

迁移到基于函数的原始操作必然会导致特权侧的 Python 代码比使用 rootwrap 更复杂,因此能够轻松调试这段 Python 代码至关重要。原型代码包含对 Neutron 测试套件的充分修改,可以正确地使测试失败并捕获从特权代码触发的任何堆栈跟踪,并在 unittest 输出中按预期显示它们。在迁移到 privsep 的项目中,合并类似的修改将是一个重要部分。

交互式调试(通过 pdb)特权进程,特别是特权代码中使用 pdb.set_trace(),需要 pdb 具有可用于交互的合适通道。由于 stdin 和 stdout 在特权进程中已关闭,因此将提供一个辅助函数,以在新的 Unix 套接字上启动 pdb。出于显而易见的原因,生产部署中的调试侧通道是不安全的,并且需要开发人员在使用 pdb 之前修补适当的调用。

代码覆盖率

coverage.py 支持跨子进程收集覆盖率统计信息 [1]。为此,特权进程需要尽快调用 coverage.process_startup()(例如,从 main()),如果设置了 COVERAGE_PROCESS_START 环境变量,则启用覆盖率功能。如果通过 sudo 调用特权进程,则必须显式配置 sudo 策略以允许传播此环境变量。

启用此功能的具体 tox 环境细节将在后续更改中确定。看起来稍加努力就可以实现,并且需要初始执行环境的显式支持,因此不会影响常规部署的安全性。

性能分析

Python profilecProfile 模块旨在收集特定函数调用的统计信息,并且不支持跨进程边界收集统计信息。在非特权特权进程中进行性能分析将按预期工作,但尝试跨特权边界进行性能分析只会收集通信通道本地侧的统计信息。

由于可以对每个进程进行性能分析,因此有可能在未来构建统一的性能分析结果。但是,这被认为超出此规范的范围。

操作员视角

配置文件需要一个额外的部分

[privsep]
user = novapriv
group = novapriv
capabilities = CAP_SYS_ADMIN, CAP_NET_ADMIN

这是特权进程应该使用的 uid、gid 和 capabilities。默认情况下,特权进程继续使用进程最初启动时拥有的 uid/gid(可能为 root)。capabilities 的默认值由实例化项目代码提供,可能需要根据所使用的特定配置选项/模块进行覆盖。

像 nova 这样多样化的服务可能会使用多个单独的特权守护进程,并且每个守护进程将拥有自己的命名配置部分,具有不同的默认 capabilities。

在最偏执的设置中,每个特权进程应该作为专用的非 root 用户运行,与非特权用户(以及任何其他 privsep 进程)分开。特权用户和非特权用户都不应该能够写入服务配置文件或 Python 加载路径中的任何位置。

替代方案与历史

rootwrap 的演变很简单

  • “我们需要以 root 身份运行一些命令” -> 开始使用 sudo

  • “太多命令,sudoers 变得笨重” -> 引入 rootwrap

  • “rootwrap 每次重新调用都很昂贵” -> rootwrap-daemon

以所需的权限运行整个 Python 进程

如果非特权<->特权边界包含任何有效地将 root 权限授予调用者的漏洞,那么拥有这种分离的好处很小,我们不妨享受代码简单性/性能优势,只需在统一的进程中运行所有内容即可。

一种变体是在进程内可以恢复的方式下降低“有效”权限(例如,seteuid(2))。这可以防止对权限的“意外”滥用,但不会针对控制该进程的恶意攻击者提供额外的安全性。

我认为这种观点有很多道理。但是,考虑到 OpenStack VM 作为安全目标而日益普及和重要性,我认为我们仍然需要继续努力改进这方面的工作。本规范试图建立一个有效的安全边界并提供真正的额外防御层,同时几乎像在进程内函数调用一样易于使用。

使用 multiprocessing

Python multiprocessing 库已经具有客户端进程通过 IPC 通道与工作进程通信。我们可以将其作为核心通信机制重用(实际上 rootwrap-daemon 就像这样使用 multiprocessing)。

这是合理的,也许我们最终会选择这样做。我最初没有使用 multiprocessing 是因为它被设计为方便的单用户工作池,而不是特权分离边界。如 rootwrap-daemon 所示,需要解决序列化和几个“魔术代理”选择问题才能提供安全性,而且我认为这些解决方法导致了在您不希望出现这种情况的地方出现脆弱且难以审计的代码。

幸运的是,我们只需要一组狭窄的功能,并且从头开始重写核心通信代码是直接的。结果是在安全入口点处代码明显更少。

保持 stdin/stdout 不变

特别是,这将允许 pdb.set_trace() “直接工作”而无需进一步操作(假设它仅从单个线程调用)。虽然没有具体问题,但保留 stdin 会留下一个额外的潜在攻击向量进入特权上下文。由于 pdb 已经对使用不同的通道进行交互提供合理的支持,因此选择关闭这些文件描述符(并在 /dev/null 上重新打开)似乎是一种可接受的安全/便利权衡。

实现

作者

主要作者

gus

其他贡献者

里程碑

  1. 将现有的原型代码移动到 oslo.privsep

  2. 引入 privsep 版本的 rootwrap 代码的大部分内容

  3. 更新提及 rootwrap 配置/过滤器的文档

  4. 逐步淘汰替代 rootwrap 代码

工作项

一个工作原型已经存在于 https://review.openstack.org/#/c/155631/,尽管所提出的 API 已经随着本规范的发展而演变。

剩余的大部分工作涉及将核心机制移动到新的 oslo.privsep 项目,并将 Neutron 原型更改重新基于该通用核心。从 Neutron 的经验来看,将新项目迁移到此机制的最大部分将是集成到 unittest 模拟环境中,并且会因项目而异。

在当前的原型实现中,通信通道一次只能有一个未完成的操作,并且特权进程是单线程的。这些限制将在将代码移动到 oslo 时通过添加唯一的消息 ID 并在特权侧使用小的线程工作池来解决。

一旦大部分代码存在于 oslo.privsep 中,我们应该鼓励 OpenStack 安全组和其他人进行广泛审查。

迁移

此机制可以与 rootwrap 共存而不会产生干扰。预期的迁移过程是创建需要权限的例程的替代 privsep 版本,并将调用者迁移到新的实现。剩余的“困难”案例可能需要不寻常的权限或真正的 uid=0,可以继续使用 sudo/rootwrap,并且本规范没有建议我们应该完全迁移离开 rootwrap。

参考资料

修订历史

修订版

发布名称

描述

引入

注意

本作品采用知识共享署名 3.0 非移植许可协议授权。 http://creativecommons.org/licenses/by/3.0/legalcode