从0开始学V8漏洞利用之V8通用利用链(二)
经过一段时间的研究,先进行一波总结,不过因为刚开始研究没多久,也许有一些局限性,以后如果发现了,再来修正。
概述
我认为,在搞漏洞利用前都得明确目标。比如打CTF做二进制的题目,大部分情况下,目标都是执行system(/bin/sh)
或者execve(/bin/sh,0,0)
。
在v8利用上,我觉得也有一个明确的目标,就是执行任意shellcode
。当有了这个目标后,下一步就是思考,怎么写shellcode
呢?那么就需要有写内存相关的洞,能写到可读可写可执行的内存段,最好是能任意地址写。配套的还需要有任意读,因为需要知道rwx内存段的地址。就算没有任意读,也需要有办法能把改地址泄漏出来(V8的binary保护基本是全开的)。接下来就是需要能控制RIP,能让RIP跳转到shellcode
的内存段。
接下来将会根据该逻辑来反向总结一波v8的利用过程。
调试V8程序
在总结v8的利用之前,先简单说说v8的调试。
- 把该文件
v8/tools/gdbinit
,加入到~/.gdbinit
中:
1 | $ cp v8/tools/gdbinit gdbinit_v8 |
- 使用
%DebugPrint(x);
来输出变量x的相关信息 - 使用
%SystemBreak();
来抛出int3
,以便让gdb进行调试
示例
1 | $ cat test.js |
如果直接使用d8运行,会报错:
1 | $ ./d8 test.js |
因为正常情况下,js是没有%
这种语法的,需要加入--allow-natives-syntax
参数:
1 | $ ./d8 --allow-natives-syntax test.js |
接下来试试使用gdb来调试该程序:
1 | $ gdb d8 |
然后就能使用gdb命令来查看其内存布局了,另外在之前v8提供的gdbinit中,加入了一些辅助调试的命令,比如job
,作用跟%DebufPrint
差不多:
1 | pwndbg> job 0x3a0c08049685 |
不过使用job命令的时候,其地址要是其真实地址+1,也就是说,在上面的样例中,其真实地址为:0x3a0c08049684
:
1 | pwndbg> x/4gx 0x3a0c08049685-1 |
如果使用job命令,后面跟着的是其真实地址,会被解析成SMI(small integer)类型:
1 | pwndbg> job 0x3a0c08049685-1 |
0x4024b42 * 2 == 0x8049684
(SMI只有32bit)
对d8进行简单的调试只要知道这么多就够了。
WASM
现如今的浏览器基本都支持WASM,v8会专门生成一段rwx内存供WASM使用,这就给了我们利用的机会。
我们来调试看看:
测试代码:
1 | $ cat test.js |
然后使用gdb进行调试,在第一个断点的时候,使用vmmap
来查看一下内存段,这个时候内存中是不存在可读可写可执行的内存断的,我们让程序继续运行。
在第二个断点的时候,我们再运行一次vmmap
来查看内存段:
1 | pwndbg> vmmap |
因为WASM代码的创建,内存中出现可rwx的内存段。接下来的问题就是,我们怎么获取到改地址呢?
首先我们来看看变量f
的信息:
1 | DebugPrint: 0x24c6081d3645: [Function] in OldSpace |
可以发现这是一个函数对象,我们来查看一下f
的shared_info
结构的信息:
1 | - shared_info: 0x24c6081d3621 <SharedFunctionInfo js-to-wasm::i> |
接下里再查看其data
结构:
1 | - data: 0x24c6081d35f5 <Other heap object (WASM_EXPORTED_FUNCTION_DATA_TYPE)> |
在查看instance
结构:
1 | - instance: 0x24c6081d3509 <Instance map = 0x24c608207439> |
仔细查看能发现,instance
结构就是js代码中的wasmInstance
变量的地址,在代码中我们加入了%DebugPrint(wasmInstance);
,所以也会输出该结构的信息,可以去对照看看。
我们再来查看这个结构的内存布局:
1 | pwndbg> x/16gx 0x24c6081d3509-1 |
仔细看,能发现,rwx段的起始地址储存在instance+0x68
的位置,不过这个不用记,不同版本,这个偏移值可能会有差距,可以在写exp的时候通过上述调试的方式进行查找。
根据WASM的特性,我们的目的可以更细化了,现在我们的目的变为了把shellcode
写到WASM的代码段,然后执行WASM函数,那么就能执行shellcode
了。
任意读写
最近我研究的几个V8的漏洞,任意读写都是使用的一个套路,目前我是觉得这个套路很通用的,感觉V8相关的利用都是用这类套路。(不过我学的时间短,这块的眼界也相对短浅,以后可能会遇到其他情况)
首先来看看JavaScript的两种类型的变量的结构:
1 | $ cat test.js |
首先是变量a
的结构:
1 | DebugPrint: 0xe07080496d1: [JSArray] |
变量a
的结构如下:
1 | | 32 bit map addr | 32 bit properties addr | 32 bit elements addr | 32 bit length| |
因为在当前版本的v8中,对地址进行了压缩,因为高32bit地址的值是一样的,所以只需要保存低32bit的地址就行了。
elements
结构保存了数组的值,结构为:
1 | | 32 bit map addr | 32 bit length | value ...... |
变量a
结构中的length
,表示的是当前数组的已经使用的长度,elements
表示该数组已经申请的长度,申请了不代表已经使用了。这两个长度在内存中储存的值为实际值的2倍,为啥这么设计,暂时还没了解。
仔细研究上面的内存布局,能发现,elements
结构之后是紧跟着变量a
的结构。很多洞都是这个时候让变量a
溢出,然后这样就可以读写其结构的map和length的值。
接下来在一起看看变量b
和c
:
1 | 变量c: |
变量c
的结构和变量a
的基本上是一样的,只是变量a
储存的是double
类型的变量,所以value都是64bit的,而变量c
储存的是对象类型的变量,储存的是地址,也对地址进行了压缩,所以长度是32bit。
任意变量地址读
既然内存结构这么一致,那么使用a[0]
或者c[0]
取值的时候,js是怎么判断结构类型的呢?通过看代码,或者gdb实际测试都能发现,是根据变量结构的map值来确定的。
也就是说如果我把变量c
的map地址改成变量a
的,那么当我执行c[0]
的时候,获取到的就是变量b
的地址了。这样,就能达到任意变量地址读的效果,步骤如下:
- 把
c[0]
的值设置为你想获取地址的变量,比如c[0]=a;
。 - 然后通过漏洞,把
c
的map地址修改成a
的map地址。 - 读取
c[0]
的值,该值就为变量a
的低32bit地址。
在本文说的套路中,上述步骤被封装为addressOf
函数。
该逻辑还达不到任意地址读的效果,所以还需要继续研究。
double to object
既然我们可以把对象数组变为浮点型数组,那么是不是也可以把浮点型数组变为对象数组,步骤如下:
- 把
a[0]
的值设置为自己构造的某个对象的地址还需要加1。 - 然后通过漏洞,把
a
的map地址修改成c
的map地址。 - 获取
a[0]
的值
这个过程可以封装为fakeObj
函数。
任意读
这个时候我们构造这样一个变量:
1 | var fake_array = [ |
该变量的结构大致如下:
1 | | 32 bit elements map | 32 bit length | 64 bit double_array_map | |
根据分析,理论上来说布局应该如上所示,但是会根据漏洞不通,导致堆布局不通,所以导致elements
地址的不同,具体情况,可以写exp的时候根据通过调试来判断。
所以我可以使用addressOf
获取fake_array
地址:var fake_array_addr = addressOf(fake_array);
。
计算得到fake_object_addr = fake_array_addr - 0x10n;
,然后使用fakeObj
函数,得到你构造的对象:var fake_object = fakeObj(fake_object_addr);
这个时候不要去查看fake_object
的内容,因为其length
字段和elements
字段都被设置为了无效值(0x41414141)。
这个时候我们就能通过fake_array
数组来达到任意读的目的了,下面就是一个通用的任意读函数read64
:
1 | function read64(addr) |
任意写
同理,也能构造出任意写write64
:
1 | function write64(addr, data) |
我们可以这么理解上述过程,fakeObj
对象相当于把把浮点数数组变量a
改成了二维浮点数数组:a = [[1.1]]
,而fake_array[1]
值的内存区域属于fake_object
对象的elements
和length
字段的位置,所以我们可以通过修改fake_array[1]
的值,来控制fake_object
,以达到任意读写的效果。
写shellcode
不过上述的任意写却没办法把我们的shellcode
写到rwx区域,因为写入的地址=实际地址-0x8+0x1
,前面还需要有8字节的map地址和length,而rwx区域根据我们调试的时候看到的内存布局,需要从该内存段的起始地址开始写,所以该地址-0x8+0x1
是一个无效地址。
所以需要另辟蹊径,来看看下面的代码:
1 | $ cat test.js |
首先看看data_buf
变量的结构:
1 | DebugPrint: 0x2ead0804970d: [JSArrayBuffer] |
再来看看backing_store
字段的内存:
1 | pwndbg> x/8gx 0x555c12bb9050 |
double
型的2.0以十六进制表示就是0x4000000000000000
,所以可以看出data_buf
变量的值存储在一段连续的内存区域中,通过backing_store
指针指向该内存区域。
所以我们可以利用该类型,通过修改backing_store
字段的值为rwx内存地址,来达到写shellcode
的目的。
看看backing_store
字段在data_buf
变量结构中的位置:
1 | pwndbg> x/16gx 0x2ead0804970d-1 |
发现backing_store
的地址属于data_buf + 0x1C
,这个偏移在不同版本的v8中也是有一些区别的,所以写exp的时候,可以根据上面的步骤来进行计算。
根据上述的思路,我们可以写出copy_shellcode_to_rwx
函数:
1 | function copy_shellcode_to_rwx(shellcode, rwx_addr) |
利用
在linux环境下,我们测试的时候想执行一下execve(/bin/sh,0,0)
的shellcode,就可以这样:
1 | var shellcode = [ |
如果想执行windows的弹计算器的shellcode,代码只需要改shellcode变量的值就好了,其他的就不用修改了:
1 | var shellcode = [ |
其他
在上面的示例代码中,出现了几个没说明的函数,以下是这几个函数的代码:
1 | var f64 = new Float64Array(1); |
因为在上述思路中,都是使用浮点型数组,其值为浮点型,但是浮点型的值我们看着不顺眼,设置值我们也是习惯使用十六进制值。所以需要有ftoi
和itof
来进行浮点型和64bit的整数互相转换。
但是因为在新版的v8中,有压缩高32bit地址的特性,所以还需要u2d
和d2u
两个,把浮点型和32bit整数进行互相转换的函数。
最后还有一个hex
函数,就是方便我们查看值:
1 | function hex(i) |
总结
目前在我看来,不说所有v8的漏洞,但是所有类型混淆类的漏洞都能使用同一套模板:
1 | var wasmCode = new Uint8Array([0,97,115,109,1,0,0,0,1,133,128,128,128,0,1,96,0,1,127,3,130,128,128,128,0,1,0,4,132,128,128,128,0,1,112,0,0,5,131,128,128,128,0,1,0,1,6,129,128,128,128,0,0,7,145,128,128,128,0,2,6,109,101,109,111,114,121,2,0,4,109,97,105,110,0,0,10,138,128,128,128,0,1,132,128,128,128,0,0,65,42,11]); |
其中打问号的地方,需要根据具体情况来编写,然后就是有些偏移需要根据v8版本情况进行修改,但是主体结构基本雷同。
之后的文章中,打算把我最近研究复现的几个漏洞,套进这个模板中,来进行讲解。
从0开始学V8漏洞利用之V8通用利用链(二)