hole 复现
1 | There's a hole in the program ? |
- 提示信息如下:
1 | ➜ hole cat README.txt |
- 两个 patch 文件如下:
1 | diff --git a/src/builtins/builtins-array.cc b/src/builtins/builtins-array.cc |
- 注册了目标方法,并且给出了一个网页
crbug.com/1263462
1 | diff --git a/src/d8/d8-posix.cc b/src/d8/d8-posix.cc |
- 注释了部分源码(
system
没了)
环境搭建
1 | fetch v8 |
在 v8 引擎的6.5版本以上,google 采用了 GN+Ninja
的编译组合,因此需要以下工具来安装依赖:
安装 depot_tools 工具集:
1 | git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git |
- 然后将
depot_tools
加入你的PATH
环境变量中(其实也可以不添加)
1 | sudo gedit /etc/profile.d/yhellow.sh |
- 取得
depot_tools
之后,需要取得大量编译依赖,google 提供了一个比较方便的工具gclient
来获取依赖 - 然后在 V8 的目录中执行以下代码安装依赖:(注意:V8 的路径不能有中文)
1 | export PATH="$PATH:/home/yhellow/tools/depot_tools" |
初始化以及编译:
- v8 使用
Ninja
作为编译工具,同时使用GN
来生成.ninja
文件 - 使用
v8gen
可以生成不同平台的编译配置文件:
1 | ./tools/dev/v8gen.py x64.release |
在 out.gn/x64.release/args.gn
中添加如下命令:(增添调试信息)
1 | symbol_level = 2 |
使用 ninja
开始编译:
1 | ninja -C ./out.gn/x64.release d8 |
可以用 find
命令来查找可执行文件的位置:
1 | ➜ v8 git:(63cb7fb817) ✗ find ./ -name d8 -type f |
另外,V8还给 GDB 提供了一些工具,可以把以下这行命令添加到 GDB 的 .gdbinit
中:
1 | source /home/yhellow/tools/v8/v8/tools/gdbinit |
参考:如何用GN编译V8引擎
patch 分析
第2个 patch 就是注释了源码的一些功能(暂时不用管)
第1个 patch 应该就是漏洞利用的关键了:
1 | case Builtin::kArrayHole: |
- 这里规定
Builtin::kArrayHole
返回一个Oddball
引用类型 oddball
是数据类型的引用,表示在 V8 中怎么调用相关类型(null undefined true false
合称为oddball
)
1 | SimpleInstallFunction(isolate_, proto, "hole", Builtin::kArrayHole, 0, false); |
SimpleInstallFunction
每执行一次就安装一个 API 到isolate_
中- 这里相当于把符号 “hole” 和函数
Builtin::kArrayHole
绑定到一起了
1 | // CSA_CHECK(this, TaggedNotEqual(key, TheHoleConstant())); |
- 在 patch 中注释了一个
CSA_CHECK
- CodeStubAssembler,V8 的一个组件,该组件定义了一种在 TurboFan 后端构建的可移植汇编语言
1 | BUILTIN(ArrayHole){ |
- 为
Array
定义了一个Hole
方法,获取Hole
方法的参数- 当参数个数大于“1”时,返回
undefined_value
未知类型 - 当参数个数不大于“1”时,返回
Type::Oddball()
引用类型
- 当参数个数大于“1”时,返回
- Built-in Functions(Builtin)作为V8的内建功能,实现了很多重要功能
- Builtin 是编译好的内置代码块(chunk),存储在
snapshot_blob.bin
文件中,V8 启动时以反序列化方式加载,运行时可以直接调用
简单来说,Hole
方法就是返回一个 Oddball
引用:
1 | var c = []; |
1 | ➜ hole ./d8 ./exp.js |
先学习这篇文章,了解 V8 的相关知识和常用利用手段:
标记指针 Tagged Pointer
Tagged Pointer 是一个指针(内存地址),它具有与其关联的附加数据:
- 大多数体系结构都是字节可寻址的(最小的可寻址单元是字节),但是某些类型的数据通常会与数据的大小对齐,这种差异使指针的一些最低有效位未被使用,它们可以用于标签-通常用作位字段(每个位是一个单独的标签),只要使用该指针的代码在访问前将这些位屏蔽掉即可
- 相反,在某些操作系统中,虚拟地址的宽度比整个体系结构的宽度窄,从而使最高有效位可用于标签(注意,某些处理器特别禁止在处理器级别使用此类标记指针,尤其是 x86-64,这要求操作系统使用规范形式的地址,且最高有效位全为0或全为1)
JS 对象内存信息布局
可以直接使用 GDB 来查看内存布局:
1 | pwndbg> set args --allow-natives-syntax --shell ./exp.js |
- 测试用 JavaScript 代码如下:
1 | function Foo(properties, elements) { |
- 尝试在 GDB 中打印信息
1 | d8> function Foo(properties, elements) {for (let i = 0; i < elements; i++) {this[i] = `element${i}`}for (let i = 0; i < properties; i++) {this[`property${i}`] = `property${i}`}} |
- 先打印对象
JS_OBJECT_TYPE
的信息:
1 | pwndbg> x/4xw 0x1dfe0004d74d-1 /* JS_OBJECT_TYPE */ |
- 前3个数据分别为
map properties elements
的地址低4字节 - 注意:
- V8 使用了标记指针,因此在打印前需要先把指针还原
- V8 使用了指针压缩的技术,仅在内存中存储指针的下部32位,并将基本高32位存储在特定寄存器中
指针 properties
和 elements
与 V8 的两种属性有关:
1 | function Foo(properties, elements) { |
- 第一个 for 循环定义了
elements
个数组索引属性(Array-indexed Properties) - 第二个 for 循环定义了
properties
个命名属性(Named Properties)
V8 遍历时一般会先遍历前者,前后两者在底层存储在两个单独的数据结构中,分别用 elements
和 properties
两个指针指向它们
- PS:V8 有一种策略,如果命名属性少于等于10个时,命名属性会直接存储到对象本身,而无需先通过 properties 指针查询(直接存储到对象本身的属性被称为对象内属性 In-object Properties)
1 | pwndbg> x/8xw 0x1dfe0004d795-1 /* elements */ |
- 从第3个指针开始,就是:[
element0
:element11
]
1 | pwndbg> x/8xw 0x1dfe0004de81-1 /* properties */ |
- 从第3个指针开始,就是:[
property10
:property11
](前10个都是对象内属性)
对象的映射 map
是一种特殊的属性,其中包含以下信息:
- 对象的动态类型,即
String Uint8Array HeapNumber ...
- 对象的大小(以字节为单位)
- 对象的属性及其存储位置
- 数组元素的类型,例如未装箱的双精度或标记的指针
- 对象的原型
1 | 0x1dfe0019b715: [Map] in OldSpace |
Map
将提供属性值在相应区域中的确切位置
本质上,映射定义了应如何访问对象:
- 对于对象数组:存储的是每个对象的地址
- 对于浮点数组:以浮点数形式存储数值
如果我们可以修改 Map
中某些变量的数据类型,就可以达到类型混淆的效果
JavaScript Map 对象原理
先随便打印一个 Map
的内存信息:
1 | d8> %DebugPrint(m) |
- 和对象 JS_OBJECT_TYPE 相比,JSMap 多了一个
table
属性,这个属性是一个 OrderedHashMap 顺序哈希表
Map
是基于哈希表实现的,但哈希表不提供任何迭代顺序保证,而 ES6 规范要求实现在迭代 Map
时保持插入顺序,因此,“经典”算法不适合 Map
V8 使用了确定性哈希表算法(顺序哈希表),以下显示了此算法使用的主要数据结构:
1 | interface Entry { /* buckets */ |
- CloseTable 接口代表了哈希表,包含:
- hashTable:哈希表数组(大小等于“存储桶”的数量)
- dataTable:包含按插入顺序排列的条目
- Entry 接口代表“存储桶”
- key/value:键值对
- chain:链属性(指向存储 Entry 的下一个条目)
- nextSlot:用于索引到 dataTable 中
具体的操作如下:
- 每次将新条目插入表中时,它都会存储在 nextSlot 索引下的 dataTable 数组中(插入的条目将成为新的尾部)
- 当一个条目从哈希表 hashTable 中删除时,它也会从数据表 dataTable 中删除,但已删除的条目仍会占用数据表中的空间(用 hole 进行填充)
- 当一个表充满了条目(包括存在的和已删除的)时,需要用更大(或更小)的大小重新散列(重建)
使用这种方法,对 Map
进行迭代只需遍历数据表即可,这保证了迭代的插入顺序要求
测试案例:
1 | map = new Map(); |
- 先执行两个
set
:
1 | map.set(1, 'a'); |
- 然后执行一个
delete
:
1 | map.delete(1); |
- 对象 JSMap 的详细信息如下:
1 | d8> %DebugPrint(map); |
- 打印
table
属性:
1 | pwndbg> x/20xw 0x1d830004b9d9-1 |
- 整数在 V8 中表示为前31位,最后一位不使用(这就是“0x1”表示为“0x2”,“0xfffffffe”表示“-1”的原因)
- hole 在本程序中用
0x00002459
表示 - undefined 在本程序中用
0x000023e1
表示
记录 OrderedHashMap 的一些重要元数据的粗略布局:
1 | table + 0x10 => Map capacity (0x4) |
- 先预留
0x4 * capacity/2
的空间用于填充 Bucket data(存储桶数据) - 之后的空间会以
0x4 * 3
为单位来储存各个Entry
信息 - 被
delete
方法删除的区域需要用 hole 填充 - 而之后的空间则被 undefined 填充
JavaScript SandBox & JIT
对于 JavaScript 来说,沙箱并非传统意义上的沙箱,它只是一种语法上的 Hack 写法,沙箱是一种安全机制,把一些不信任的代码运行在沙箱之内,使其不能访问沙箱之外的代码
当需要解析或者执行不可信的 JavaScript 代码时,需要隔离被执行代码的执行环境,并对执行代码中可访问对象进行限制,通常开始可以把 JavaScript 中处理模块依赖关系的闭包称之为沙箱
我们大致可以把沙箱的实现总体分为两个部分:
- 构建一个闭包环境
- 模拟原生浏览器对象
一个重要的保护是,它将所有外部指针转换为查找表的索引,例如指向 Web 程序集 RWX 页的指针和 ArrayBuffer 后备存储的指针,因此,我们不能使用普通方法来实现任意读写
而 JavaScript JIT(Just-In-Time)则是另一种机制:
JIT compiler 混合了编译器和解释器的优点,大幅提高了 JavaScript 的运行速度:
- 一开始只是简单的使用解释器执行,当某一行代码被执行了几次,这行代码会被打上 Warm 的标签,当某一行代码被执行了很多次,这行代码会被打上 Hot 的标签
- 被打上 Warm 标签的代码会被传给 Baseline Compiler 编译且储存,同时按照行数和变量类型被索引
- 被打上 Hot 标签的代码会被传给 Optimizing compiler,这里会对这部分带码做更优化的编译
- 当发现执行的代码命中索引,会直接取出编译后的代码执行,从而不需要重复编译已经编译过的代码
利用 JIT 机制就可以绕过 SandBox
学习相关 POC
在 path 中给出了一篇文章 crbug.com/1263462
先学习它
这篇文章大概讲述了 V8 对 TheHole 的特殊处理(具体怎么处理的暂时不清楚),利用这个特性,可以对 Map
进行破坏
POC 如下:
1 | var map = new Map(); |
先查看这个 hole 和题目中的 hole 方法有什么关系:
1 | void Isolate::clear_pending_exception() { |
the_hole_value
在本题的 path 中也出现过
1 | %DebugPrint(hole); |
- 使用
DebugPrint
可以发现 hole 是Oddball
引用类型
具体的细节暂时不用管,我们只需要知道这个 POC 可以破坏 Map
就行了
入侵思路
先依葫芦画瓢把 POC 中的利用复刻一份:
1 | var c = []; |
1 | ➜ x64.release git:(63cb7fb817) ✗ ./d8 ./exp.js |
- 成功将
Map
进行了破坏
1 | d8> %DebugPrint(m); |
- 打印对应的
table
:(此时Map
已经被破坏,在Map.size == -1
的情况下,不能使用job
命令)
1 | pwndbg> x/20xw 0x14080004baad-1 |
- 先执行一次
m.set(0x8,-1)
后再次打印table
:(现在可以使用job
命令)
1 | pwndbg> x/20xw 0x14080004baad-1 |
- 新写入的
0x10
(JS num:8) 覆盖了table + 0x10
的位置(Bucket data[-1]),而上面提到这个位置就是Map capacity
- 正因为如此,存储桶被扩展了,元素 Entry 的位置也会改变(其实这里并不是 Entry 的位置发生了改变,而是 V8 为了预留 Bucket data 的空间,而认为 Entry 的位置向后进行了移动)
1 | pwndbg> job 0x14080004baad |
因此修改 POC 为:
1 | var c = []; |
- 打印 JSMap 和 JSArray 对象的内存信息:
1 | d8> %DebugPrint(m) |
oob_arr
和m->table
的位置很接近(0x233e0004bacd + 0x4c == 0x233e0004bb19
)
1 | pwndbg> x/20xw 0x233e0004bb19-1 |
oob_arr+0x8
(table+0x54
):oob_arr->elements
oob_arr+0xc
(table+0x58
):oob_arr->length
先执行一次 m.set(0x10,-1)
后再次打印 table
:
1 | pwndbg> job 0x233e0004bacd |
- 由于
Map
的容量被覆盖为32,这意味着存储桶扩展到16 - 由于存储桶的扩展,元素指针 Entry 向后移动了
(16-2)*0x4 = 0x38
字节 - 所以,之前映射键的第一个元素 Entry 存储在
table+0x1c
中,现在它将存储在table+0x54
中
1 | pwndbg> x/30xw 0x233e0004bacd-1+0x54 |
- 但是
table+0x54
是存储的元素oob_arr
指针,table+0x58
是oob_arr
长度 - 因此,在损坏之后,如果我们第三次调用
map.set
,它将覆盖oob_arr
元素的指针和长度,通过进一步的技术,我们将能够使用损坏的oob_arr
作为我们的读写基元
1 | d8> m.set(oob_arr, 0xffff); |
1 | pwndbg> x/30xw 0x233e0004bacd-1+0x54 |
oob_arr
的长度已经被修改了,完成了 Array OOB 数组越界
现在我们有一个可以进行 OOB 读写的数组,为了控制 RIP,我们需要能够执行:
- addrof:获取对象的地址
- 创建一个名为 victims 的新变量,它是一个空对象的数组
- 将目标对象分配给 victims 数组的元素之一
- 使用从
oob_arr
读取的 OOB,读取受害者元素的存储值(即目标对象地址)
1 | victim = [{}, {}, {}, {}]; |
- read:读取给定地址的值(RAA)
- 创建一个名为 read_gadget 的数组,该数组由浮点值组成
- 使用 OOB 从
oob_arr
写入,利用溢出覆盖read_gadget[0]
,使其指向target_addr-0x8
(数组的第一个元素存储在elements+0x8
中) - 返回
read_gadget[0]
1 | read_gadget = [1.1, 2.2, 3.3]; |
- write:将值写入给定地址(WAA)
- 前面和 read 类似
- 但我们没有返回
read_gadget[0]
值 - 而是为
read_gadget[0]
分配了我们所需的值
1 | function weak_write(addr, value) { |
接下来我们需要寻找控制 RIP 的方法(劫持程序的执行流)
实际上,可以通过 JIT 喷射攻击来走私 shellcode 代码:(绕过 sandbox)
- 我们可以做的是将我们的 shellcode 转换为浮点数,以便我们的浮点数十六进制按原样存储在 Jitted 函数区域中
- 实例代码如下:
1 | const foo = ()=> |
- JavaScript 中定义的浮点实际上是走私的 shellcode
- 它将执行
sys_execve('/bin/sh')
- 由于该函数被调用了很多次,因此 v8 将对代码进行 JIT(启用 TURBOFAN)
使用 GDB 打印内存信息:
1 | d8> %DebugPrint(foo) |
- 请注意,
foo
方法中有一个名为 code 的属性,其中基于 gdb 中的检查,偏移量为foo+0x18
- 接下来打印 code 属性的信息:
1 | pwndbg> job 0x0c250019a2dd |
- code:指向 jitted code 区域(
code + 0x8
) - code_entry_point:指向 jitted code 指令的开头(
code + 0xc
)
1 | pwndbg> job 0x55e3400042c1 /* jitted code */ |
1 | pwndbg> telescope 0x55e340004300 /* code_entry_point */ |
使用 WAA,让我们通过移动其存储值来覆盖此 code_entry_point
,以指向我们的第一个走私 shellcode
,以便当我们调用 foo
时,它将跳转并执行我们构建的 shellcode
- PS:最好将目标 JIT 代码放在文件的顶部,这样它就不会弄乱我们创建的 WAA
最后的入侵步骤:
- 执行
addrof(foo)
获取foo
对象的地址 - 使用
weak_read
获取foo+0x18
(foo->code
) - 使用
weak_read
获取foo->code+0xc
(foo->code->code_entry_point
) - 使用
weak_write
在foo->code
处覆盖上foo->code->code_entry_point+shift_offset
(其中shift_offset
是起始 JIT 代码指令到走私的shellcode
代码之间的距离)
通过调试来寻找 shift_offset
的值:
1 | pwndbg> telescope 0x5577a0004b00 |
其实这个 shellcode
的开头有一处很明显的特征:
1 | 0: 68 2f 73 68 00 push 0x68732f |
- 通过
0x68732f
就可以快速定位shellcode
:
1 | pwndbg> telescope 0x5577a0004b00+115 |
shift_offset
的值就是“115”
完整 exp 如下:
1 | const foo = ()=> |
小结:
本人对 V8 不是很了解,只是之前复现过一些 JavaScript pwn
比赛时 V8 的环境我搭了很久,搭好后也不会做题,这个题就当是 V8 入门吧
全程模仿官方 wp,原文如下:
文章全英文,锻炼了一下阅读英语论文的能力
学习到的知识如下:
JS 对象内存信息布局
JavaScript Hole 漏洞
Tagged Pointer
JavaScript Sandbox 以及其绕过手法
Map 对象原理以及 OrderedHashMap
V8 的编译以及调试手法