一直觉得 v8
漏洞利用是一件非常好玩的事情,所以找时间入门了一下,这篇博客所使用的环境是 *CTF 2019
的 oob
,相关附件读者可以自行上网搜索下载。这篇博客主要用于总结本人在入门 v8
漏洞利用时所学到的东西,由于 Qanux
又菜又爱玩,文章不免存在许多的问题,请读者多多包容
基本概念
在开始之前,肯定有很多人想问 v8 是一个什么东西,下面是在知乎中搜到的对于 v8 的描述:
V8引擎是由C++编写的Google开源高性能JavaScript和WebAssembly引擎,它用于Chrome和Node.js等。
V8可以独立运行,也可以嵌入到任何C++应用程序中。
V8支持众多操作系统,如Windows、linux、android等,也支持其他硬件架构,如IA32,X64,ARM等,具有很好的可移植和跨平台特性。
作为 js
引擎,V8
会编译 / 执行 JavaScript
代码,管理内存,负责垃圾回收,与宿主语言的交互等。通过暴露宿主对象 (变量,函数等) 到 JavaScript
,JavaScript
可以访问宿主环境中的对象,并在脚本中完成对宿主对象的操作。
接下来看看 v8
工作原理的简化细分:

当 Chrome
或 Node.js
需要执行一段 JavaScript
代码时,它会将源代码传递给 V8
。V8
将 JavaScript
源代码送入所谓的解析器 (Parser
),解析器为源代码创建一个抽象语法树 (AST
) 表示。AST
随后被传递给新引入的 Ignition
解释器,在那里它被转换成一系列字节码。然后,Ignition
执行这个字节码序列。
在执行过程中,Ignition
收集了有关某些操作输入的剖析信息或反馈。其中一些反馈被 Ignition
自身用来加速后续的字节码解释。例如,对于属性访问,如果在所有时间都具有相同的形状 (即你总是为属性a传递一个值,其中 a
是一个字符串),我们会缓存如何获取 a
值的信息。在后续执行相同的字节码时,我们不需要再次搜索a。这里的底层机制称为内联缓存 (IC
)。
接下来再聊聊什么是 d8
。d8
是一个非常有用的调试工具,你可以把它看成是 debug for V8
的缩写。我们可以使用 d8
来查看 V8
在执行 JavaScript
过程中的各种中间数据,比如作用域、AST、字节码、优化的二进制代码、垃圾回收的状态,还可以使用 d8
提供的私有 API
查看一些内部信息。
走进v8
本来想写写如何配置 v8
环境的,可是网上相关资料太多了,加上笔者比较懒,就没写,等哪天心血来潮再补上吧
调试
在给 gdb
配置好 v8
的调试文件后,即可利用如下命令来调试我们的 JavaScript
代码:
1 | gdb ./d8 |
这里解释一下命令里面的几个参数:
- –allow-natives-syntax:开启原生
API
(用的比较多) - –shell:运行脚本后切入交互模式
在调试的过程中我们可以在代码中加入如下代码来进行调试:
1 | %DebugPrint(obj); |
其中 %DebugPrint(obj);
作用为打印对象的信息 (debug
版本的 d8
可以打印对象的详细信息,而 release
版本的 d8
只会打印对象类型和对象的地址),%SystemBreak();
的作用类似于断点
由于标准的 JavaScript
并不支持以上语法,所以在运行时要加上 --allow-natives-syntax
选项
现在使用如下代码来进行测试:
1 | a = [1, 1, 4, 5, 1, 4]; |
启动效果如下:

可以看见打印出了这个整数数组的地址,由于我这个 d8
是 release
版本,所以并没有打印出该数组对象的详细信息,但我们可以使用 job
命令来达到相同的效果

这里有个需要注意的点,那就是 DebugPrint
打印出来的是真实地址加一,而 job
命令后面接着的也需要是 object
的真实地址加一,不然会被解析成 smi
类型
v8 object的基本结构
首先给出 object
的通用结构:

不同对象的 object
结构都会不一样,但是都有很多相似之处,现在就来详细分析上面给出的例子,为了防止忘记,这里再次贴出代码:
1 | a = [1, 1, 4, 5, 1, 4]; |
还是给出一样的结果:

可以看出该对象为 JSArray
,其结构和 object
的通用结构差不多,但是还是有一点点区别
下面是 JSArray
的结构图:

其各个字段的含义大致如下:
- map:定义了如何访问对象,具有相同
Map
的两个JS object
,就代表具有相同的类型(即具有以相同顺序命名的相同属性),比较 Map 的地址即可确定类型是否⼀致,同理,替换掉 Map 就可以进行类型混淆。 - prototype:对象的原型(如果有)
- elements:对象的地址
- length:长度
我们可以在 gdb
中查看 elements

可以看见 elements
中的数据也分为 3
层,分别为 map
指针、length
、data
这里还有一个需要注意的地方,那就是 elements
的地址是在 object
的上方的,也就是说程序在申请一个对象时,是先向堆申请一块空间用于存储对象的数据,再申请一块空间用于管理该对象。虽然 elements
是在 obj 上上方,但这并不代表 elements
就一定紧贴着 obj
,这个问题我打算留到后面再讲
好像相关结构了解到这里就差不多了,如果后面还有别的那就再补补吧(笑
v8漏洞利用思想
通过上面对 object
结构的分析,也许有人已经知道进行漏洞的利用了。可以猜测我们对一个对象进行访问时下标的最大值是由 elements
上的 length
所决定的,如果我们可以修改这个 length
为一个很大的值,我们就能够做到越界读写。同时,如果我们可以修改 obj
中的 map
,我们令其出现神奇的效果,具体在下面漏洞利用中再分析
在平时的 CTF
题目中我们的目的是如何让程序执行 system("/bin/sh")
,而在 v8
中,我们的目的是让 v8 任意执行我们的 shellcode
然而实现这一切需要一个前提,那就是需要存在 rwx
权限的区域。这时候就需要 WASM
登场了
什么是 WASM
?顾名思义,是 Asm on the web
,但其实不是真正意义上的汇编,只是更加接近汇编。WASM
可以在 Javascript Engine
的地址空间中导入一块可读可写可执行的内存页。
下面看看这一段代码:
1 | let wasm_code = new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0, 1, 133, 128, 128, |
结果:
1 | 0x0d1dfcf5f731 <Instance map = 0x1ccbe5f49789> |
可以看见此时内存已经出现了拥有 rwx
权限的区域
1 | pwndbg> vmmap |
现在的问题是我们要如何获取到这个内存区域的地址,我们来查看一下 f
的 shared_info
结构的信息:
1 | pwndbg> job 0x0d1dfcf5f8f1 |
接下里再查看其 data
结构:
1 | pwndbg> job 0x0d1dfcf5f8c9 |
再查看 instance
结构:
1 | pwndbg> job 0x0d1dfcf5f731 |
仔细查看能发现,instance
结构就是 js
代码中的 wasm_mod
变量的地址
我们再来查看这个结构的内存布局:

仔细看,能发现,rwx
段的起始地址储存在 instance+0x88
的位置,不过这个不用记,不同版本,这个偏移值可能会有差距,可以在写 exp
的时候通过上述调试的方式进行查找。
根据 WASM 的特性,我们的目的可以更细化了,现在我们的目的变为了把 shellcode
写到 WASM
的代码段,然后执行 WASM
函数,那么就能执行 shellcode
了。
这里可以写成一个固定的模板:
1 | let wasm_code = new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0, 1, 133, 128, 128, |
其中 arbitrary_address_read
函数的具体实现方式要看具体的漏洞环境
常用shellcode
在 CTF
题目中,我们的目的大多数是 getshell
然后获取 flag
,用于 getshell
的 shellcode
如下:
1 | var shellcode = [ |
当然我们也可以令其弹计算器,shellcode
如下:
1 | var shellcode = [ |
类型混淆利用模板
这里先给出一些方便类型混淆漏洞利用的模板,在后面编写 exp
时会用上
1 | let array_buffer = new ArrayBuffer(0x8); |
漏洞利用实战
漏洞分析
和文章一开始说的一样,环境用的是 *CTF 2019
的 oob
。题目给了一个 diff
文件:
1 | diff --git a/src/bootstrapper.cc b/src/bootstrapper.cc |
嗯。。。。。。,好像看的不太懂,这里简单解释一下
这里主要是出题人为 array
定义了一个 oob
函数,其函数的功能如下:
- 获取参数的数量,然后根据参数个数进行不同的操作
- 如果参数数量大于
2
则直接抛出undefined
- 如果参数数量小于等于
2
,则先把array
转成doublearray
- 然后判断如果无额外参数(第一个是
this
),则是read
功能,返回array[length]
- 如果传入了一个参数,则是
write
功能,将value
写入到doublearray[length]
中
这里的漏洞还是挺好发现了,我们知道 array
最后一个元素的索引为 length - 1
, 而这里可以索引到 length
,也就是说我们可以在 elements
中越界读和写一个索引的数据
在上面的分析中我们知道 elements
是在 obj
的上方的,当时我也说过 elements
并不一定紧贴着 obj
的,现在我就来分析一下这个问题。demo
代码如下:
1 | a = [1,2,3,4]; |
结果如下:
1 | 0x20bd84310ab9 <JSArray[4]> |
怪,可以看见在 elements
到 obj
的中间存在一些数据,于是我好奇去看看这是什么东西

好家伙,居然还会存在一个别的结构。算了,这不是我这个初学者该了解的东西,等学深入了再研究吧,暂时不影响解题
既然全为整数的 array
的 elements
无法紧贴着 obj
,那存在浮点数的 array
呢?demo
代码如下:
1 | a = [1.1,2.2,3,4]; |
运行结果如下:
1 | 0x116059b90b21 <JSArray[4]> |
可以看见带浮点数数组的 elements
是紧贴着 obj
的,这符合我们漏洞的利用
首先尝试利用一下能否利用该漏洞来泄露出 obj
中 map
的值
1 | let array_buffer = new ArrayBuffer(0x8); |
结果如下:
1 | [*] float array map: 0x000029e6d51c2ed9 |
可以看到我们已经成功的泄露出了 map
。同理,我们也可以用类似的方法去修改 map
为了能够在 WASM
上写入我们的 shellcode
,我们需要任意地址写和地址泄露,这时候就需要用到类型混淆
地址泄露
在上面已经说过,v8
是通过 map
所指向的区域来判断该对象是什么类型,也就是说,如果我们吧一个存储 obj
的 array
的 map
修改为存在浮点数数组的 map
,这时候我们就能够直接获取到该对象的地址。我们可以将该原语封装成一个 addressOf
函数
1 | let array_buffer = new ArrayBuffer(0x8); |
其中 addressOf
函数的功能为获取指定对象 obj
的地址,建议读者仔细阅读和理解该函数的实现原理
同理,我们也可以用类似的思路来实现任意地址的写,不过有一点点绕
任意地址写
任意地址写的思想为伪造一个 object
,根据类型混淆可以将该原语封装成一个函数:
1 | function fakeObj(addr) { |
这里还是希望读者可以自行思考和理解该原语是如何实现伪造 object
的。
我们伪造的 object
是在 elements
上面的,而 elements
上的数据是可控的,我们可以按照如下样式伪造一个存在浮点数的 object
1 | var float_array_mem = [ |
这里伪造的是一个没有原型的存在浮点数的 objcet
,target
为我们想要进行写的地址,也就是伪造的 elements
我们可以在泄露出 float_array_mem
的地址后通过 fakeObj(fake_obj_addr)
函数来获取我们的 fake object
,然后向 target
写入数据。至于为什么 target
要 -0x10
呢,因为 elements
上面有 0x10
字节用于存储 map
和 length
有了对象地址泄露和任意地址写,我们就以为能够在 WASM
上愉快的写 shellcode
了,可事情并没有这么简单,即在写 0x7fxxxxx
这样的高地址的时候会出现问题,地址的低位会被修改,导致出现访问异常。因为写原语使用的是 FloatArray
的写入操作,而 Double
类型的浮点数数组在处理 7f
开头的高地址时会出现将低 20
位与运算为 0
这时候我们就要使用 DataView
对象,该对象的结构如下:

这是用来读写 ArrayBuffer
的 BackingStore
的内容的对象,在 exploit
里常用作最后的任意地址读写原语的构造。
可以看见这个 DataView
多了一个 BackingStore
。DataView
对象中的 backing_store
会指向申请的 data_buf
(backing_store
相当于我们的 elements
),修改 backing_store
为我们想要写的地址,并通过 DataView
对象的 setBigUint64
方法就可以往指定地址正常写入数据了。
那现在我们的思路就很明确了,首先申请 2
个 ArrayBuffer
对象 ab1
、ab2
,申请他们各自的 DataView
对象 dv1
、dv2
。将伪造的 fakeobj
的 elements
指向 dv1
的 BackingStore-0x10
,再通过修改 fakeobj
令 dv1
的 BackingStore
指向 dv2
的 BackingStore-0x10
此时,我们可以通过修改 fakeobj
来修改 dv2
BackingStore
处的值,最后通过 dv2
的内置函数来实现任意地址的读写。提醒一下,这里要区分开 BackingStore
的地址和 BackingStore
地址出的值这2个概念。原语封装函数如下:
1 | function arbitrary_address_read(address) { |
exp
1 | let array_buffer = new ArrayBuffer(0x8); |
最后
十分简单的入门了一下 v8
漏洞利用,该类漏洞以及利用方式还有很多,看来有的学了
今天 Csome
学长在 defcon
拿了一血,太牛拉

哎,我也想成为像他那么强,还有很长的路要走啊。md,不说那么多了,开卷!!!
参考:
https://www.anquanke.com/post/id/267518
https://blog.csdn.net/qq_45323960/article/details/130124693
https://blog.csdn.net/weixin_46483787/article/details/134934993
https://ponyfoo.com/articles/an-introduction-to-speculative-optimization-in-v8