为什么不选择 Object Capability 语言?
安全领域概览
软件行业的供应链攻击正变得越来越普遍。我们编写的应用程序具有非常长且深入的依赖链,任何被对手破坏的组件都可以执行应用程序本身可以执行的任何操作。由于许多广泛使用的组件都是由在容易被黑客攻击的笔记本电脑上伪匿名编写的,所以我们有点自找麻烦。 令人惊讶的是,它花了这么长时间。
也许解决方案在于具有 capabilities 的沙箱:封装执行某些操作的能力的不透明对象。如果你的程序没有被赋予 capability,那么你将无法执行它保护的操作。把它们想象成神奇的钥匙:你无法伪造它们,但你可以克隆它们,并且(在某些系统中)在执行时降低克隆的能力。
我们已经经常使用 capabilities:句柄、文件描述符和 JWT tokens 都是不同名称下的示例,但我们不在单个程序中使用它们。改变这一点是一个流行的想法,尤其是在 Hacker News 上。它最初由 Morris 在 1970 年代提出,几天前它又出现了:
要使这一切最终成为现实需要什么? 我们真的需要_另一个_全新的编程和库生态系统吗?
在这篇文章中,我想向你展示如果你想走这条路将面临的挑战。这并不是为了阻止任何人,只是为了绘制一张你即将进入的领域的地图,并解释为什么它目前是空无一人的。在进行的过程中,我将介绍两种截然不同的概念:Java 中 capabilities 的历史,以及 Chrome 的 Mojo object-capability 系统。
路上的分叉
这里有一些你必须解决的问题才能对库进行沙箱化:
- 你的威胁模型是什么?
- 你如何阻止组件篡改彼此的内存?
- 你是否将你的计划与其他不相关的要求(例如,跨语言互操作)交织在一起?
- 如果有的话,你可以重用人类现有的多少代码库?
让我们从威胁模型开始,或者更直白地说,你试图解决的_确切_问题是什么? 沙箱设计者经常对他们需要走多远存在分歧。你是否关心资源耗尽或 DoS 攻击? 例如,如果一个库可以调用 exit(0)
并终止你的进程,或者故意导致它段错误,这可以接受吗? 你关心 Spectre 攻击吗? 也许吧!
每次你使你的威胁模型更强大时,它就变得更难以使用。 在这里该怎么做非常不清楚。
无论你选择什么模型,库都希望与使用它们的代码共享内存。 因此,object capability 系统不能简单地放入任何旧语言中! 必须仔细设计它以提供 Java 团队所说的完整性——一段代码不得覆盖另一段代码所依赖的状态。 保证这个属性原来很难,事实上,很少有语言甚至尝试提供它。 C/C++ 不提供。 像 Python 或 Ruby 这样的动态脚本语言也不提供,因为你实际上可以通过在标准库中进行 monkey-patching 来动态更改语言规则。 JavaScript
也没有真正支持它:Google 赞助了一个名为 Caja 的研究项目一段时间,该项目能够将 JavaScript
重写为一种据称具有完整性的形式,但是 它有很多漏洞,他们最终放弃了。
一种主流的 Object Capability 语言
通过阅读 2004 年描述一种名为 Joe-E 的语言的论文,可以最好地理解这些要求。
Joe-E 语言是 Java 的一个子集,旨在支持共享地址空间内的纯 capability 编程。 Java 已经具备了基础知识:没有指针运算,你不能 monkey patch,语言规则是固定的,并且对象具有强制的可见性规则。 通过查看 Joe-E 从 Java 中移除了什么,我们可以看到还需要什么。
你必须删除的一些东西已经很明显了:
- 可以写入私有字段或调用私有方法的反射。
- 标准库中授予环境权限的全局方法。
- 本地方法。
还有一些不太明显:
- 任何可变全局变量都是一个问题,因为它可能允许一个组件违反另一个组件所持有的期望。 Joe-E 禁止可变全局状态。
- 异常可能会泄漏 capabilities 并且受到限制。
- 终结器被删除。
finally
关键字被删除。
这与常规 Java 相差甚远,这就是为什么 Joe-E 被认为是子集语言的原因。 你不能只是采用现有的 Java 库并从 Joe-E 代码中使用它。 到目前为止,最大的困难是他们所谓的“驯服”标准库,这意味着找到所有授予环境权限的地方并阻止它们。
值得注意的是,尽管 Joe-E 是一个研究项目,但其中一些更改正在被 Java 采用,用于使用模块系统的应用程序:
- 你很快将被要求指定哪些模块可以使用本地代码。
- 你已经被要求指定哪些模块可以使用“深度”反射。
- 终结器已经被删除。
就其本身而言,这对于纯 capability 语言来说是不够的,并且移除 SecurityManager
意味着没有官方方法来“驯服”标准库,因此这些更改本身并没有太大帮助。 但这是一个有趣的发展方向。
上帝对象
如果你仔细思考纯 capability 设计的后果,一些可用性问题就会显现出来。
首先是,如果你希望整个应用程序以 object-capability 风格编写,那么它的 main()
方法必须被赋予一个“上帝对象”,暴露应用程序开始时_所有_的环境权限。 然后你必须编写代理对象,限制对该上帝对象的访问,同时实现标准接口。 没有语言在其标准库中具有这样的对象或这样的接口,事实上,“上帝对象”被视为违反了良好的面向对象设计。
在没有环境权限的情况下,库或其依赖项_可能_需要使用的每个资源都必须由调用者传入。 因此,更改需要的权限意味着对 API 本身进行向后不兼容的更改。
这是一个问题,因为库的许多用户不会关心沙箱化。 他们可能有充分的理由不关心——也许他们信任你是因为你为谁工作,或者他们的数据不敏感,或者他们使用其他一些安全机制,或者他们只是想尽快完成他们的工作。 如果你不断阻止他们的代码编译,并进行对他们没有好处的 API 更改,他们会感到不安并切换到竞争库。 你需要请求新权限的频率将很大程度上取决于你的语言和标准库的设计,所以要小心。 例如,读取它附带的新数据文件的库不应要求用户提供新权限(Java 库是可以包含数据文件的 zip 文件,因此在那里可以处理)。
另一个问题是可配置性。 库读取全局状态是很常见的,例如,打开日志记录的环境变量。 这在 capability 系统中是不允许的:代码的调用者必须为其读取环境变量和配置文件。
堆栈之神
上帝对象问题是 Java SecurityManager
架构不是纯 capability 系统的原因。 它_启用_了 capabilities,但它没有要求它们。 在 SecurityManager
设计中,所有代码都以环境权限开始。 每个方法都与一个模块相关联,并且可以从模块中删除环境权限,从而使它们无法执行该操作,或者只能在被赋予 object capability 的情况下才能执行该操作。
它是这样工作的:当一个模块调用到另一个正在保护一些可能敏感的操作(例如,标准库)的模块时,保护者会执行堆栈遍历以查看所有调用它的代码,从而相交它们的权限。 这种设计至少有三个优点:
- 不关心沙箱化的人可以忽略它。
- 所需的权限可以用一个简单的配置文件而不是代码或文档来描述。 这使得发布罐装配置文件变得容易,这些配置文件向开发人员展示了库在不同情况下可能需要的权限,然后让他们自定义它以最小化权限。
- 你可以通过在具有安全接口的特权模块中定义类,然后“断言特权”来创建 object capabilities,这会截断该帧处的堆栈遍历。
该设计优雅且理论上合理,但受到实际可用性问题和错误的困扰。 一个问题是程序实际拥有的令人困惑的环境权限数量。 快速浏览 NetPermission 类(众多类之一)会显示这样的内容:
你有没有想过 HTTP TRACE 方法? 可能没有,但 Java 沙箱设计者想到了,因为执行 TRACE
请求可能有害,因此需要权限。
大多数语言允许你执行 HTTP 请求。 在只有 object capabilities 的系统中,你如何在允许对域执行 GET
的同时限制对 TRACE
的访问? 当库的作者和用户分开时,这变得特别棘手。 用户必须为库提供一个 HTTP 客户端,并且该客户端必须是可配置的或可代理的,以便将其转换为 object capability,并且用户必须准确地理解库需要什么。 类型系统没有帮助:你无法表达像“允许访问 example.com 但不能访问其他任何地方的 HTTP 客户端”这样的类型(好的,可能在 TypeScript 中可以,但是 TypeScript 没有强制执行其规则的 VM,因此这对安全来说毫无用处)。
SecurityManager
设计解决了所有这些问题。 如果一个库需要访问一个特定的网站,它可以附带一个配置文件,该配置文件在 NetPermission
中列出了该域,用户可以在使用该库之前快速审核它,该库会在内部请求一个 HTTP 客户端,并且一切正常。 如果不需要沙箱化的开销,则可以通过 JIT 编译器消除它。
那么,为什么 SecurityManager
会消亡呢,就在供应链攻击开始成为现实的时候? 你可以阅读 官方理由 或我对它的总结:
- Java 有很多功能。
- 维护所有这些功能的权限检查非常昂贵。
- 很难避免漏洞。
- 没有人足够关心供应链攻击来使用沙箱化。
- 即使是少数使用它的人也在错误地使用它。
嗯,一个昂贵的、经常崩溃且没人使用的东西显然是节省成本的目标…… 并且就在那里。 即使与纯 capability 系统相比,它具有可用性优势,但对于开发人员来说,进程内沙箱化仍然需要付出太多的努力。 供应链攻击仍然足够罕见,以至于防御措施不在大多数积压工作的首位,并且可能在一段时间内不会出现。
Spectre
还有另一个原因放弃了 SecurityManager
。 推测执行攻击允许任何代码读取整个地址空间,而无需任何权限,即使在设计用于阻止这种情况的语言中也是如此。 像降低计时器分辨率这样的 hacks 有所帮助,但并没有完全修复它,并且付出了巨大的代价。 此时,大多数软件沙箱的设计者绝望地大喊,然后放弃了,并宣称只有硬件强制执行的地址空间才能正确地约束软件组件。
我个人认为这种信念并不完全正确。 如果你可以将其泄露到某个地方,或者你可以窃取允许你升级权限的凭据,那么读取数据仅在现实世界的攻击中才有用。 许多软件安全分析在读取任何字节时停止并宣布胜利,而没有停下来思考这些字节接下来会去哪里。 这并非完全不合理,因为在 Web 上下文中,泄露数据的能力是一种环境权限,但如果我们谈论的是沙箱化库,那么情况就大不相同了。 大多数库不需要写入 IO 的权限,因此即使它们以某种方式进行了未被检测到的 Spectre
攻击(很难),它们也无法轻易地将读取的数据发送回对手。
Chromey 才是王道!
仍然。硬件隔离怎么样? 文件描述符是内核提供的一种 capability,但它是一种相当奇怪且不灵活的类型。 它们不是 object capabilities 的一个很好的例子。
Windows 做得稍微好一些,它的内核具有“对象管理器”的概念和更通用的句柄概念。
但最好的例子是 Chrome。
正在运行的 Chrome 实际上是一个通过名为 Mojo 的协议进行通信的微服务本地网络。 Mojo 非常有趣。 它允许你将对象绑定到连接进程的“消息管道”,这会将它们转换为 object capabilities。 管道是不可伪造的,可以通过其他管道发送,从而允许一个进程中的对象具有一组特定的内核权限,这些权限可以暴露给具有较少权限的其他进程。 正在运行的 Chrome 是一个在服务之间指向对象的整个图。 Mojo 抽象了对象位置(服务可以在单独的进程中或在进程内运行而无需代码更改),并且还提供了诸如共享内存之类的优化。 然后,Chrome 沙箱将每个进程的环境权限降低到其任务所需的最小值。 对象通过一个简单的注释移入沙箱。
虽然这听起来很容易,但至少有四个陷阱。 首先,考虑到内核沙箱对于 Web 的重要性以及浏览器使用它的时间有多长,你可能会认为它背后有一套成熟的强大 API。 但你错了。 没有一个操作系统具有所有文档记录、稳定且有用的沙箱 API。 Linux、macOS 和 Windows 使用的方法甚至都不太相似。 那么 Chrome 是如何做到的呢? 答案是大量对 OS 内部 API 的逆向工程,愿意对不兼容的 OS 升级做出快速反应,太大而不能失败,最后,大量非常特定于平台的代码,没有太多抽象。
第二个陷阱是 Mojo 不容易重用 - 没有你可以下载并在自己的项目中使用的独立版本。 尽管如此,如果你想要一个可以阻止推测攻击的 object capability 系统,那么 Chrome 代码库是一个不错的起点。 它还附带一个大型代码库,旨在在没有环境权限的情况下运行,但所有代码都用 C++ 编写,并且没有稳定的 API。
第三个陷阱是 Mojo 实际上是一个 RPC 系统。 服务可以共享内存,但只能在大块中,并且只能非常小心地共享。 它不提供任何特定的支持来管理跨进程的对象生命周期。 必须仔细设计内存管理协议,以避免跨进程资源泄漏或引用计数周期。 在设计代码时,存在安全边界这一事实需要持续关注。
最后一个陷阱是上下文切换成本很高,尤其是在 Windows 上。 我们可以降低开销吗? 支持内存保护密钥 (MPK) 的 CPU 可以通过足够的工作,比典型的上下文切换更快地切换内存保护上下文。 这需要仔细验证所有本地代码(包括 JIT 编译的代码),以确保它不包含对 MPK 寄存器的写入,但这是可以做到的。
结论
Object capability 系统承诺提供沙箱化库,并在对抗供应链攻击的战争中取得重大进展,但它们很难构建。 研究制作主流纯 object capability 语言的最佳例子是 Joe-E,这是 Java 的一个早已过时的子集。 现实世界 object capability 系统的最佳例子是 Chrome。
如何使库沙箱化变得足够容易,以至于人们实际采用它仍然是一个悬而未决的问题。 我有一些想法,但没有一个经过充分的充实以至于今天可以实施。 也许我会在另一天写一下。 在此之前,我希望这次对该领域的快速游览是有用的!