作为无服务器云提供商,我们在全球分布的基础设施上运行您的代码。能够在我们的网络上运行客户代码意味着任何人都可以充分利用我们的全球覆盖和低延迟优势。Workers 不仅高效,更致力于为用户简化操作。简而言之:您编写代码,我们处理其余事务。
“处理其余事务”的重要一环,就是尽可能保障 Workers 的安全性。我们此前已撰文介绍过自身的安全架构。确保 Workers 的安全性是一个颇具挑战性的问题,因为 Workers 的核心设计理念就是在我们的硬件上运行第三方代码。这是信息安全领域最为棘手的难题之一:攻击者在精心策划攻击时,能够充分利用目标系统上运行的编程语言的全部能力。
正因如此,我们始终致力于持续更新和优化 Workers 运行时环境,以便充分运用硬件和软件领域的最新改进。本文将为大家分享我们在保障 Workers 安全性方面所开展的部分最新工作。
首先了解一下背景知识:Workers 是基于 V8 JavaScript 运行时构建的,而 V8 最初是为 Chrome 等基于 Chromium 的浏览器开发的。这让我们有了一个良好的开端,因为 V8 诞生于充满对抗性的环境之中,始终面临着严峻的攻击和审查。和 Workers 一样,Chromium 的设计目标也是安全地运行具有对抗性的代码。正因如此,V8 会持续接受最先进的模糊测试工具和净化器的检验,多年来,通过引入 Oilpan/cppgc 等新技术以及改进静态分析等方法,V8 的安全性得到了不断增强。
不过,我们使用 V8 的方式略有不同。因此,我们将在本文中介绍我们如何修改 V8 以提高我们用例中的安全性。
Intel、AMD 和 ARM 的现代 CPU 都支持内存保护密钥,有时也称为 PKU,即用户空间保护密钥。这是一项非常强大的安全功能,可以增强虚拟内存和内存保护的能力。
传统上,您电脑或手机中 CPU 的内存保护功能主要用于保护内核以及以及保护不同进程之间的互通。在同一个进程内部,所有线程都能访问相同的内存区域。而内存保护密钥技术则让我们能够阻止特定线程访问其不应访问的内存区域。
V8 已经为即时编译器应用了内存保护密钥。对于以 JavaScript 为代表的编程语言,其即时编译器会在代码运行时动态生成经过优化、高度适配的代码版本。通常情况下,编译器会在独立的线程中运行,需要具备向代码区域写入数据的权限,以便部署优化后的代码。不过,编译器线程本身不需要能够执行这些代码。与之相对,常规的执行线程则需要能够运行这些代码,但不得对其进行修改。内存保护密钥为每个线程提供了其所需的权限,但仅此而已。Chromium 项目中的 V8 团队并未停下技术迭代的脚步,他们在此详细阐述了关于内存保护密钥的未来规划。
在 Workers 平台中,我们的安全需求与 Chromium 浏览器存在显著差异。Workers 的安全架构采用 V8 隔离实例来分隔运行在我们服务器上的不同脚本。(此外,我们还部署了额外的缓解措施,以增强系统对 Spectre 攻击的防御能力)。若 V8 引擎按预期运行,这些防护应该足够可靠,但我们更坚信纵深防御理念:通过多层相互叠加的安全控制机制来构建防护体系。
正因如此,我们对 V8 进行了内部修改,采用内存保护密钥实现各隔离实例之间的安全隔离。现代 x64 架构的 CPU 最多支持 15 个不同的密钥,其中部分密钥已用于 V8 中的其他功能,因此我们实际可用的密钥约有 12 个。我们为每个隔离实例分配一个随机密钥,该密钥用于保护其 V8 堆数据——即脚本运行时创建的 JavaScript 对象所处的内存区域。这意味着,以往可能导致攻击者跨隔离实例窃取数据的漏洞,在 92% 的情况下会直接触发硬件级防护机制(按 12 个密钥计算,92% 由 11/12 计算得出)。
插图展示了一个攻击者试图从其他隔离区读取数据的场景。大多数情况下,这种越界访问会被不匹配的内存保护密钥检测到——系统会立即终止攻击者的脚本并通知我们,以便我们及时调查和修复问题。图中用红色箭头表示攻击者“走运”的特殊情况:当攻击者碰巧遇到了具有相同内存保护密钥的隔离区(表现为图中隔离区颜色相同)时,攻击就可能得逞。
不过,我们仍有提升空间,可将 92% 的保护率进一步优化。在本文后续部分,我们将阐述如何针对某一常见场景将保护率提升至 100%。但在此之前,先来看看我们正在利用的 V8 软件加固特性。
过去几年间,V8 新增了另一个纵深防御功能:V8 沙箱(注意不要与 Workers 自上线起便采用的二级沙箱混淆)。V8 沙箱作为一项历经数年的技术攻坚项目,其防护能力已逐步趋于成熟。该项目的诞生源于一个关键发现:V8 引擎的诸多安全漏洞往往始于对堆内存中对象的非法篡改。攻击者以这种篡改作为跳板,进而渗透至进程的其他区域,最终可能实现权限提升,获取对受害者浏览器乃至整个系统的更高访问权限。
V8 沙箱项目是一项雄心勃勃的软件安全防护措施,旨在阻断攻击升级路径:让攻击者无法从 V8 堆内存的破坏行为进一步危害整个进程。这意味着需要采取多项措施,其中就包括彻底清除堆内存中的所有指针。但在此之前,让我们尽可能用通俗易懂的语言解释什么是内存破坏攻击。
内存破坏攻击会诱使程序错误地使用自身内存。计算机内存本质上只是一系列整数的存储空间,每个整数都存放在特定位置。这些位置都有各自的地址(地址本身也只是一个数字)。程序会以不同方式解读这些位置中的数据,比如将其视为文本、像素,或是指针。指针实际上是用于标识其他内存位置的地址,因此它们就像某种箭头,指向其他数据片段。
下面是一个具体示例,该示例涉及缓冲区溢出攻击。这是一种历史上较为常见且相对容易理解的攻击形式:假设某个程序包含一个小缓冲区(比如一个 16 字符的文本框),紧随其后存放着一个 8 字节指针,该指针指向某些普通数据。攻击者可能会向该程序发送一个 24 字符的字符串,从而引发“缓冲区溢出”。由于程序存在漏洞,前 16 个字符会正常填入目标缓冲区,但剩余的 8 个字符则会溢出并覆盖相邻的指针。
请参见下文,了解现在如何阻止此类攻击。
此时,该指针已被重定向至攻击者指定的敏感数据区域,而非其原本应当访问的常规数据。当程序试图调用它自以为正常的指针时,实际上访问的却是攻击者精心挑选的敏感数据。
此类攻击往往分阶段实施:首先制造一个小规模的混乱(例如缓冲区溢出),继而利用该混乱引发更大问题,最终获取本不应属于攻击者的数据访问权限或系统能力。攻击者最终可能通过这种误导手段,窃取敏感信息或在程序中植入恶意数据——而这些恶意数据将被程序误认为合法内容。
以上是对利用缓冲区溢出实施内存破坏攻击的简要抽象说明——这只是众多攻击手段中较为简单的一种。若想了解更为详细且时效性更强的实际案例,可参阅 Google 发布的说明文档,或这份 V8 漏洞分析报告。
许多攻击都是基于篡改指针实施的,因此理想情况下我们会彻底移除程序内存中的所有指针。但由于面向对象语言的堆内存中充斥着大量指针,表面上看这似乎是一项不可能完成的任务——不过这项技术突破得益于早期的发展成果。自 2020 年起,V8 引擎就开始提供通过使用压缩指针来节省内存的选项。这意味着在 64 位系统上,堆内存仅使用 32 位的相对偏移量(相对于基准地址)。这将堆内存总量限制在最大 4GiB,这一限制对于浏览器环境来说是可以接受的,对于运行在 Cloudflare Workers 的 V8 隔离实例中的单个脚本来说同样适用。
一个包含多个字段的人工构造对象,用于展示压缩堆与未压缩堆在布局上的差异。图中的每个存储单元均为 64 位宽度。
如果整个堆都位于一个连续的 4 GiB 内存区域内,那么所有指针的高 32 位都将保持相同值,因此我们无需在每个对象的每个指针字段中重复存储这些高位。从示意图中可以看到,所有对象指针都以 0x12345678 开头,因此这部分高位地址信息是冗余的,完全不需要存储。这意味着对象指针字段和整数字段可以从 64 位缩减至 32 位。
我们仍然需要为某些字段(例如双精度浮点数,以及缓冲区的沙箱偏移量)保留 64 位字段,这些字段通常是脚本用于输入和输出数据的。有关详细信息,请参阅下文。
未压缩堆中的整数存储在 64 位字段的高 32 位中。压缩堆则使用 32 位字段的高 31 位。两种情况下,最低位均设置为 0,以指示整数(而不是指针或偏移量)。
从概念上来说,我们有两种基于 4 GiB 整数倍基地址的压缩与解压缩方法:
// Decompress a 32 bit offset to a 64 bit pointer by adding a base address.
void* Decompress(uint32_t offset) { return base + offset; }
// Compress a 64 bit pointer to a 32 bit offset by discarding the high bits.
uint32_t Compress(void* pointer) { return (intptr_t)pointer & 0xffffffff; }
这种指针压缩功能最初主要设计用于节省内存,可以作为沙箱的基础。
最大的 32 位无符号整数约为 40 亿,因此 Decompress() 函数无法生成任何超出 [base, base + 4 GiB] 范围的指针。您可以说指针被困在这个区域,因此它有时被称为指针隔离笼。V8 可以为指针笼保留 4 GiB 的虚拟地址空间,以便只有 V8 对象出现在这个范围内。通过消除所有来自这个范围的指针,并遵循一些其他严格的规则,V8 可以将攻击者造成的任何内存破坏控制在这个笼子里。即使攻击者破坏了笼子内的 32 位偏移量,它也仍然只是一个 32 位偏移量,只能用于创建仍然被困在指针笼子内的新指针。
之前提到的缓冲区溢出攻击不再奏效,因为在指针隔离笼内仅存有攻击者自身的数据。
为构建沙箱环境,我们以 4 GiB 的指针隔离笼为基础,再额外分配 4 GiB 用于缓冲区及其他数据结构,从而形成完整的 8 GiB 沙箱空间。这也解释了为何前文提到的缓冲区偏移量采用 33 位设计,使其能够访问沙箱后半段的缓冲区区域(在 Chromium 浏览器采用更大沙箱时为 40 位)。V8 引擎将这些缓冲区偏移量存储在高位 33 位中,并在使用前右移 31 位进行处理,以此防范攻击者对低位比特位的潜在篡改。
Cloudflare Workers 早已采用 V8 的压缩指针技术,但要充分发挥沙箱的完整效能,我们必须进行一些关键改进。此前在使用 V8 的沙箱配置时,一个进程内的所有隔离实例都必须属于同一个沙箱。这将导致所有 V8 堆内存的总容量被限制在 4 GiB 以下。然而对于我们的架构而言,这种限制远远不够,因为我们需要在同一时间为成千上万个脚本提供服务。
正因如此,我们委托 Igalia 团队为 V8 新增了隔离组功能。每个隔离组都拥有独立的沙箱,且可包含 1 个或多个隔离实例。基于这一改进,我们现已能够启用沙箱机制,一举消除了一整类潜在的安全隐患。尽管技术上允许在同一个沙箱中放置多个隔离实例,但目前我们仅在每个沙箱中放置单个隔离实例。
沙箱的布局。单个沙箱中可包含多个隔离实例,但所有堆内存页必须位于指针隔离笼(即沙箱的前 4 GiB 空间)。我们采用 32 位偏移量替代对象间的传统指针引用,而缓冲区则使用 33 位偏移量,因此它们可以到达整个沙箱,但不能到达沙箱外部。
虚拟内存并非无限,Linux 进程中会执行大量操作
不过,到这一步我们还没有完全解决问题。每个沙箱需要在进程的虚拟内存映射中预留 8 GiB 的空间,并且出于效率考虑,这个空间必须为 4 GiB 对齐。虽然它实际占用的物理内存要少得多,但沙箱机制为了实现其安全特性,需要这么大的虚拟地址空间。这就给我们带来了一个难题:因为在采用四级页表的 Linux 系统中,一个进程的虚拟地址空间“仅有”128 TiB(另外 128 TiB 被预留给了内核,用户空间无法使用)。
在 Cloudflare,我们希望尽可能高效地运行 Workers,以降低成本和价格,并提供慷慨的免费套餐。这意味着每台机器上都会运行大量隔离实例(每个沙箱一个),以至于很难将它们全部放入 128 TiB 的空间中。
基于这一认知,我们必须审慎规划沙箱在内存中的布局。但遗憾的是,Linux 系统调用 mmap 不允许我们直接指定内存分配的对齐方式——除非您能恰好猜到一个可用的空闲内存位置来发起请求。若要获取一个 4 GiB 对齐的 8 GiB 内存区域,我们必须申请 12 GiB 的空间,然后在该范围内找出必然存在的对齐 8 GiB 区域,并将未使用的(阴影标注的)边缘归还给操作系统:
如果我们允许 Linux 内核随机放置沙箱,最终就会形成如下带有间隙的布局。尤其是在系统运行一段时间后,沙箱之间可能会出现 8 GiB 和 4 GiB 两种大小的间隙。
遗憾的是,由于我们采用的 12 GiB 对齐技巧,甚至连 8 GiB 的内存间隙都无法加以利用。如果我们向操作系统申请 12 GiB 内存,它永远不会给我们提供一个类似上图中绿色和蓝色沙箱之间那样的 8 GiB 间隙。此外,Linux 进程的虚拟地址空间中还存在大量其他活动:malloc 实现可能需要在特定地址获取内存页,可执行文件和库通过 ASLR(地址空间布局随机化)被映射到随机位置,而且 V8 还会在沙箱之外进行内存分配。
最新一代的 x64 CPU 支持更大的地址空间,这同时解决了两个问题;而 Linux 内核能够利用额外的地址位,通过五级页表实现。不过,进程必须主动启用此功能,只需通过一次 mmap 调用,申请一个超出 47 位地址范围的地址即可。之所以需要主动启用,是因为某些程序无法处理如此高的地址。有趣的是,V8 就是其中之一。
在 V8 中修复这个问题并不难,但我们的服务器集群尚未全部升级到所需的硬件。因此,目前我们需要一个能够兼容现有硬件的解决方案。我们修改了 V8,使其能够获取大容量内存区域,然后使用 mprotect 系统调用 为沙箱创建紧密封装的 8 GiB 空间,从而绕过不灵活的 mmap API。
像这样控制沙盒的位置实际上给我们带来了安全方面的好处,但首先我们需要阐述一个特定的威胁模型。
在本威胁模型的分析框架下,我们假设攻击者拥有任意篡改沙箱内数据的能力。这在历史上是众多 V8 漏洞利用案例中的初始步骤。这样的案例非常之多,以至于 Google V8 漏洞悬赏计划专门设立了特殊评级:只要您能合理假定自己具备这种内存篡改能力,并成功将其升级为更严重的漏洞利用,就能获得赏金。
不过,我们假设攻击者并不具备执行任意机器代码的能力。倘若他们真有这种能力,就能禁用内存保护密钥。因为攻击者即便能访问沙箱内的内存,也只能获取到他们自己的数据。所以,攻击者必须设法实施权限提升攻击,通过篡改沙箱内的数据来访问沙箱之外的数据。
大家应该还记得,经过压缩且处于沙箱环境中的 V8 堆内存仅包含 32 位偏移量。因此,即便此处发生数据损坏,也不会波及指针隔离笼之外的内存区域。不过,沙箱中还存在数组——这些是具有特定大小、可通过索引访问的数据集合。根据我们的威胁模型设定,攻击者能够篡改这些数组的记录大小以及用于访问数组元素的索引。这意味着,攻击者有可能将沙箱中的数组变成一种错误访问内存的工具。正因如此,V8 沙箱通常会在其周围设置保护区域:这些区域是 32 GiB 大小的虚拟地址范围,且没有虚拟到物理地址的映射。这有助于防范最坏的情况:使用最大 32 位索引对元素大小为 8 字节的数组(例如,双精度浮点数数组)进行索引。这样的访问可以到达沙箱外部最远 32 GiB 的范围:是最大 32 位索引(40 亿)的 8 倍。
我们希望此类访问操作能触发警报,而不是让攻击者得以访问邻近内存区域。使用保护区域时,这种防护效果会自动实现,但我们无法为每个沙箱都分配常规的 32 GiB 保护区域。
我们不必采用传统的保护区域方案,而是可以利用内存保护密钥。通过精细控制每个隔离组所使用的密钥,我们能够确保在 32 GiB 范围内,任意沙箱都不会使用相同的保护密钥。从本质上说,这些沙箱充当着彼此的保护区域,而这一保护机制正是由内存保护密钥来实现的。如此一来,我们只需要在大型连续沙箱区域的起始和末尾,各预留一段 32 GiB 的浪费性保护区域即可。
采用全新的沙箱布局后,我们严格执行内存保护密钥的轮换机制。由于不再使用随机分配的内存保护密钥,在当前威胁模型下,前文所述的 92% 的问题得以彻底解决。沙箱内部的任何安全漏洞都无法影响到具有相同内存保护密钥的其他沙箱。如图所示,任意沙箱周边 32 GiB 范围内都不存在共用相同内存保护密钥的内存区域——任何试图访问该范围内存的操作都将触发警报,其效果与未映射的保护区域完全一致。
从某种程度上来说,整篇博文都在谈论我们的客户无需做的事情。他们无需升级服务器软件来获取最新补丁,我们已为他们完成。他们无需担心自己使用的配置是否最安全、最高效。所以,这里没有号召大家行动的意思,或许只是希望大家安心睡觉。
不过,如果您对这类工作感兴趣——尤其是如果您有 V8 或类似语言运行时的实现经验,那么不妨考虑加入我们的团队。我们正在美国和欧洲两地招聘。这里是个绝佳的工作之地,而且 Cloudflare 正在蒸蒸日上、不断壮大。