本文讲解V8中的HeapSpary也就是常说的“堆喷”手法的基本原理和思想。后续会写一篇复现CVE-2021-38001的文章,讲解实际HeapSpary的利用和效果。
V8中的堆结构
首先,我们需要对V8中的堆结构有个基本认识。V8内存结构总体如下所示
从图中可以看到,整个堆区域总体可以分为几个小区域:
- New space(Young generation)
- Old space(Old generation)
- Large object space
- Code-space
- Cell space, propertity, and map space
我们在V8中所说的“堆”与平时glibc等等所谈到的“堆”有所区别,两者只是功能相近,都是表达用来动态管理内存的一整套机制。但V8中的内存分配,并不依赖于glibc的堆块管理,我们在下文中vmmap的部分也能注意到,两者并不指向同一个虚拟地址范围。或者说,V8中的“堆”比glibc的“堆”更加靠近表层,主要依照解析的JS代码来进行分配操作,而glibc在这里的堆则是决定d8自身在运行时所用到的动态分配的内存。
使用vmmap
,我们可以找到其V8对应的堆区域大致在下图红框范围:
其中0x331e08240000 0x331f00000000 ---p f7dc0000 0 [anon_331e08240]
这一部分表示尚未分配的内存区域。为了方便观察结构,我们可以new一个较大的Array,使其从0x331e08240000这里进行新的分割,而不是从其他区域选择之前废弃的堆块。如下代码,我们分配一个0x8000的Array
1 | a = Array(0x8000); |
对比两者vmmap
结果,可以注意到红线这里的确新分割出了一片区域,区域Size从0x80000增长至0xa3000
我们直接查看多出来区域的内容也就是0x28ce081c0000+0x80000
如下
1 | pwndbg> x/20gx 0x28ce081c0000 + 0x80000 |
通过阅读其他的文章以及一些自己的猜测,我们基本能判断出部分偏移处的含义:
- 0x0: 表示整个结构的大小为0x23000,经过多次测试,新分割的块似乎以0x1000进行对齐
- 0x18: 已有数据块内容的起始地址
- 0x20: 空闲区域的起始地址
- 0x28: 已有数据块的大小
0x20008 = 0x8000 * 4 + 0x8 = 0x000028ce08262120 - 0x000028ce08242118
- 0x28: 结构头的大小0x2118
如果不太熟悉V8的对象结构,可能会疑惑为什么数据块大小额外要加0x8,这里的0x8其实就是elements结构的前两个部分:32 bit map addr和32 bit length,之后紧接着的就是实际存储的数据了。具体可以看我之前的文章:V8通用利用链研究
从DebugPrint
的输出,我们也能确定数据的起始地址为0x28ce08242119 - 1
1 | DebugPrint: 0x28ce08049941: [JSArray] |
堆喷的基本思想
获取堆地址
在上述我们可以继续扩大下Array的大小,使其以0x40000对齐,(0x40000 -0x3000) / 4 = 0xF400
。计算得到可以更改Array大小为0xF400进行测试
其中0x3000由0x2118以0x1000对齐可得,4是指向空元素
the_hole
的压缩指针长度
1 | a = Array(0xF400); |
查看结构
1 | pwndbg> x/20gx 0x411081c0000 + 0x80000 |
可以注意到分割的大小确实是0x40000。另外,由于现在分割块以0x40000对齐,从而保证了低两字节的块起始地址恒为0x0000。导致数据的起始地址变为低两字节恒为0x2118
,也就是分割块的头部大小。同时我们观察Debug输出,也能注意到elements
的地址第两位为0x2119 = 0x2118 + 1
。
1 | # 这里的地址和前面的高位并不一样,是因为我又重新跑了一边,之前的结果忘记复制了😂,反正关注点主要在后4字节 |
我们又知V8中地址的存储经常使用指针压缩的方式,所以只需关注低32位即可。
指针压缩详细内容可以看看这篇文章:https://v8.js.cn/blog/pointer-compression/
起始只需关注下压缩指针和解压指针的代码就很清楚了
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15 /*** 压缩 ***/
uint64_t uncompressed_tagged;
uint32_t compressed_tagged = uint32_t(uncompressed_tagged);
/*** 解压 ***/
uint32_t compressed_tagged;
uint64_t uncompressed_tagged;
if (compressed_tagged & 1) {
// pointer case
uncompressed_tagged = base + uint64_t(compressed_tagged);
} else {
// Smi case
uncompressed_tagged = int64_t(compressed_tagged);
}
在多次运行后,我们可以注意到另一个有趣的现象,下面是几次elements
的不同结果
1 | - elements: 0x28fa08242119 |
可以很容易注意到,不仅仅低16bit完全相同,整个低32位都是完全相同的,也就是说elements
对应的压缩地址是完全固定的,因而我们可以通过合理划分块来获得elements
的地址。
这里的地址固定,是因为整个堆区域的起始,都是从0x00000000开始(低32bit),且整个空间区域地址连续,从之前的vmmap图我们也能观察到这点。因此在JS脚本不变的情况下,堆区域的分配基本只受到d8版本的影响,从而使得堆喷的成功率大大提高。
伪造对象
通过上述的内容,我们清楚了用堆喷来泄露可控堆地址的基本思想,在此基础上,我们可以考虑在堆上伪造对象结构,比如这里我们可以尝试伪造一个存储Double类型数据的JSArray对象。
首先需要伪造JSArray对应的array_map
对象
1 | elemetns_addr = 0x08282119 |
在map结构中这里的value0和value1也基本是固定值,应该直接找个结构复制出来即可,在js对象进行读写时,并不检查完整的map结构,而是根据这里的特征值来判断元素类型。
然后伪造JSArray主体部分
1 | fake_elements = elemetns_addr + 0x2000; |
上述便是伪造一个结构的基本流程,了解了这个过程后,addressOf和fakeObject也会变得更加容易实现。另外,针对obj类型的Array map值用gdb动调复制得到如下
1 | obj_array_map_value0 = itof(0x1604040408002119n); |
addressOf & fakeObj
那么,我们可以写出addressOf
和fakeObj
函数的大致框架,其中evil代表我们在堆中伪造的Array对象
1 | function addressOf(obj) |
fakeObj
部分如下
1 | function fakeObj(addr) |
总体来看,堆喷和对象伪造省去了我们在正常利用流程中泄露map地址的部分,我们可以更轻易的获取obj array和double array的map.
下一篇文章将以CVE-2021-38001为例,讲解实际堆喷的利用