查看原文
其他

补丁未到,利用先行:看我如何发现、分析并利用 Chrome 1day 漏洞 (Patch- Gapping)?

István Kurucsai 代码卫士 2022-11-01
 聚焦源代码安全,网罗国内外最新资讯!
编译:奇安信代码卫士团队
Patch-Gapping(补丁间隔)即利用开发人员已修复(或已在修复进程中)的但真正补丁尚未推送给用户的开源软件漏洞的实践。在这个窗口期,安全问题是半公开性质的而用户仍然易受攻击,时长可达数天到数月的时间。越来越多的人开始认识到这个问题的严重性,谷歌可能检测到在野利用案例。本文将详述如何发现、分析并利用影响 Chrome 1day 漏洞。该漏洞的补丁尚未公开发布。此前本文作者也曾介绍过一个类似的 1day 漏洞。

背景

除了分析已公开的漏洞之外,我们的 nDay 团队还在修复程序处于开发阶段中识别可能存在的安全问题。8月中旬,关于chromium 审计的一份更改清单引起了我们的注意,它说的是影响密封和冻结对象的问题,其中包括触发分段错误的回归测试。之后由于采用了另外一种补丁方案,因此它已被放弃(并删除),接着在更为复杂的 CL 1760976 下运作。
由于最终的修复方案如此复杂,因此7.7v8 分支的临时解决方案是禁用受影响的功能,不过它将在9月10日(当地时间)发布到稳定版。7.6分支也做出了类似的修改,但它是在稳定版更新至 76.0.3809.132之后做出的,因此并未包含在稳定版中。如此,最新发布的 Chrome 稳定版本仍然受影响。这些情况都使得该漏洞成为开发 1day 利用的理想选择。
提交(commit) 信息是描述性的,该问题是由 Object.preventExtensionsObject.seal/freeze 对对象的映射和元素存储的影响以及在某些条件下 v8 如何遵循不正确的映射转换造成的。由于 v8 中的映射处理是一个复杂的主题,本文仅讨论和漏洞理解相关的绝对必要详情。其它相关主题信息可参考文末链接。
V8 中的对象结构
JS 引擎对对象的属性存储实现了多种优化。常见的优化技术是为整数键(通常被称为元素)和字符串/符号键(通常被称为插槽或命名的属性)使用单独的后备存储。这样就可允许引擎可能将连续数组用于具有证书键的属性,其中索引直接映射到底层存储,从而加快访问速度。字符串键控值也存储在数组中但如要获取和键对应的索引则需要另一级别的间接取值。而这些信息以及其它信息都是由对象的映射(或 HiddenClass)提供的。

在 HiddenClass 中存储对象形状是另一种节省存储空间的尝试。HiddenClass 在概念上类似于面向对象语言中的类。不过,由于无法预先知道基于原型的语言如 JavaScript 中对象的属性配置,因此可以按需创建。JS 引擎仅为既定形状创建单个 HiddenClass,且被具有相同结构的所有对象进行分享。在对象中添加命名属性会导致创建新的 HiddenClass,其中包含所有先前属性和新属性的存储详细信息,之后对象的映射得以更新,如下所示:

这些转换保存在 HiddenClass 链中,当使用相同的命名属性或者以同样的顺序添加属性时,将查询这些链。如果存在匹配的转换,则会重复使用;否则将创建新的 HiddenClass 并将其添加到转换树。

属性本身可被存储在三个地方。存储速度最快的是对象内存储,只需查找 HiddenClass 中的键即可找到对象内存储空间的索引。不过它仅限于一定数量的属性,其它属性存储在所谓的快速存储中,它是由对象的属性成员指向的单独数组,如下所示:

如果一个对象增删了很多对象,则维护 HiddenClass 的成本很高。V8 使用启发式检测此类情况并将对象迁移到基于字典的慢速属性存储中,如下所示:

另外一种常见的优化方法是将整数键控元素存储密集地或以打包的格式存储,前提是它们均适合特定表示,如小的整数或浮点数。这样就绕过了引擎中常见的 value boxing,将数字存储为 Number 对象的指针,从而节省空间并加快对阵列的操作。V8 处理多个类似元素类型如 PACKED_SMI_ELEMENTS,表示一个连续存储的小整数的元素数组。这种存储格式在对象的映射中进行追踪并需要一直进行更新以避免类似的类型混淆问题。元素种类被组织到一个点阵中,只允许转换到更一般的类型。这意味着向具有 PACKED_SMI_ELEMENTS 元素种类增加浮点值将把每个值转换为两个,设定新增值并将元素种类更改为 PACKED_DOUBLE_ELEMENTS

preventExtensions、密封和冻结
JavaScript 提供多种修复对象属性的方法。
  • Object.preventExtensions:阻止将新属性添加到对象中

  • Object.seal:阻止增加新属性以及对现有属性的重新配置(更改可写入的、可枚举的或可配置的特性)。

  • Object.freeze:和 Object.seal 相同但同时阻止更改属性值,因此有效地阻止对对象的修改。

PoC 分析

漏洞产生的原因在于,v8 在某种情况下遵循 map 转换,而不会相应地更新匀速后备存储,从而造成一系列后果。如下是带注释的经修改的触发:
// Based on test/mjsunit/regress/regress-crbug-992914.jsfunction mainSeal() { const a = {foo: 1.1}; // a has map M1 Object.seal(a); // a transitions from M1 to M2 Map(HOLEY_SEALED_ELEMENTS) const b = {foo: 2.2}; // b has map M1 Object.preventExtensions(b); // b transitions from M1 to M3 Map(DICTIONARY_ELEMENTS) Object.seal(b); // b transitions from M3 to M4 const c = {foo: Object} // c has map M5, which has a tagged `foo` property, causing the maps of `a` and `b` to be deprecated b.__proto__ = 0; // property assignment forces migration of b from deprecated M4 to M6 a[5] = 1; // forces migration of a from the deprecated M2 map, v8 incorrectly uses M6 as new map without converting the backing store. M6 has DICTIONARY_ELEMENTS while the backing store remained unconverted.}mainSeal();
在 PoC 代码中,两个对象 a 和 b被以相同的初始结构创建,之后 a 被密封,而 b调用 Object.preventExtensionsObject.seal。这就导致 a 切换具有 HOLEY_SEALED_ELEMENTS 元素类的映射,而 b 通过具有 DICTIONARY_ELEMENTS 元素类的映射迁移到慢速属性存储中。
该漏洞于第10到13行触发。第10行使用不兼容的 foo 属性创建对象 c,导致为 c 创建具有标记 foo 属性的新映射,并且 a 和 b 的映射被标记为已弃用。这就意味着它们将在下一个属性集操作上迁移到新映射。第11行触发 b 的转换,第13行触发 a 的转换。问题在于, v8 错误地假定 a 可被迁移至和 b 一样的映射,但也未能转换后备存储,从而导致 FixedArray(v8 部分 Object Layout 中展示的 Properties 数组)和 NumberDictionary (Properties Dict) 之间发生类型混淆。
还可能产生另外一种类型混淆问题,如补丁中另外一个回归测试所展示的那样。可能还存在可导致将此无效映射转换转变为可利用的原语的方法,例如通过破坏优化 JIT 编译器所做的假设。

利用

可使用如上展示的类型混淆损坏 Array 的长度,之后使用该 Array 进一步损坏 TypedArrays,从而将该漏洞转变为一个任意读/写原语。之后可借此实现在渲染器进程中实现任意代码执行。
FixedArray NumberDictionary Memory Layout
FixedArray 是用于几个不同 JavaScript 对象的后备存储的 C++ 类。它的结构简单如上所示,只有一个映射指针、一个存储为 v8 小整数的长度字段(基本上是一个左移32位的31位整数),然后是元素本身。
pwndbg> job 0x065cbb40bdf1 0x65cbb40bdf1: [FixedDoubleArray] map: 0x1d3f95f414a9 length: 16 0: 0.1 1: 1 2: 2 3: 3 4: 4 pwndbg> tel 0x065cbb40bdf0 25 00:0000 0x65cbb40bdf0 -> 0x1d3f95f414a9 <- 0x1d3f95f401 01:0008 0x65cbb40bdf8 <- 0x1000000000 02:0010 0x65cbb40be00 <- 0x3fb999999999999a 03:0018 0x65cbb40be08 <- 0x3ff0000000000000 04:0020 0x65cbb40be10 <- 0x4000000000000000
NumberDictionary 类在 FixedArray 之上实现一个整数键控哈希表,其结构如下。除了 map 和 length 之外,它的另外四个组成部分是:
  • Elements:存储在字典中的元素数量

  • Deleted:被删除的元素数量

  • Capacity:可被存储在字典中的元素数量。支持数字字典的 FixedArray 的长度将是三倍的 capacity 加上字典的额外头部组成部分(四个)。

  • Max number key index:字典中存储的最大数量的键

该漏洞使得能够在FixedArray 中将四个字段设置为任意值,之后触发类型混淆并将它们作为 NumberDictionary 的头部字段。
pwndbg> job 0x2d7782c4bec90x2d7782c4bec9: [NumberDictionary]- map: 0x0c48e8bc16d9 <Map>- length: 28- elements: 4- deleted: 0- capacity: 8- elements: {0: 0x0c48e8bc04d1 <undefined> -> 0x0c48e8bc04d1 <undefined>1: 0 -> 167052: 0x0c48e8bc04d1 <undefined> -> 0x0c48e8bc04d1 <undefined>3: 1 -> 167064: 0x0c48e8bc04d1 <undefined> -> 0x0c48e8bc04d1 <undefined>5: 0x0c48e8bc04d1 <undefined> -> 0x0c48e8bc04d1 <undefined>6: 2 -> 167077: 3 -> 16708}pwndbg> tel 0x2d7782c4bec9-1 2500:0000 0x2d7782c4bec8 -> 0xc48e8bc16d9 <- 0xc48e8bc0101:0008 0x2d7782c4bed0 <- 0x1c0000000002:0010 0x2d7782c4bed8 <- 0x40000000003:0018 0x2d7782c4bee0 <- 0x004:0020 0x2d7782c4bee8 <- 0x80000000005:0028 0x2d7782c4bef0 <- 0x10000000006:0030 0x2d7782c4bef8 -> 0xc48e8bc04d1 <- 0xc48e8bc05...09:0048 0x2d7782c4bf10 <- 0x00a:0050 0x2d7782c4bf18 <- 0x4141000000000b:0058 0x2d7782c4bf20 <- 0xc0000000000c:0060 0x2d7782c4bf28 -> 0xc48e8bc04d1 <- 0xc48e8bc05...0f:0078 0x2d7782c4bf40 <- 0x10000000010:0080 0x2d7782c4bf48 <- 0x41420000000011:0088 0x2d7782c4bf50 <- 0xc000000000
NumberDictionary 中的元素被存储为底层 FixedArray 中的三个插槽。例如,键为0的元素以0x2d7782c4bf10 开始。首先是键,之后是值,在这个情况下是 0x4141 的小整数,之后 PropertyDescriptor 表示可配置、可写且可枚举的属性的特性。0xc000000000 PropertyDescriptor 对应所有的三个属性集。
该漏洞使NumberDictionary 的所有头字段(长度除外)可通过在 FixedArray 中将它们设置为任意值的方式进行控制,之后通过触发问题将其当做 NumberDictionary 的头字段进行处理。虽然可以在另外一个方向触发类型混淆,但它并没有产生任何立即有潜力的原语。也可通过设置虚假 PropertyDescriptor 以混淆数据属性和访问者属性,引发进一步的类型混淆问题,但实际证明这些方式限制太多因此放弃。
从利用的角度来看,capacity字段最有意思,因为它用于大多数边界计算。当尝试设置、获取或删除元素时,HashTable::FindEntry 函数用户获取与键对应的匀速的位置,其代码如下所示:
// Find entry for key otherwise return kNotFound.template <typename Derived, typename Shape>int HashTable<Derived, Shape>::FindEntry(ReadOnlyRoots roots, Key key, int32_t hash) { uint32_t capacity = Capacity(); uint32_t entry = FirstProbe(hash, capacity); uint32_t count = 1; // EnsureCapacity will guarantee the hash table is never full. Object undefined = roots.undefined_value(); Object the_hole = roots.the_hole_value(); USE(the_hole); while (true) { Object element = KeyAt(entry); // Empty entry. Uses raw unchecked accessors because it is called by the // string table during bootstrapping. if (element == undefined) break; if (!(Shape::kNeedsHoleCheck && the_hole == element)) { if (Shape::IsMatch(key, element)) return entry; } entry = NextProbe(entry, count++, capacity); } return kNotFound;}
V8 中的哈希表使用随机的哈希种子进行二次探测,这意味着代码中的哈希参数以及内存中字典的确切结构将从运行改为运行。如下FirstProbeNextProbe 函数用于查找存储值的位置。它们的大小参数是字典的容量,因此是受攻击者控制的。
inline static uint32_t FirstProbe(uint32_t hash, uint32_t size) { return hash & (size - 1);}inline static uint32_t NextProbe(uint32_t last, uint32_t number, uint32_t size) { return (last + number) & (size - 1);}
容量(capacity) 是正常条件下2的幂数,以 capacity-1 屏蔽探针会导致对入站值的访问范围的限制。然而,通过类型混淆将容量设置为更大的值将导致对随机偏移的越界访问。它很容易导致崩溃情况,因为 v8 会尝试将任何奇数值解释为标记指针。
一种可能的解决方案是将容量设置为越界数k,即2的幂加1。这就使 FindEntry 算法仅访问两个可能的位置,一个位于偏移0的位置,一个位于偏移k的位置(3倍)。通过仔细填充,可以在字典后放置目标数组,而它的长度属性就位于偏移处。使用和目标数组长度相同的键在字典上调用删除操作将导致算法将长度替换为孔 (hole) 的值。孔 (hole) 是指向静态对象的有效指针,实际上是一个很大的值,允许目标数组用于更方便的基于数组的越界读写操作。
虽然这个方法可行,但由于随机化和被损坏 NumberDictionary 的降级性质,它是不确定的。然而,这种失败不会导致 Chrome 崩溃,而且易于被检测到;重新加载页面将哈希种子进行重新初始化,因此可以任意多次尝试该利用。

任意代码执行

如下对象结构用于获取对进程内存空间的任意读/写访问权限:
  • O:将用于触发该漏洞的对象。

  • Padding:一个被用于填充的数组,在与o完全正确的偏移量位置获取目标浮点数数组。

  • Float_array: 通过o 上的越界元素删除成为初始长度损坏的目标。

  • Tarr:用于损坏下一个类型数组的TypedArray。

  • Aarw_tarr:用于任意内存访问的类型数组。

  • Obj_addrof:用于实现泄露任意 JavaScript 对象地址的 addrof 原语的对象。

首次损坏后,该漏洞利用通过如下常规步骤实现代码执行:
  • 创建如上描述的结构。

  • 触发该漏洞,通过删除 o 上的属性来损坏 float_array 的长度。如果这一步骤失败,则重新加载页面重新开始利用。

  • 损坏 tarr 的长度以提高依赖性,因为继续使用受损坏的浮点数数组将引发问题。

  • 损坏 aarw_tarr 的后备存储并用它获取对地址空间的任意读写权限。

  • 加载 WebAssembly 模块。它将4KiB 的读写可执行存储区映射到地址空间。

  • 使用任意读/写原语遍历 WebAssembly 模块中导出函数的 JSFunction 对象层次结构,以查找读写可执行区域的地址。

  • 用shellcode 替换 WebAssembly 函数的代码并通过调用该函数来执行它。

完整的利用代码可见GitHub 页面(文末)。需要注意的是,逃避 Chrome 部署的沙箱需要动用其它另外一个漏洞。

检测

该利用并未修改任何不常见的功能或者在渲染进程中触发异常行为,使得如果没有误报结果,则无法区分恶意和非恶意代码。

缓解

通过 Settings / Advanced settings /Privacy and security / Content 设置目录禁用 JavaScript 执行,从而有效地缓解问题。

结论

Chrome 的稳定版本 77 将发布修复方案,但该版本明天才会发布。
恶意人员可能已经具有基于补丁间隔的能力。通过仔细分析这些漏洞,我们可以测试防御措施针对未修复安全漏洞的有效性。同时它使得进攻团队能够在自己的组织机构中测试检测和响应函数。


参考链接
V8 映射处理参考链接:
https://bit.ly/fast-frozen-sealed-elements-in-v8
https://v8.dev/blog/fast-properties
https://v8.dev/blog/react-cliff

完整的利用代码地址:
https://github.com/exodusintel/Chrome-Issue-992914-Sealed-Frozen-Element-Kind-Type-Confusion-RCE-Exploit/tree/master/chrome_992914


完整利用的演示视频:
https://blog.exodusintel.com/2019/09/09/patch-gapping-chrome/

另外一个 Chrome 1day 利用分析文章:

https://blog.exodusintel.com/2019/04/03/a-window-of-opportunity/



推荐阅读

BCS 2019议题分享|开源软件安全实践与思考

谷歌不打算修复Chrome 中的RCE漏洞 PoC代码已发布

利用不明 Chrome 0day 漏洞劫持5亿 iOS 用户会话



原文链接

https://blog.exodusintel.com/2019/09/09/patch-gapping-chrome/




题图:Pixabay License



本文由奇安信代码卫士编译,不代表奇安信观点,转载请注明“转自奇安信代码卫士 www.codesafe.cn”




奇安信代码卫士 (codesafe)

国内首个专注于软件开发安全的产品线。


您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存