V8-Hole漏洞
对于V8 Hole相关内容的学习
V8 Map对象的分析可以看这篇搬运:V8-Map对象
V8源码分析
在v8中,JSMap的内存布局如下:
- Map:每个对象会有的,包含对象属性的shape;
- FixedArray Length:整个OrderdHashMap的大小
- elements:存在的entry的数量
- deleteds:删除的entry数量
- buckets:buckets数量
考虑如下代码:调试查看内容如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19var map = new Map();
%DebugPrint(map);
readline();
map.set(1, 1);
map.set(2, 1);
map.set(3, 1);
map.set(4, 1);
%DebugPrint(map);
readline();
map.delete(3);
%DebugPrint(map);
readline();
map.set(5, 1);
%DebugPrint(map);
readline();
第一次%DebugPrint(map):
查看其OrderedHashMap内容:
可以发现其初始时elements和deleteds都是0,buckets为2。
hashTable为0xffffffff
dataTable则为0x2b0c3e9004d1(应该是未初始化),有12个,每个entry占3x8字节,因此总容量为4个entry。即2*buckets。
第二次添加四个元素后%DebugPrint(map):
elements变为4个
hashTable有0x2和0x3
dataTable对应内容为似乎和前面的分析文章不同,将后来插入的放到了buckets的头部。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22[
{
key: 1,
value: 1,
chain: -1,
},
{
key: 2,
value: 1,
chain: -1,
},
{
key: 3,
value: 1,
chain: 0,
}
{
key: 4,
value: 1,
chain: 1,
}
]
第三次删除(3,1)后%DebugPrint(map):
elements变为3,deleteds变为1
key=3的位置变为undefined,也即是#hole,hashTable并没有改变。
用一下kanxue的参考文章作为示例:
第四次添加新元素%DebugPrint(map):
可以发现OrderedHashMap发生变化,变成31长度,即发生扩容。
扩容后buckets变为0x4,elements为4。
hashTable则变为了0x1, 0x0,0x3,然后发现dataTable内容中deleted_entry去除掉了。
map.set
map.set(key, value)的作用是给map添加元素。其接口处理逻辑如下:
- 检查Key是否存在;
- 若不存在空闲entry,则进行扩容,然后填充entry;
- 若存在空闲的entry,则直接填充;
- 若key存在,则直接更新;
- 若key不存在,则检查是否存在空闲key
这里使用TryLookupOrderedHashTableIndex函数去寻找key对应的entry,及判断key是否存在:
对于不同类型的key,有着不同的寻找方式,这里以Smi类型的Key为例,对于Smi类型的Key寻找其entry利用的函数时FindOrderedHashTableEntryForSmiKey,该函数主要逻辑为利用ComputeIntegerHash计算Key的哈希值,然后再用FindOrderedHashTableEntry进行查找.
map.delete
map.delete(key)作用为删除对应元素,其接口处理逻辑如下:
- 删除对应key的entry,标记为已删除,修改entry的key,value为hole constant
- 修改elements,deleted值
- 查看elements是否小于buckets / 2,如果是则进行shrink
- rehash重新分配一个new_table
利用原理
hole泄漏如何利用JSMap进行攻击。
Hole是JS内部的一种数据类型,用来标记不存在的元素,这个数据类型通常是不能泄露到用户JS层面。Hole类型的漏洞利用是指由于内部数据结构通过漏洞被暴露至用户JS层,因此可以根据Hole创建一个长度为-1的JSMap结构,导致越界读写,从而实现RCE。
根据前面分析,我们知道当使用map.delete删除一个元素时,只是将该元素的key,value设置为hole,并没有实际地删除该元素,实际上只是做了标记,当进行shrink操作时,这些被hole标记的元素才会被真正删除。那么如果我们可以创建key=hole的元素,那么我们就可以多次删除元素从而导致map.size为-1(当然这里前提是不进行shrink操作,因为shrink操作会清除hole元素)。
考虑如下代码:
1 | var map = new Map(); |
调试可以发现elements变为-1。
但是如果是如下代码:
1 | var map = new Map(); |
因为这样在删除一次hole后,elements = 0,buckets = 2,导致发生shrink,则hole元素会被删除。
接下来则是进行OOB。
如果我们继续向map中添加元素,在之前set操作分析中,当添加一个新元素时,new entry寻找方式为&hashTable + buckets + occupancy * 3,这里occupancy = elements + deleted。在构造map.size = -1后,相关字段为:elements = -1, deleted = 0, buckets = 2。所以new_entry = &hashTable + 2 + (-1 + 0) * 3 = &hashTable - 1 = hashTable[-1] = &buckets
所以new_entry = key|value|chain = buckets|hashTable[0]|hashTable[1],即下一次添加新元素时,就可以修改buckets = key1、hashTable[0] = value1
然后我们再添加新元素,此时:new_entry = &hashTable + buckets + (0 + 0) * 3 = hashTable[key1], 而key1我们可以控制,所以new_entry也是可控的,从而越界写key/value,这里一般就是去写JSArray的length字段。但是需要注意的是,在set操作中,当对bucket链表进行遍历时会进行检查,所以我们需要使bucket[hash(key) & (buckets - 1)] = -1从而避免遍历bucket链表。
构造好map.size = -1后,第一次添加新元素时无所谓的,因为此时bucket[0] = -1、bucket[1] = -1,但是第二次就需要注意了,第一次添加时会导致bucket[0] != -1或者bucket[1] != -1,但是其实bucket[0] = value1,所以可以让bucket[0] = value1 = -1,这样在第二次添加时我们只需要让:hash(key2) & (buckets - 1) = 0即可,这里到时候爆破一下即可。
模版如下:
1 | var map = new Map(); |
key2爆破脚本,这里ComputeUnseededHash函数以实际V8源码为准:
1 |
|
相关例题
一般思路为利用漏洞把hole泄漏出来,后面基本上是一样的。这里直接用%TheHole()来获取Hole。演示利用手法:
1 | const {log} = console; |
需要爆破key2,然后可以修改oob_arr.length的值