V8 Pwn Basics 1: JSObject
什么是 V8
V8 是 Google 用 C++ 开发的一个开源 JavaScript 引擎. 简单来说, 就是执行 js 代码的一个程序. Chromium, Node.js 都使用 V8 解析并运行 js.
V8 的开源仓库在 这里
V8 执行流程
JavaScript 是 解释语言, 需要先翻译成字节码后在 VM 上运行. V8 中实现了一个 VM. 出于性能考虑, 目前的引擎普遍采用一种叫做 Just-in-time (JIT) 的 编译 技术, V8 也是. JIT 的思想在于, 如果一段代码反复执行, 那么将其编译成机器代码运行, 会比每次都解释要快得多. 当然这里编译出来的机器代码还是在 VM 的环境上运行的, 比如使用相同的堆栈.

V8 解释和编译 JS 代码的流程如上图所示.
- 解析器 将 JS 代码转换为 抽象语法树 (AST)
- 解释器 将 AST 转换成 字节码, 并在 VM 中执行
- 编译器 将一些字节码优化编译成二进制机器码并执行
v8 的解释器叫 Ignition, 最新的编译器叫 TurboFan, 使用 JIT 技术. (之前有一个非优化编译器 SparkPlug 和一个优化编译器)
Ignition 是一个寄存器机, 而不是堆栈机器. 也就是说, 字节码的操作对象的是寄存器? (他这么说, 我就这么信)
环境搭建
不会看官网?
JS 对象在 V8 中的表示
众所周知, JS 是 动态类型语言, 对象的类型依赖运行时环境. 所以引擎不仅需要记录对象的值, 还需要记录对象的类型.
JS 有这几种基本数据类型: Undefined, Boolean, String, Symbol, Number, Object. 数组, 函数实际上是 Object. 显然, 最复杂的一种类型就是 对象. 下面主要介绍 Object 在 V8 中的表示.
Object 的本质是一组有序的 属性, 类似于有序字典, 即键值对有序集合. 键可以是非负整数, 也可以是字符串. 键为数字的属性称为 编号属性, 为字符串的称为 命名属性. 比如一个 object = {'x': 5, 1: 6};
. 引用这个属性可以用 .
或者 ]
, 如 object.x
, object[1]
. 每个属性都有一系列 [属性特性, 它描述了属性的状态, 比如 object.x
的值, 它是否可写, 可枚举等等.
JSObject
因为动态的特征, Object 的结构不是一成不变的, 比方说可以写这样的代码:
|
|
运行结束后, a = {1: 1, 'two': 2}
为了实现这一点, 对象的结构, 或者说 “形状”, 在 V8 中使用一个称为 Map 的类来表示, 它也称 Hidden Class, 或者 Shape.
每当创建一个对象时, V8 会在堆上分配一个 JSObject (C++ class), 来表示这个对象. 其中结构如下:
- MAP: 指向 HiddenClass 的指针
- Properties: 指向包含 命名属性 的对象的指针
- Elements: 指向包含 编号属性 的对象的指针
- In-Object Properties: 指向对象初始化时定义的 命名属性 的指针
Properties 和 Elements 独立存储, 为两个 FixedArray (V8 定义的 C++ class), 编号属性一般也叫 元素, 他是可以用整数下标来访问的, 一般也就存储在连续的空间中. 而由于动态的原因, 命名属性难以使用固定的下标进行检索. V8 使用 Map Transition 的机制来动态表示命名属性.
Hidden Class
Map (Hidden Class) 的结构如下所示:

(很多字段如 Transition Array 这张图没画出来)
比较重要的字段如下:
- 第三个字段 bit field 3: (以某些位) 存储了属性的数量.
- Descriptor Array Pointer: 指向 描述数组 的指针, 描述数组包含命名属性的信息, 如名称, 存储位置等
- Transition Array Pointer: 指向 Transition Array 的指针. 它相当于转换树上, 这个 Map 链接的边的集合
- back pointer: 指向转换树父亲节点 Map 的指针 (改字段与 construtor 复用, 因为根没有父亲).
V8 有两种方式来存储 命名属性, 对应了两种动态维护 Object 方式. 一种叫 快速属性, 一种叫 慢速属性 或 字典模式.
Fast Properties and Map Transition
快速属性分两种, 一种是每个 Object 的 in-object properties, 直接访问, 非常快速, 但是没有动态支持. 另一种是存在 Map 的 Descriptor Array 中, 使用 Map Transition 来支持动态, 也就是 JS 的 “基于原型继承”.
每次新增命名属性时, 都会基于原来的 Hidden Class 做 转换, 即新建一个 Hidden Class, 并维护信息, 同时维护两条有向边 (Transition Array 里向前一条, back pointer 向后一条), 组成一个树形结构.
在添加命名属性的时候, 除了 Map 会做变换, 其中的 Discriptor Array 也会更新, 但不是每个 Map 都有独立的 Discriptor Array, 因为他们一定程度上可以复用来节省空间. 如下面的代码:
|
|
在动态添加的过程中, 如果我们看进入 if 的那个分支, Peak 的结构 (属性名以及位置) 变化应该是这样的:
|
|
可以发现每个 Map 重复的部分其实很多. 除了 Map0 (因为 {}
?) 外, 其他的 Map 共用一个 Descriptor Array, 为 {name, height, experience}
, 而 Map1 的属性数量为 1, 它不使用后面两个属性; 同理 Map2 的属性数量为 2, 不使用最后一个. 这样就完成了复用.
不能复用的 Descriptor Array 则会新建一个, 如上述代码又进入 else 分支, 并动态添加 cost 属性, 会产生 {name, height, prominence, cost}
上述代码的整个转换树 (省略 back pointer) 如下图所示:
Slow Properties and Dictionary Mode
当一个 Object 删除命名属性删的多了, 树形结构自然不好维护, 这时 V8 会转而使用类似字典的方法, 存储在 Map 的 Properties 中. Properties 是一个指向 FixedArray 的指针, 这个 FixedArray 就存储着这些属性, 然后通过哈希来访问. 使用了字典模式后, Descriptor Array 指针就空了, 也不使用 Map Transition.
字典模式的 Map 结构如下图:

编号属性 (Elements)
JS 的数组也是一种 Object. 我们在使用数组的时候, 一般来说会连续访问. 所以编号属性 (又称元素) 直接存放在连续的内存中, 通过 Map 的 Elements 指针字段索引过去. 增加元素的时候不会有 Map Transition 的操作.
V8 对元素的类型进行了细分, 我们需要理解的是 SMI_ELEMENTS, DOUBLE_ELEMENTS 和 ELEMENTS. SMI 是小整数, DOUBLE 是浮点数, ELEMENTS 就是其他. 数组会维护一个元素类型. 比如 x = [1, 2, 3]
的类型为 SMI, 而 x = [4.4, 5.5]
的类型为 DOUBLE, x = ['789']
的类型为 ELEMENTS. 这三种类型是包含关系, JS 数组中可以同时出现整数, 小数, 字符串等, 而元素类型是数组的属性而不是元素的属性, 所以细化分类之后一定要有兼容性. 比如 x = [1, 2.0]
的类型为 DOUBLE, 如果向 x 中再添加一个 '3'
(字符串), 那么数组 x 的类型将转换为 ELEMENTS. 而这种转换是单向的, 即使删除了所有字符串, 已经是 ELEMENTS 的不会转回 DOUBLE.
除了元素类型的区别, V8 还区分 packed 和 holey. packed 表示数组里所有空间都使用, 而 holey 表示有没有使用的空间 (空洞). 如 x = [1, 2, 3]
是 PACKED_SMI, 而 y = [4.4, , 5.5]
是 HOLEY_DOUBLE, 因为 y[1]
没有定义. 这种区分主要是 V8 用于优化空间的. 同样 PACKED 到 HOLEY 也是一个单向的转换.
数组也有快速模式和慢速模式, 一般来说会使用快速模式, 即数组空间连续. 如果一个数组非常稀疏, 那么 V8 将会使用慢速模式, 创建一个字典来索引元素项目.
属性记录
属性的键值和属性特性在 Descriptor Array, Preperties 中均使用 (key, value, detail) 三项来记录, key 是键名, value 是值, detail 是属性特征, 包括 writeable, enumerable, configurble. 元素的慢速模式也采用这种方式存储. 快速模式就是一个连续空间, 类似于数组.
指针标记
V8 在存储对象的地方, 可能是一个指针, 也可能是一个值. 比如编号数组中, 如果是小整数数组, 那么直接在数组中记录 SMI 值就可以; 如果是其他对象, 如字符串, 那么记录的是指针. 如何知道元素究竟是 SMI 还是指针 (double 只在用户数据中出现, V8 知道哪些位置是 double, double 不用使用一个位来标记?)? 由于指针是对齐 4B 的, 所以指针的后两位是 0, 可以在这里做标记. 最低位为 1 表示这是一个指针而不是 SMI, 倒数第二低位标记强弱指针 (和 gc 有关, 没学过, 8会); 最低位为 0 表示这是一个 SMI. 所以一个 SMI 是 31 位的, 存储在高 31 位中, 最低位都是 0 (表现在内存中就好像给实际值乘以了一个 2).
需要注意的是, 内存中可能有一些元数据是整数的, 比如 len 之类的字段, 也是只有 31 位, 最后一位为 0.
指针压缩
对象在堆上分配, 并且一般距离不会超过 $2^32$. 所以每个指针的高 32 位都是一样的, 那就没必要存这么多了. V8 把堆的高 32 位存在根寄存器 (R13) 中. 由于这一点, 一些文章也称指针值为一个偏移, 不过个人倾向就叫他指针.
调试
gdb 配置
v8 自己写了个 gdb 的命令, 叫 job
, 非常好用! 配置只需要在 .gdbinit
source 一下 /your/path/to/v8/tools/gdbinit
和 /your/path/to/v8/tools/gdb-v8-support.py
即可.
d8 带 --allow-natives-syntax
参数启动的话, 可以在 js 脚本中写一些调试用的函数, 这些函数通常以 %
开头, 如 %DebugPrint()
显示对象信息, %DebugPrintPtr()
显示指针指向的对象信息, %SystemBreak()
下断点等.
在 gdb 中, 使用 job value
即可显示信息, 如果 value 是指针 (带标记) 或者指针的低位 (带标记), 那么会打印这个指针所指向的 C++ 对象的信息, 如果是值 (如 SMI, 末尾 0), 则会打印出其真实值.
查看内存
(仅简单举例, 可以自己尝试观察其他对象的内存信息)
JSObject 的详细内存布局如下:

由于用的多的可能还是数组, 于是这里就拿数组举例了.
gdb d8
, 然后 set args --allow-natives-syntax
, 运行. 写 var x = [4, 3, 2, 1]
, 然后 %DebugPrint(x)
, 可以看到输出:
d8> var x = [4, 3, 2, 1]
undefined
d8> %DebugPrint(x)
DebugPrint: 0x32d20008c2f5: [JSArray]
- map: 0x32d2001ce165 <Map[16](PACKED_SMI_ELEMENTS)> [FastProperties]
- prototype: 0x32d2001ce3a9 <JSArray[0]>
- elements: 0x32d2001da84d <FixedArray[4]> [PACKED_SMI_ELEMENTS (COW)]
- length: 4
- properties: 0x32d200000219 <FixedArray[0]>
- All own properties (excluding elements): {
0x32d200000e19: [String] in ReadOnlySpace: #length: 0x32d20018428d <AccessorInfo name= 0x32d200000e19 <String[6]: #length>, data= 0x32d200000251 <undefined>> (const accessor descriptor), location: descriptor
}
- elements: 0x32d2001da84d <FixedArray[4]> {
0: 4
1: 3
2: 2
3: 1
}
0x32d2001ce165: [Map] in OldSpace
- type: JS_ARRAY_TYPE
- instance size: 16
- inobject properties: 0
- elements kind: PACKED_SMI_ELEMENTS
- unused property fields: 0
- enum length: invalid
- back pointer: 0x32d200000251 <undefined>
- prototype_validity cell: 0x32d200000ac5 <Cell value= 1>
- instance descriptors #1: 0x32d2001ce915 <DescriptorArray[1]>
- transitions #1: 0x32d2001ce931 <TransitionArray[4]>Transition array #1:
0x32d200000edd <Symbol: (elements_transition_symbol)>: (transition to HOLEY_SMI_ELEMENTS) -> 0x32d2001ce949 <Map[16](HOLEY_SMI_ELEMENTS)>
- prototype: 0x32d2001ce3a9 <JSArray[0]>
- constructor: 0x32d2001ce0d1 <JSFunction Array (sfi = 0x32d20018b321)>
- dependent code: 0x32d200000229 <Other heap object (WEAK_ARRAY_LIST_TYPE)>
- construction counter: 0
[4, 3, 2, 1]
可以看到, 对象 x 是 JSArray, 地址是 0x32d20008c2f5 - 1 (指针标记). 输出了 JSObject 和他的 Map 信息.
在 gdb 中 job 0x32d20008c2f5
, 输出如下:
pwndbg> job 0x32d20008c2f5
0x32d20008c2f5: [JSArray]
- map: 0x32d2001ce165 <Map[16](PACKED_SMI_ELEMENTS)> [FastProperties]
- prototype: 0x32d2001ce3a9 <JSArray[0]>
- elements: 0x32d2001da84d <FixedArray[4]> [PACKED_SMI_ELEMENTS (COW)]
- length: 4
- properties: 0x32d200000219 <FixedArray[0]>
- All own properties (excluding elements): {
0x32d200000e19: [String] in ReadOnlySpace: #length: 0x32d20018428d <AccessorInfo name= 0x32d200000e19 <String[6]: #length>, data= 0x32d200000251 <undefined>> (const accessor descriptor), location: descriptor
}
- elements: 0x32d2001da84d <FixedArray[4]> {
0: 4
1: 3
2: 2
3: 1
}
查看内存 x/4xw 0x32d20008c2f4
:
pwndbg> x/4xw 0x32d20008c2f4
0x32d20008c2f4: 0x001ce165 0x00000219 0x001da84d 0x00000008
对比可以看到, 第一个字段是 map 指针的低 4 字节, 第二个字段是 properties 指针的低 4 字节, 第三个字段是 elements 指针的低 4 字节, 第四个字段是属性个数 4, 由于最低位用于标记, 永远为 0, 故在内存中是 8.
job 0x00000008
的输出如下:
pwndbg> job 0x00000008
Smi: 0x4 (4)
job 0x001ce165
可以查看 map (job 支持只使用指针低 4 字节, 但是一点要带指针标记)
pwndbg> job 0x001ce165
0x32d2001ce165: [Map] in OldSpace
- type: JS_ARRAY_TYPE
- instance size: 16
- inobject properties: 0
- elements kind: PACKED_SMI_ELEMENTS
- unused property fields: 0
- enum length: invalid
- back pointer: 0x32d200000251 <undefined>
- prototype_validity cell: 0x32d200000ac5 <Cell value= 1>
- instance descriptors #1: 0x32d2001ce915 <DescriptorArray[1]>
- transitions #1: 0x32d2001ce931 <TransitionArray[4]>Transition array #1:
0x32d200000edd <Symbol: (elements_transition_symbol)>: (transition to HOLEY_SMI_ELEMENTS) -> 0x32d2001ce949 <Map[16](HOLEY_SMI_ELEMENTS)>
- prototype: 0x32d2001ce3a9 <JSArray[0]>
- constructor: 0x32d2001ce0d1 <JSFunction Array (sfi = 0x32d20018b321)>
- dependent code: 0x32d200000229 <Other heap object (WEAK_ARRAY_LIST_TYPE)>
- construction counter: 0
pwndbg>
job 0x001da84d
查看 elements:
pwndbg> job 0x001da84d
0x32d2001da84d: [FixedArray] in OldSpace
- map: 0x32d200000101 <Map(FIXED_ARRAY_TYPE)>
- length: 4
0: 4
1: 3
2: 2
3: 1
查看其内存:
pwndbg> x/6xw 0x32d2001da84c
0x32d2001da84c: 0x00000101 0x00000008 0x00000008 0x00000006
0x32d2001da85c: 0x00000004 0x00000002
第一个字段是他对应的 map 指针的低 4 字节, 第二个字段是长度, 之后是内容, 分别对应 4, 3, 2, 1.