初探V8
初探 V8
参考文章
chrome v8 pwn 学习 (2) | CoLin’s BLOG
V8 Pwn Basics 1: JSObject - Wings 的博客
通过StarCTF oob题目学习V8 PWN 入门 | 长亭百川云
注意
本文下图显示的内容都是基于v8 7.5版本的实验结果,该版本暂未使用指针压缩技术,对于高版本的v8的实验结果并不如下图所示
环境搭建
1.需要下载两个编译v8
源码的工具depot_tools
、ninja
(下载depot_tools需要挂代理)
1 | git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git |
2.然后将这两个工具添加进环境变量
1 | echo 'export PATH=$PATH:"/path/to/your/depot_tools"' >> ~/.bashrc |
3.使用depot_tools
去下载源码
1 | fetch v8 |
4.准备依赖和编译v8
(执行的时候要在默认终端,也就是bash,使用zsh等别的终端可能会有点问题,哦对,记得挂代理)
1 | gclient sync |
5.至此你就配置好了你的v8
1 | ./path/to/your/v8/out.gn/x64.debug/d8 |
当然除了debug版本的d8,我们还需要release版本的d8,也很简单就可以编译好
1 | tools/dev/v8gen.py x64.release |
回退版本和加载补丁
上述编译好的v8是最新版本,在做题时,我们通常要将版本号回退到指定版本,指定的hash值是每个提交(commit)的唯一标识符,一般题目会给的,回退之后重新编译d8
1 | git reset --hard a7a350012c05f644f3f373fb48d7ac72f7f60542 (这后面的是hash值) |
调试d8
v8
有自带的调试JS
代码的gdb插件,我们可以将他复制到本地的gdbinit文件中,给dbg增加插件
1 | cd /path/to/v8/tools目录 |
这样就可以在gdb中使用job
等命令来调试js了
同时d8
内部也有API
调试,最常用的命令有:
%DebugPrint(arr)
和SystemBreak()
首先随便编写一个js文件:
1 | var a=[1,2,3]; |
如果是直接用d8来调试,只需要
1 | path/to/your/v8/out.gn/x64.debug/d8 exp.js --allow-natives-syntax #--allow-natives-syntax是配置参数,为的是可以使用%DebugPrint(a)等调试语法 |
可以看到打印出了a
的结构
gdb调试:
1 | gdb path/to/your/v8/out.gn/x64.debug/d8 |
便可以让程序停在%SystemBreak();
的地方,可以查看内存和寄存器的状态来调试
可以看到成功执行了%DebugPrint(a);
并在断点处停了下来,可以查看内存中的值
也可以在gdb里用job()
来查看一个对象中的内部属性
其他具体调试方法可以看参考博客,写的很详细噢~~
JS的数据结构
目前我对于JS对象的数据结构也是一知半解的,以下内容仅供参考,有很多来自AI,如有错误还望理解
指针和数字的区分
为了区分一个内存中储存的数据是一个指针还是数字,v8牺牲了64位内存中的最低位,若最低为1,则表示这个地方储存的是一个指针,这也就解释了为什么内存中存储的指针的最后一位为1或9,而不是我们熟知的0或8,同时对于小整数,v8只用高32位来储存,而将低32位置零(注意这里指7.5版本,高版本并非如此)
首先,JS中最主要的结构有原始类型、原生对象和宿主对象
原始类型
原始类型 | 描述 | 示例 |
---|---|---|
number |
数值(包括整数、浮点数、NaN、Infinity) | 42 、3.14 、NaN |
string |
字符串(字符序列) | 'hello' 、"world" |
boolean |
布尔值(仅 true 和 false ) |
true 、false |
null |
表示 “空值”(特殊原始值,typeof 会返回 'object' ,属于历史遗留问题) |
null |
undefined |
表示 “未定义”(变量未赋值时的默认值) | let a; 中 a 的值 |
symbol |
唯一标识符(ES6 新增,用于对象属性的唯一键) | Symbol('id') |
bigint |
大整数(ES2020 新增,用于表示超出 number 精度范围的整数) |
123n 、BigInt(999999999999999999) |
原生对象
- 普通对象构造器:
Object
(用于创建普通对象,如new Object()
)。 - 原始类型包装对象:
String
(字符串包装对象,如new String('a')
)、Number
(数字包装对象,如new Number(123)
)、Boolean
(布尔包装对象,如new Boolean(true)
)。
(注意:包装对象是对象,与对应的原始类型不同,如typeof new String('a')
是'object'
,而typeof 'a'
是'string'
)。 - 特殊对象:
Symbol
(创建 symbol 的构造函数)、BigInt
(创建 bigint 的构造函数)。 - 集合类对象:
Array
(数组)、Map
、Set
、WeakMap
、WeakSet
。 - 函数与执行相关:
Function
(函数构造器)、Promise
、Generator
、AsyncFunction
等。 - 工具类对象:
Date
(日期)、RegExp
(正则表达式)、Math
(数学工具,静态对象,无需实例化)、JSON
(JSON 解析 / 序列化工具,静态对象)。 - 错误对象:
Error
、TypeError
、SyntaxError
等。
宿主对象
宿主对象是由 JS 执行环境(如浏览器、Node.js)提供的对象,不属于 ECMAScript 标准,依赖具体环境。
浏览器环境
:
window
(全局对象)、document
(DOM 文档对象)、navigator
(浏览器信息)、location
(URL 信息)等。- DOM 元素对象(如
document.getElementById('id')
返回的元素)、DOM 事件对象(如event
)。 - BOM 相关对象(如
history
、screen
)。
Node.js 环境
:
global
(全局对象)、process
(进程信息)、require
(模块加载函数)、module
(模块对象)等。- 内置模块对象(如
fs
文件系统、http
网络模块)。
常见对象种类
类型分类 | V8 内部类型标识(英文定义) | 含义与示例 |
---|---|---|
普通对象 | JS_OBJECT_TYPE |
最基础的键值对对象,通过 {} 或 new Object() 创建,如 { a: 1 } 。 |
数组 | JS_ARRAY_TYPE |
有序索引列表,通过 [] 或 new Array() 创建,如 [1, 2, 3] ,自带 length 属性。 |
函数 | JS_FUNCTION_TYPE |
可执行的对象,通过 function () {} 或箭头函数创建,如 (a) => a + 1 。 |
日期 | JS_DATE_TYPE |
日期时间对象,通过 new Date() 创建,如 new Date('2024-01-01') 。 |
正则表达式 | JS_REGEXP_TYPE |
正则匹配对象,通过 /pattern/ 或 new RegExp() 创建,如 /abc/g 。 |
包装对象 | JS_STRING_TYPE (字符串) JS_NUMBER_TYPE (数字) JS_BOOLEAN_TYPE (布尔) |
原始类型的包装对象,如 new String('abc') (注意:与原始字符串 'abc' 不同)。 |
Symbol 对象 | JS_SYMBOL_TYPE |
唯一标识符对象,通过 Symbol() 创建,如 Symbol('key') 。 |
BigInt 对象 | JS_BIGINT_TYPE |
大整数对象,通过 BigInt() 创建,如 BigInt(12345678901234567890) 。 |
宿主对象 | 无统一标识(由环境定义) | 由执行环境(如浏览器、Node.js)提供的对象,如浏览器中的 window 、document ,Node.js 中的 global 、process 。 |
对象之间的关系
JS 中对象的关系通过原型链(Prototype Chain) 连接,所有对象最终都继承自 Object.prototype
(除了 null
)。核心关系如下:
原型链的顶层:
Object.prototype
是所有对象的最终原型(除了null
),它包含toString()
、hasOwnProperty()
等通用方法。- 特例:
Object.prototype
的原型是null
(没有上级)。
具体对象的继承关系:
- 普通对象:直接继承
Object.prototype
(如{}
的prototype
指向Object.prototype
)。 - 数组:继承
Array.prototype
,而Array.prototype
又继承Object.prototype
(即[].prototype === Array.prototype
,Array.prototype.prototype === Object.prototype
)。 - 函数:继承
Function.prototype
,而Function.prototype
继承Object.prototype
(如(function () {}).prototype === Function.prototype
)。 - 包装对象:
String.prototype
、Number.prototype
等继承Object.prototype
(如new String('a').prototype === String.prototype
)。
简单说:所有对象都是
Object
的 “后代”,但不同类型的对象有各自的 “直接父类”(如数组的直接父类是Array.prototype
)。- 普通对象:直接继承
如何判断对象类型
可以通过 Object.prototype.toString.call()
来判断, typeof
也可以,但貌似Object.prototype.toString.call()
更可靠
1 | console.log(Object.prototype.toString.call({})); // "[object Object]" → 对应 JS_OBJECT_TYPE |
从下图可以看到,我们创建的每一个数组,都继承于 Array.prototype
,因为其prototype
都指向Array.prototype
,故而我们的数组可以使用‘push’,pop
等方法,而Array.prototype
继承于 Object.prototype
, Object.prototype
继承于null
,
对象的属性
不同的对象有不同的属性
最常见的对象便是普通对象(JS_OBJECT_TYPE)
和数组对象(JS_ARRAY_TYPE)
普通对象
从上图可以看到,一个普通对象的属性有
map
指向的是一个对象,map
所指向的对象就是我们常说的js
隐藏类prototype
指向此对象的原型对象,也说明了此对象继承于哪个对象elements
: 指向包含 编号属性 的对象的指针properties:
指向包含 命名属性 的对象的指针In-Object Properties
: 指向对象初始化时定义的 命名属性 的指针
接下来通过一段js代码来理解这些属性:
1 | const obj = { |
运行结果见下图
map
map
这个属性非常重要,可以看到其中有很多的结构
type: JS_OBJECT_TYPE
作用:标识该 map 对应的对象类型。
这里JS_OBJECT_TYPE
表示该 map 用于普通对象(而非数组、函数、日期等其他类型),引擎通过这个字段快速判断对象的基础类型,从而应用对应的处理逻辑(如属性访问方式、内存布局等)。这里非常重要,可以通过篡改一个对象的map为其他类型对象的map,导致错误访问其中的元素,造成类型混淆漏洞,下面的例题会用到
instance size: 40
作用:表示该类型对象的实例内存大小(单位:字节)。
这里 40 字节是该对象在内存中的固定占用空间(包括对象头、属性值存储区等),引擎根据这个值为新对象分配内存,确保内存布局的一致性。这里
obj
对象有三个属性以及两个槽位用来储存properties
的数据,所以大小为5*8=40inobject properties: 2
作用:表示对象内置属性槽的数量(即直接存储在对象内存中的属性容量)。
这里 2 意味着该对象在自身内存中预留了 2 个属性槽(可直接存储a
、b
这样的属性),超过这个数量的属性(如afs
)会被存储到外部的PropertyArray
中(这也解释了为什么properties
字段会包含afs
)。elements kind: HOLEY_ELEMENTS
作用:描述对象elements
区域(存储整数索引属性)的类型。HOLEY_ELEMENTS
表示elements
区域存在 “空洞”(即有未赋值的索引,如<the_hole>
),引擎会针对这种类型的elements
采用特定的迭代和访问策略(与无空洞的PACKED_ELEMENTS
不同)。unused property fields: 2
作用:表示对象内置属性槽中未使用的数量。
结合inobject properties: 2
可知,该对象的 2 个内置属性槽已被a
、b
填满,这里的 2 可能是指那两个undefinedenum length: invalid
作用:与对象的可枚举属性长度相关。invalid
表示该 map 对应的对象没有预定义的可枚举属性长度(通常在对象属性动态变化后,这个值会失效,需要动态计算)。stable_map
作用:标记该 map 是否处于 “稳定状态”。stable_map
表示该 map 对应的对象结构近期没有频繁变化(如添加 / 删除属性),引擎会对稳定的 map 应用更多优化(如缓存属性访问路径),提升性能。back pointer: 0x33df8624ab89 <Map(HOLEY_ELEMENTS)>
作用:指向该 map 的 “父 map”,形成 map 之间的继承关系。
当对象动态添加属性时,V8 不会修改原 map,而是创建新 map 并通过back pointer
指向原 map,这样既能保留历史结构,又能实现结构的增量更新(类似链表的思想)。prototype_validity cell: 0x01a27869f9b1 <Cell value= 0>
作用:用于快速验证对象原型链的有效性。
当原型链发生变化(如修改prototype
)时,这个单元格的值会更新,引擎通过检查该值可快速判断原型链是否有效,避免每次访问原型方法时都重新验证整条链。instance descriptors (own) #3: 0x016530fcdf09 <DescriptorArray[3]>
作用:指向当前对象自身的属性描述符数组,存储对象自有属性的元信息。
这里的#3
表示包含 3 个属性描述符,对应a
、b
、afs
三个属性,每个描述符记录了属性名、存储位置、特性(可写 / 可枚举 / 可配置)等关键信息,是属性名与属性值关联的核心桥梁。layout descriptor: (nil)
作用:描述对象内存布局的额外元信息(如特殊属性的对齐方式、内存偏移量等)。(nil)
表示该对象采用默认内存布局,没有需要特殊说明的布局优化或约束(通常普通对象的layout descriptor
均为nil
,特殊类型对象可能有具体值)。prototype: 0x01a278682091 <Object map = 0x33df86240229>
作用:指向该对象的原型对象(即obj.prototype
指向的对象)。
这里原型是Object.prototype
(普通对象的默认原型),确保对象能继承toString()
、hasOwnProperty()
等通用方法。constructor: 0x01a2786820c9 <JSFunction Object (sfi = 0xb9497c9cf9)>
作用:指向该对象的构造函数。
普通对象的构造函数是Object
,因此obj.constructor === Object
会返回true
。dependent code: 0x155438a802c1 <Other heap object (WEAK_FIXED_ARRAY_TYPE)>
作用:存储依赖该 map 的优化代码(如 JIT 编译的机器码)。
当 map 发生变化时,引擎会通过这个指针找到并失效相关的优化代码,确保代码执行的正确性(避免基于旧结构的优化代码出错)。construction counter: 0
作用:记录基于该 map 创建的对象实例数量。
0 表示目前没有通过该 map 新建对象(或计数器未激活),引擎可能根据这个值决定是否对该 map 进行更深度的优化。
prototype
这个之前已经说过了,这里就不再赘述了
elments
编号属性其实也就相当于索引值,如上,我可以通过obj[1]
来访问到4,而4也是储存在elements指向的指针的内存里
可以看到v8的机制为了后续方便,直接分配了17个槽位,我猜测这应该和我在未使用索引值2的情况下直接使用了索引值3,所以直接分配了这么多(感兴趣的可以自己测一下,我太懒了),导致了很多的空槽位,那些空槽位指向了<the_hole>
In-Object Properties
命名属性一般也就是指由字符串来命名元素,如上,我可以通过obj.a
或obj["a"]
来访问到1,这种初始化定义的命名属性指向的元素会直接储存在该对象内存的下部,如图
可以看到1,2直接储存在了该对象属性的下部
properties
非初始化时的命名属性将会存储在properties指向的指针里
可以看到v8的机制为了后续方便,直接分配了3个槽位用来存放其中的数据,但只有第一个槽位存储了数据555,其他两个槽位为undefined
未定义,也就是创建了但是未初始化
这些properties
的键值存储再map
中的instance descriptors (own)
数组对象
测试代码:
1 | arr=[2,1,'aaa']; |
属性只多了一个length
,用来记录目前这个数组的长度,其他和普通对象差不多吗,没什么好说的了,感兴趣大家可以自己测一下
魔改v8
魔改v8是学习v8很重要的一个步骤,可以方便出题,理解v8的运行原理,我目前对于很多细节不太懂,就不做过多解释了,只会根据大佬的博客照猫画虎的写内置函数,建议大家看看别的大佬的博客,学习一下
练习题
StarCTF oob
附件分析
看题目给的补丁文件:
1 | diff --git a/src/bootstrapper.cc b/src/bootstrapper.cc |
主要实现就是给数组对象增加了一个名为oob
的方法,其主要实现在22~44行,其他修改只是为了能正常调用oob
方法
oob
有越界漏洞,比如一个数组的大小为length
,正常索引值应该为[0~length-1]
,但是oob却可以访问索引值[length]
,这就导致了漏洞,如果创建了一个数组对象arr
,调用arr.oob()
,可以实现越界读取,调用arr.oob(data)
,可以将data
越界写入arr
的elements
对于diff补丁的具体分析不再叙述,因为对于其中的很多宏定义以及函数的作用,写法,都不是很清晰,就不误导大家了,具体分析大家可参考通过StarCTF oob题目学习V8 PWN 入门 | 长亭百川云
准备函数
首先要准备浮点数和大整数相互转换的函数,为什么要是浮点型呢,因为附件里使用oob方法时强制把数组中的elements
中的内容转换为了FixedDoubleArray
类型,也就意味着我们越界读会读取到float
类型的数据,越界写进去的数据会当作float
类型
1 | var buf=ArrayBuffer(16); |
调试分析
浮点类型的数组和普通的数组是没有什么区别的
1 | var float_arr=[1.1,2.2,3.3]; |
可以看到,elements紧挨着这个数组,我们可以越界读取的地方记录着这个数组的map
当然其他数组也是如此
1 | var float_arr=[1.1,2.2,3.3]; |
如此,便可以得到浮点型数组的map地址和对象型数组的map地址的浮点型表示
1 | float_arr.oob(btof(123n)); |
当然越界写也很简单,将要写入的整数转化为浮点类型后,通过oob
就可以写进去了,那么接下来就是漏洞利用了
有了上述两种类型的map
地址后,我们可以实现
- 获取任意对象的地址
- 将任意地址解析为对象类型
1 | function get_obj_addr(obj) |
有了上述功能后,我们便可以伪造一个对象了
1 | make_fake_obj=[float_map,btof(0n),btof(0n),btof(0x1000000000n),2.2,3.3,4.4,5.5]; |
这是创建了一个数组对象,这个数组对象的elements
里的内容是我们伪造对象的数据,我们伪造的是一个float
类型的对象,所以填入了float_map
1 | make_fake_obj=[float_map,btof(0n),btof(0n),btof(0x1000000000n),2.2,3.3,4.4,5.5]; |
之前我们知道了,一个数组的elements紧挨着该数组的map,我们get到的对象地址就是储存map的位置,而这个数组8有个元素,故fake_map的地方就在-0x40处,如此便得到了伪造对象的地址,接着将这个地址解析为一个对象、
接着就可以操作伪造的对象,来实现任意地址读写的操作
1 | var buf=new ArrayBuffer(16); |
可以看到成功吧测试对象的properties读了出来,并且完成了修改
几个小细节:这里制造伪造对象的容器,也就是我实例代码的make_fake_obj
最好选择纯净的浮点型数组,因为浮点型数组的值是直接储存在elements里的。而如果多种类型的数据混在一块,就会导致里面的数据会通过指针来索引,其次是伪造的对象,不知为啥必须在length属性后面添加几个浮点数,也就是我上述代码的,2.2,3.3,4.4,5.5
,无论是几个都行,但就是不能没有,这里我也不太清晰,只能照做了
WASM
我们现在已经拥有了任意地址读写的功能,接下来的目标就是利用WASM
向程序中写入一段shellcode,然后执行这段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]); |
这里是直接照搬着别人的脚本,现在执行f()
函数就相当于执行了
1 | int main() |
一开是比较好奇,既然可以直接写入函数,为什么不直接执行system("/bin/sh")
,然后搜了一下
Wasm 的指令集是结构化、类型安全的,仅包含有限的内存操作、算术运算等指令,不支持直接调用操作系统系统调用(如execve
、open
等),也无法直接访问进程的其他内存区域。
但是wasm却可以给我们一段可读可写可执行的页内存,接下来便是向其中写入shellcode,f
是一个函数对象,我们可以获取其地址,那么怎么获取可读可写可执行的页内存地址呢?调试一下
1 | var buf=new ArrayBuffer(16); |
参考别人的博客,这段区域在f
对象中的shared_info
中的data
中的instance
由此,便可以得到这段内存的地址了
1 | var buf=new ArrayBuffer(16); |
接下来就是向其中写入我们的shellcoed
由于任意地址写的时候,需要用到目标地址-0x10处的地址,但是对于rwx
的区域来说,这段内存是不和法的,因此不能利用任意地址写直接写进去,故需要另想办法
1 | var data_buf = new ArrayBuffer(32); |
这样就可以成功将shellcode
写入rwx
中,就可以getshell
了
EXP
1 | var buf=new ArrayBuffer(16); |