hole
1 | d8: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, BuildID[xxHash]=101a73109e1e8a0a, stripped |
- 64位,dynamically,全开
1 | git reset --hard 247b33e9218a9345f0073f45b967530b38153272 |
程序提供了 diff 文件:
1 | diff --git a/src/builtins/builtins-collections-gen.cc b/src/builtins/builtins-collections-gen.cc |
- 注释了
CSA_CHECK(this, TaggedNotEqual(key, TheHoleConstant()))
检查 - 新填一个变量 times,修改了
if (original_map.UnusedPropertyFields() == 0)
的判断逻辑 - 注释了一些库函数
漏洞分析
在 V8 中有如下的注释:
1 | // This check breaks a known exploitation technique. See crbug.com/1263462 |
在该网站中给出了一个 Poc:
1 | let theHole = %TheHole(); |
- 和 HITCON2022 hole 一样的 Poc
使用 GDB 进行调试:
1 | set args --allow-natives-syntax --shell ./exp.js |
1 | d8> %DebugPrint(m); |
当 Map.size 被改为 -1 时,程序的下一次 Map.set
就会向上溢出覆盖一些数据,我们的目标就是覆盖 capacity
,OrderedHashMap 的一些重要元数据的粗略布局如下:
1 | table + 0x10 => Map capacity (0x4) |
- 程序会先预留
0x4 * capacity/2
的空间用于填充 Bucket data(存储桶数据)
执行 m.set(0x10,-1)
前:
1 | pwndbg> x/20xw 0x0ecd000458a9-1 |
执行 m.set(0x10,-1)
后:
1 | pwndbg> x/20xw 0x0ecd000458a9-1 |
- 由于
Map
的容量被覆盖为32,这意味着存储桶扩展到16 - 由于存储桶的扩展,元素指针 Entry 向后移动了
(16-2)*0x4 = 0x38
字节 - 所以,之前映射键的第一个元素 Entry 存储在
table+0x1c
中,现在它将存储在table+0x54
中,因此程序会把一些原本超出 Entry 范围的数据给当成是 Entry - 利用这一点就可以完成 array_oob
现在要考虑的问题就是,如何构造 %TheHole()
%TheHole()
只是 V8 为了方便调试而提供的 runtime 函数- v8 中内置了一些 runtime 函数,可以在启动 d8 时追加
--allow-natives-syntax
参数来启动内置函数的使用 - 由于不记得比赛环境有没有添加
--allow-natives-syntax
,这里就默认没有(PS:但在官方 wp 中是使用了 runtime 函数的,理论上来说是可以直接用%TheHole()
的)
继续学习上面的文章可以找到如下 Poc:
1 | function trigger() { |
- 这个 Poc 不能直接使用,因为 hole 变量已经被禁止了
在官方 wp 中给出了另一篇文章:cve-2022-4174
在本篇文章中给出了如下的 Poc:
1 | let v1; |
- Promise 是 JS 中进行异步编程的新的解决方案
- Promise.any 是一个 Promise 组合器,它将会迭代可迭代参数中的所有 Promise,使用“then”函数将它们组合成一个新的 Promise
这里简述一下此漏洞的原理:
- 在迭代过程中,变量 remainingElementsCount 计算有多少输入 Promise
- Promise.any 的行为是在所有输入 Promise 都被拒绝时拒绝组合的 Promise
- 此变量 (remainingElementsCount) 由 reject handler 拒绝处理程序关闭:如果计数 == “0”,则组合承诺被拒绝
- 剩余元素计数初始化为 “1”(不是 “0”),以便在迭代期间同步拒绝的第一个输入 Promise,而不会错误地拒绝组合 Promise(由于输入是可迭代对象,因此输入承诺的数量是预先未知的)
- 每次迭代将剩余元素计数递增 “1”,在迭代结束时,剩余元素计数递减 “1”
- 当组合 Promise 被拒绝时,它会被拒绝,并显示 AggregateError,而 AggregateError 又包含每个输入承诺拒绝的值数组
- V8 的 Promise.any 实现懒惰地构造了这个错误数组,错误数组的新容量被错误地计算为 max(剩余元素计数,输入承诺的索引 “+1”)
- 由于在输入迭代期间,剩余元素计数比真实值高 “1”,因此同步拒绝会创建一个大 “1” 个元素的错误数组,此元素永远不会分配给并保留未初始化的 TheHole 值,该值会泄漏到用户脚本
利用该 Poc 就可以构造 TheHole,配合之前的 Poc 就可以得到一个 size == -1
的 Map:
1 | function trigger() { |
接下来的利用就和 HITCON2022 hole 的思路一样了:
- 执行
addrof(foo)
获取foo
对象的地址 - 使用
weak_read
获取foo+0x18
(foo->code
) - 使用
weak_read
获取foo->code+0x10
(foo->code->code_entry_point
) - 使用
weak_write
在foo->code->code_entry_point
处覆盖上foo->code->code_entry_point+shift_offset
(其中shift_offset
是起始 JIT 代码指令到走私的shellcode
代码之间的距离)
相关的方法也是直接用 HITCON2022 hole exp 中的:
1 | function addrof(in_obj) { /* 获取一个对象的地址 */ |
1 | function ftoi(val) { /* float转化为int */ |
这个 foo 方法如下:
1 | const foo = ()=> |
- JavaScript 中定义的浮点实际上是走私的 shellcode
- 它将执行
sys_execve('/bin/sh')
- 由于该函数被调用了很多次,因此 v8 将对代码进行 JIT(启用 TURBOFAN)
在 GDB 中打印它的信息:
1 | d8> %DebugPrint(foo) |
1 | pwndbg> job 0x386f001d242d |
1 | pwndbg> telescope 0x386f001d242d-1 |
我们的目标就是覆盖 code_entry_point
,使其指向 shellcode:
1 | pwndbg> telescope 0x386f001d242d-1 |
1 | pwndbg> telescope 0x55a0a0004ab4 |
完整 exp 如下:
1 | const foo = ()=> |
漏洞分析2
本题目还有另一种思路,先看第2个 patch 的信息:
1 | - if (original_map.UnusedPropertyFields() == 0) { |
在源码中找到更改的代码:
1 | // Check if we need to perform a transitioning store. |
- 程序出自于
JSNativeContextSpecialization::BuildPropertyStore
函数 - 第一次运行到这里时本来能进入逻辑部分,但是由于
times!=0
的原因,导致没有进入
这里我们先了解一下 UnusedPropertyFields
的作用:
- 对象在进行 Property 存储的时候会先开辟一定容量的数组,有些空间并不会被立刻使用
UnusedPropertyFields
就用于记录未使用的内存,这样就可以判断属性是否越界了- 当
UnusedPropertyFields == 0
时就意味着 Property 需要扩容,但由于 patch 导致程序扩容失效,最终导致 Property 越界
先看一个测试案例:
1 | function SDD() { |
- 这里的
PrepareFunctionForOptimization
和OptimizeFunctionOnNextCall
是为了程序能调用BuildPropertyStore
(经过调试,发现只有这种情况能调用BuildPropertyStore
)
GDB 打印数据:
1 | - properties: 0x0faa0004b6f5 <PropertyArray[6]> /* b */ |
- 调试发现,对象 c 在执行
BuildPropertyStore
后,的确扩容失败了
1 | pwndbg> job 0x0faa0004b6f5 /* b */ |
我们可以把 c.b[0]
给当做 %TheHole()
,后面的操作就和前面的方法一样了
完整 exp 如下:
1 | var shellcode=new Uint8Array(100); |
小结:
V8 的知识点真的很多,慢慢积累吧