很早之前,就想学习一些V8的知识,因为在之前研究Adobe Reader时对于JS引擎有过一些接触,当时就觉得里面涉及了很多的东西。现在总算下定决心,来系统的入门一下。
环境编译
万事开头难,首先就是要将V8的环境搭好,学会调试的方法。
chrome 里面的 JavaScript 解释器称为v8,我们做的pwn题主要面向的也是这个。这里搭建环境的步骤如下:
首先要知道,我们下载的源码称为V8,而V8经过编译之后得到的可执行文件为 d8。根据编译时选择的不同,编译出来的 d8 分为 debug版本 和 release版本,一般把这两个版本都编译出来。
下载源码
由于需要去谷歌的网站上下载源码,所以需要保证虚拟机能够走代理。我这里直接设置允许局域网连接,将虚拟机设置为NAT,那么就可以上梯子。
随后,依次安装 depot_tools:
1 | git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git |
这个工具是用来得到v8源码的。
ninja
这个工具是用来编译v8的:
1 | git clone https://github.com/ninja-build/ninja.git |
然后就是重新加载环境变量。
最后就是去编译 V8 源代码:
1 | fetch v8 && cd v8&& gclient sync |
最后编译出的 输出在如下位置:
1 | ./out/x64.debug/d8 |
加载补丁
上面的方法,只适用于编译最新的 V8代码。但是对于我们常见的调试漏洞或者解题,往往需要编译特定版本的 V8 或者加载相应的补丁。
需要用如下方法。
一般题目都会给出有漏洞的版本的commitid
,所以编译之前先把源码的版本reset
到和题目一致的版本,在把题目给出的diff
文件应用到源码中:
1 | git reset --hard 6dc88c191f5ecc5389dc26efa3ca0907faef3598 |
调试方法
成功编译 V8 后,我们还得了解如何调试 d8
。
下断点
在使用gdb调试时需要开启allow-natives-syntax
选项:
1 | //方法一 |
可以直接在 js
代码中使用%DebugPrint();
以及%SystemBreak();
下断点。%SystemBreak()
其作用是在调试的时候会断在这条语句这里,%DebugPrint()
则是用来打印对象的相关信息,在debug
版本下会输出很详细的信息。
job命令
用于可视化显示JavaScript
对象的内存结构。
gdb
下使用:job 对象地址
telescope命令
功能:查看一下内存数据
使用:telescope 查看地址 (长度)
基础知识
指针标记
V8 使用指针标记机制来区分 指针、双精度数 和 Smis(代表) immediate small integer
1 | Double: Shown as the 64-bit binary representation without any changes |
因此,V8 中 如果一个值表示的是指针,那么会将该值的最低bit 设置为1,所以其实真实的值需要减去 1.
Job 直接给对象地址就行,telescope 的时候,需要给真实值,需要 -1.
V8 对象结构
V8 中的对象有如下属性:
1 | map: 定义了如何访问对象 |
分析:
对象里存储的数据是在elemnts
指向的内存区域的,而且是在对象的上面。也即,在内存申请上,V8先申请了一块内存存储元素内容,然后申请了一块内存存储这个数组的对象结构,对象中的elements
指向了存储元素内容的内存地址。
map属性详解
对象的map
(数组是对象)是一种数据结构,其中包含以下信息:
1 | 对象的动态类型,即 String,Uint8Array,HeapNumber 等 |
属性名称通常存储在Map
中,而属性值则存储在对象本身中几个可能区域之一中。然后,map
将提供属性值在相应区域中的确切位置。
本质上,映射定义了应如何访问对象:
对于对象数组:存储的是每个对象的地址
对于浮点数组:以浮点数形式存储数值
所以,如果将对象数组的map
换成浮点数组 -> 就变成了浮点数组,会以 浮点数的形式存储对象的地址;如果将对 浮点组的 map 换成对象数组 -> 就变成了对象数组,打印浮点数存储的地址。
对象和对象数组
也就是说,对象数组里面,存储的是别的对象的地址。
StarCTF oob
漏洞分析
这里题目直接给出了一个 oob.diff
文件,如下所示:
1 | diff --git a/src/bootstrapper.cc b/src/bootstrapper.cc |
现在我们依次分析一下这个文件有什么信息。这里的 +
表示新增的内容,-
表示 删除的内容。
总体来说,这个文件实际就是增加了一个oob
函数,主要分为三部分:定义、实现和关联。
定义
为数组添加名为oob
的内置函数(用于调用),内部调用的函数名是kArrayOob
(实现oob的函数)
1 | + SimpleInstallFunction(isolate_, proto, "oob", |
实现
函数将首先检查参数的数量是否大于2(第一个参数始终是 this 参数)。如果是,则返回 undefined
。如果只有一个参数(this),他将数组转换成FixedDoubleArray
,然后返回array[length]
。如果有两个参数(this 和 value),它以 float
形式将 value
写入 array[length]
。
1 | src/builtins/builtins-array.cc |
那么这里就存在一个明显的数组越界,我们数组的下标应该是[0, length-1]
。而这里我们能够修改arr[length]
的值,那么就可以越界修改相邻的一个地址的值。
内存布局分析
这里,我们以一个例子来分析一个 js
中的数组的内存布局:
1 | var a = [1.1, 2.2, 3.3, 4]; |
我们用如下方式调试:
1 | gdb ./d8 |
我们首先得到a
的内存地址如下:
1 | DebugPrint: 0x19c3df24de81: [JSArray] |
这里可以看到一个对象结构布局如上述:
1 | map: 定义了如何访问对象 |
然后map
类型的详细属性,也如下所示,这下面的内容解释还不够全面,后续我会做一个深入分析(希望,还能记得这个点。。。)
1 | 对象的动态类型,即 String,Uint8Array,HeapNumber 等 |
接着,我们看一下element
结构:
1 | pwndbg> job 0x19c3df24de51 |
从上面,可以看到element
结构也首先有一个map
类型,随后是 数组长度,然后存储的是数组的每一项。
注意:这里有一个很重要的点,可以看到整个数组的JSArray
的map
类型是 紧邻在 element
类型的下面的,也即上面的0x19c3df24de80
。而结合,我们前面提到的漏洞点,那么可以很确定的得出 我们可以通过数组 越界去修改 整个数组对象的 map
结构地址。而,前面也提到了map
的一个作用就是标识当前变量的 类型,那么这里我们就可以利用修改map
来修改一些 变量的数据类型,达到 类型混淆的作用。
但是,从上面的内存结构中,我们可以得到如下的一个内存布局:
1 | elemets---> | map |<---------+ |
前面,我们只是分析了浮点数组的结构。接着我们分析一下整数数组的结构,如下:
1 | DebugPrint: 0x27c79100dea1: [JSArray] |
这里可以明显的看到ArrayObject
的地址为0x27c79100dea1
,而elements
的地址为0x27c79100ddc1
。所以,其也符合我们前面提到的存储结构。
接着,我们看一下 对象数组,如下:
1 | DebugPrint: 0x29f9d88dee1: [JSArray] |
对象数组和 数值数组还是有明显的区别。在对象数组中elements
存储的就是一个个对象的地址。但是其基本的数据存储结构还是没有变化。
对象类型混淆测试
这里我们也可以写一个测试,来测试一下类型混淆是否可行:
1 | var a = [1.1, 2,2]; |
我们没有修改c
的map
的地址前,可以看到此时其输出c[0]
是按照一个对象进行输出:
1 | pwndbg> c |
接着,我们使用 set
直接将c
的map
地址修改为a
的 map
地址,也就是将对象数组的类型修改为 浮点数数组的类型:
1 | 0x3f98aecdfc9: [JSArray] |
可以看到这里是能够混淆成功的。
漏洞测试
接着,我们来测试一下漏洞函数,看一下是否能够越界修改map值。
1 | var a = [1, 2, 3, 4]; |
利用分析
编写addressOf和fakeObject
首先定义两个全局的Float数组和对象数组,利用oob
函数泄漏两个数组的Map
类型
1 | var obj = {"a": 1}; |
下面实现两个函数,addressOf
泄漏给定对象的地址,其中f2i
是float2int
,1n
表示BigNumber
。fakeObject
函数是将伪造的fake_object
地址写入float_arr
中。
1 | function addressOf(obj) |
addressOf
泄漏地址方法:
1、 将要泄漏对象的地址传递给 对象数组obj_arr[0]
,此时这里将会存储该地址;
2、利用oob函数修改对象数组obj_arr
的map
为float_map
,将对象数组修改为浮点数数组;
3、读取obj_arr[0]
,此时认为其是一个浮点数组,所以会直接输出obj_arr[0]
处存储的地址
fakeObject
将一个地址伪造为一个对象的方法:
1、 将要伪造的地址赋值给 浮点数数组float_arr[0]
,此时这里将会直接存储该地址;
2、 将该浮点数数组float_arr
的map
修改为obj_array_map
,也即将其强制转换为对象数组;
3、 读取float_arr[0]
,此时就会将我们输入的地址当作一个对象返回给用户;
4、 恢复float_arr
为浮点数数组。
编写辅助函数
浮点数转整数、整数转浮点数、字节串表示整数
实现方法:开辟一块空间,创建两个数组,分别是浮点数组float64
和整数数组bigUint64
,他们公用创造的那块空间。
这样根据原来的形式放入对应的数组,用转换的数组输出即可。
1 | var buf =new ArrayBuffer(16); |
注意V8会给内存地址 +1,所以泄漏 object
地址的时候要将输出结果 -1。
同样在构造fake_obj
的时候内存中存储的地址为addr+1
,得到的obj
是一个对象,就不必有什么 +/-
操作了。
构造地址任意读写
我们结合上面的内存布局,来详细说明一下怎么进行对象伪造。
1 | ArrayObject ---->-------------------------+ |
如果我们在一块内存上部署了上述虚假的内存属性,比如map
、prototype
、elements
指针、length
、properties
属性,我们就可以用fakeObject
把这块内存强制伪造成一个数组对象。
我们构造的这个对象的elements
指针是可以控制的,如果我们将这个指针修改成我们想要访问的内存地址,那后续我们访问这个数组对象的内容,实际上就是访问我们修改后的内存地址指向的内容,这样也就实现了对任意指定地址的内容访问读写效果了。
下面是具体的构造:
我们首先创建一个float
数组对象fake_array
,可以用addressOf
泄漏fake_array
对象的地址,然后根据elements
对象与fake_object
的内存偏移,可以得出elements地址 = addressOf(fake_object) - (0x10+n*8)
(这里的n 是 元素个数)。而elements+0x10
为实际存储元素的位置。
我们提前将fake_object
构造为如下的形式:
1 | var fake_array = [ |
则我们可以通过addressOf(fake_array) - 0x30
计算得到存储数据元素内容的地址,然后使用fakeObject
将这个地址转换为对象fake_obj
,之后我们访问fake_obj[0]
,实际上访问的就是0x41414141 + 0x10
的内容(注意实际的元素存储在elements+0x10
处)
下面是地址任意读写的实现:
1 | //这块内存我们可以控制 |
这里解释一下这里任意读写的实现。首先,我们自己在内存中伪造了一个fake_array
数组,这个数组最开始是 浮点数数组。然后我们通过addressOf
获取fake_array
的地址fake_arr_addr
。随后,将fake_arr_addr - 0x40 +0x10
即 fake_arr_addr - 0x30
,这里是因为我们伪造的fake_array
对象总共就6个元素,占0x30,而我们对于fake_array
能够控制的就是这6个元素。这里我们用addressOf
返回的fake_arr_addr
是这个数组结构体开头的map
地址,所以要减去0x30,使其得到elements
的地址。最后使用fakeObject
将这个地址fake_object_addr
转换为一个对象地址fake_object
其类型是浮点数数组,这样我们后续就能够通过修改fake_arr
的元素,来任意修改我们伪造的这个对象fake_obejct
的各个内容了。
任意读的实现方法:
1、 将需要读的地址 addr-0x10
,赋值给fake_array[2]
,这里为什么要减0x10,是因为我们输入一个对象的头地址map_addr
,而想要输出的elemets = map_addr + 0x10
,而我们输出时是输出elements
的值;
2、 读取fake_object[0]
,此时会将addr
的值以浮点数输出。
任意地址写同理。
测试代码可以发现已经能够任意读写:
1 | var a = [1.1,2.2,3.3]; |
任意写改进
通过上面的方式任意地址写,在写0x7fxxxxx
这样的高地址的时候会出现问题,地址的低位会被修改,导致出现访问异常。
解决:DataView对象中的backing_store
会指向申请的data_buf
(backing_store相当于我们的elements),修改backing_store
为我们想要写的地址,并通过DataView
对象的setBigUint64
方法就可以往指定地址正常写入数据了。
1 | var data_buf = new ArrayBuffer(8); |
浏览器运行shell code:wasm
wasm
是让JavaScript
直接执行高级语言生成的机器码的一种技术。
步骤:
1、 首先加载一段wasm
代码到内存中;
2、 然后通过addressOf
找到存放的wasm
的内存地址
3、 接着通过任意地址写原语用shellcode替换原本wasm的代码内容;
4、 最后调用wasm 的函数接口即可触发调用shellcode
寻找存放wasm代码的内存页地址
通过Function -> shared_info -> WasmExportedFunctionData -> instance
,在instance+0x88
的固定偏移处,就能读取到存储wasm
代码的内存页地址起始地址。
1 | //test.js,用debug版本调试 |
经过调试,可以通过如下地址链,最终找到存储wasm
的rwx
页面。
1 | DebugPrint: 0x33d55dc9fab9: [Function] in OldSpace |
那么,经过上面分析,我们要找到wasm shellcode
的地址,可以通过如下方法:
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]); |
漏洞调试
任意读写调试
调试代码如下:
1 | var buf = new ArrayBuffer(16); |
首先可以看一下伪造的fake_array_addr
和fake_object
地址及内存结构:
1 | pwndbg> tele 0x000038378f54fa68 10 |
从上图可以看到:从0x38378f54fa68
到0x38378f54fa90
地址间的这6个元素 是我们输入的fake_array
的值。 而系统返回的fake_array_addr
是0x38378f54fa98
。而0x38378f54fa78
这个是伪造的fake_object
的element
地址,伪造的fake_object
的map
地址可以看到和fake_array
的map
地址相同,都是float_array_map
地址。
那么这里再解释一下任意读写的原理:
1、 当我们将fake_object
的地址成功伪造为一个对象结构后,那么系统就会认为fake_object
是一个浮点数数组对象;
2、 我们后续可以通过修改fake_array[2]
来修改fake_object
的element
地址;
3、 修改了fake_object's elements
地址为target_addr-0x10
后,就可以通过读取fake_object[0]
将target_addr
的值读取或修改了。
pwn利用方法
如果是一个传统的pwn
题,那么getshell
的方法可以是通过修改free_hook
这里虚表指针,来getshell
。但是这里首先就有一个问题,怎么泄漏地址。
这种方法好像在之前津门杯做jerry
那个js
解释器时,用到过。就是通过got
表来泄漏地址。那么首先需要得到一个程序基址。这里参考姚老板和xmzyshypnc
大哥的博客,找到了一种泄漏地址的方法:
1 | var buf = new ArrayBuffer(16); |
后面泄露地址和Getshel的代码(get_shell里销毁对象会调用free_hook):
1 | function get_shell(){ |
最终的exp
如下:
1 | var buf = new ArrayBuffer(16); |
最终getshell
:
1 | alex@ubuntu:~/v8/traning/starctf-oob/v8/out.gn/x64.release$ ./d8 ./shell.js |
Wasm getshell
编写一段引入wasm
的js
代码,在debug
版本的d8
中来查看wasm
代码所在的地址,js
代码如下,可以在这个网站:https://wasdk.github.io/WasmFiddle/
1 | // shellcode = "\x31\xc0\x48\xbb\xd1\x9d\x96\x91 \xd0\x8c\x97\xff\x48\xf7\xdb\x53 \x54\x5f\x99\x52\x57\x54\x5e\xb0 \x3b\x0f\x05"; |
前面,我们已经分析了如何找到wasm
代码的rwx
页面地址。然后,我们通过任意写将该页面地址写入data_view
中的data_buf
的值。这样以后使用data_view
写 就会直接将shellcode
写入rwx
页面。
最后,就是去执行wasm
函数,来执行shellcode
。
1 | var buf = new ArrayBuffer(16); |
最终结果:
1 | alex@ubuntu:~/v8/traning/starctf-oob/v8/out.gn/x64.release$ ./d8 ./wasm_sc.js |
远程getshell
前面的getshell
都是本地的,这里我们可以使用反弹shell
。
但是,我想直接弹计算器,但是一直会有问题。有哪位大佬,知道该怎么解决吗?
EXP
1 | <!DOCTYPE html> |
总结
这道题总体来说不是很难,并没有涉及很多v8
的知识。非常适合我们这种对Chrome
没有太多基础的人用来入门。这道题让我对v8
的漏洞点、利用方法以及调试方法有了一个大概的接触,但是并没有对v8
内部的处理逻辑、代码、算法有很多的分析,调试的时候也没有去具体调试v8
的执行逻辑。这一块是需要后面去加强学习的。
参考
- 本文作者: A1ex
- 本文链接: http://yoursite.com/2021/09/17/Chorme-v8-入门学习/
- 版权声明: 本博客所有文章除特别声明外,均采用 MIT 许可协议。转载请注明出处!