Chrome-v8学习-精简版-1
2026-04-30 19:27:36 # 二进制安全

前言

前面的一篇文章[1]参考了大佬的英文文章[2],靠翻译去看,有些东西还是很绕,掺杂了很多的源代码部分,有点难懂。这次我结合了ai,加上自己的一些理解,重新整理了一下逻辑,我觉得更好理解一些。

欢迎对v8感兴趣的师傅,一块交流学习!!!

1、JavaScript引擎基础工作流

1.1 为什么要先学引擎原理?

所有V8漏洞的本质都是:V8引擎在解析/执行JS代码的过程中,某个步骤的逻辑出现了错误,导致攻击者可以控制内存、执行任意代码。所以我们必须先知道「正常情况下V8是怎么工作的」,才能理解「哪里会出问题」。

1.2 常见JS引擎对比

引擎名称 所属厂商 使用场景
V8 Google Chrome浏览器、Node.js
SpiderMonkey Mozilla Firefox浏览器
JavaScriptCore Apple Safari浏览器

所有JS引擎都遵循ECMAScript标准,核心工作逻辑基本一致,学会V8之后其他引擎也可以快速举一反三。

1.3 JS引擎的编译流水线(核心流程)

JS最初是纯解释型语言,执行效率很低,现代JS引擎都会引入JIT(即时编译)优化,整体流程如下:

1
2
3
JS代码 → 解析器(Parser) → AST抽象语法树 → Ignition解释器 → 字节码

热点代码(反复执行的代码) → TurboFan优化编译器 → 优化后的机器码

每个步骤的作用:

  1. 解析器(Parser)
  • 先把JS代码拆成「Token」(比如var num = 42会拆成var关键字、num标识符、=运算符、42数字四个Token)

    • 再把Token转换成AST(抽象语法树),同时检查语法错误,有语法错误直接抛出,不会往下执行。
  1. Ignition解释器:把AST转换成跨平台的字节码,边解释边执行,启动速度快,但执行效率低。
  2. TurboFan优化编译器:如果某段代码被反复执行(比如循环里的代码、被频繁调用的函数),就会被标记为「热点代码」,交给TurboFan编译成高度优化的机器码,执行效率可以接近C/C++。
  • 注意:优化是基于「代码执行时的假设」做的,比如假设函数的参数一直是整数,如果后续参数变成了字符串,优化后的代码就会「去优化」,退回到字节码执行。很多V8漏洞就是利用了优化过程中的假设错误,这也是我们后面重点要学的内容。

1.4 栈机 vs 寄存器机

Ignition解释器是基于「栈机」设计的,而TurboFan编译后的机器码是基于「寄存器机」的:

  • 栈机:所有操作都基于栈来完成,比如计算1+2,先把1压入栈,再把2压入栈,弹出两个数相加,把结果压回栈。优点是跨平台、实现简单,缺点是执行速度慢。
  • 寄存器机:直接操作CPU寄存器来完成计算,执行速度快,但是和平台架构强绑定。

2、V8对象内存表示

2.1 为什么V8要做这么多内存优化?

JavaScript是动态类型语言:你可以随时给对象加属性、删属性、改属性类型,不像C++对象在编译时结构就固定了。如果每次访问对象属性都要哈希查表,JS执行速度会非常慢,所以V8设计了大量优化机制来提升属性访问速度,而这些优化的「边角情况」就是漏洞高发区。

2.2 基础概念:Smi和堆对象

V8把所有JS值分成两大类:

类型 说明 内存特征
Smi(Small Integer,小整数) 范围在-2^30 ~ 2^30-1之间的整数,不需要单独在堆上分配内存 内存地址最后一位是0(...00),值直接存在指针里,比如1在内存里存的是2(...10)(左移1位,把最后一位空出来当标记位)
HeapObject(堆对象) 除了小整数之外的所有值:对象、数组、字符串、浮点数、函数等,都需要在堆上分配内存 内存地址最后一位是1(...01),指针指向堆上的真实对象地址

✅ 小技巧:你看V8内存里的值,最后一位是0就是数字,是1就是对象指针,这个标记机制后面漏洞利用的时候会经常用到。

2.3 核心概念:Hidden Class(也叫Map,注意和JS的Map对象区分开)

V8给每个对象都分配了一个隐藏类(Map),用来描述这个对象的结构:有哪些属性、属性的类型、属性在内存里的偏移位置。结构相同的对象会共享同一个Map,这样不需要每个对象都存一份结构信息,大大节省内存和提升访问速度。
举个例子:

1
2
3
const obj1 = {x:1, y:2}
const obj2 = {x:3, y:4}
// obj1和obj2结构完全一样,会共享同一个Map

Map的过渡规则:

当你给对象加属性时,Map会按照固定顺序过渡:

1
2
3
const obj = {} // Map A:空对象
obj.x = 1 // 过渡到Map B:有x属性
obj.y = 2 // 过渡到Map C:有x、y属性

✅ 重点:

  • 只有属性添加顺序完全一样的对象,才会共享同一个Map
  • 如果删除属性、或者添加太多属性,对象会从「快模式」切换到「慢模式(字典模式)」,所有属性存在哈希表里,不再有Map过渡,访问速度会变慢,同时也无法共享Map。

2.4 属性的存储方式

V8的对象属性分两大类:

  1. 命名属性:比如obj.xobj.name这种通过名字访问的属性
  • In-Object属性:直接存在对象本身的内存里,访问速度最快,数量固定,初始化对象时分配
  • 快属性:存在单独的属性数组里,通过Map里的索引来访问,速度稍慢
  • 慢属性(字典模式):存在哈希表里,不需要Map,访问速度最慢,一般是属性太多/删除属性时才会触发
  1. 索引属性(Elements):比如arr[0]arr[1]这种数组索引访问的属性,存在单独的Elements数组里

2.5 数组的Elements类型

V8对数组做了非常细的分类,目的是为了优化数组操作的速度,核心有3个维度:

维度1:元素类型

类型 说明
PACKED_SMI_ELEMENTS 数组里全是小整数,没有空洞
PACKED_DOUBLE_ELEMENTS 数组里有浮点数,没有空洞
PACKED_ELEMENTS 数组里有对象/字符串等非数字类型,没有空洞

维度2:是否有孔洞(holes)

  • PACKED:数组是连续的,没有空位(比如[1,2,3]

  • HOLEY:数组有空位(比如[1,,3],索引1是空的),访问的时候需要检查空位,速度更慢

    ✅ 重点:Elements类型的过渡是单向的,只能从高级往低级过渡,不能回头:

  • 如果你给全是整数的数组加一个浮点数,数组类型就从PACKED_SMI_ELEMENTS变成PACKED_DOUBLE_ELEMENTS,就算你后来把浮点数删掉,也变不回SMI类型了。

  • 如果你给数组加了一个空位,就变成HOLEY_*类型,就算你把空位填上,也变不回PACKED类型了。

这里面的概念有点多了,下面详细解释一下。

思考1:前文提到的孔洞(hole)是什么意思?

(1)英文原文

对应V8官方术语是 hole(复数形式holes),教程里对应的数组类型标记是HOLEY_*,和表示「无孔洞数组」的PACKED_*是对应概念。

(2)具体含义

孔洞指数组中未被实际赋值的空索引位置,注意和「存储了undefined值的索引」是完全不同的概念:

1
2
3
4
5
6
// 有孔洞的数组:索引2的位置是hole,没有实际存储任何值
const arr1 = [1, 2, , 4];
console.log(arr1[2]); // 输出undefined,但这是访问不存在的索引的默认返回值,不是arr1[2]真的存了undefined

// 无孔洞的数组:索引2的位置确实存了undefined值
const arr2 = [1, 2, undefined, 4];

(3)为什么V8要专门区分hole?

核心是性能优化:

  • 如果数组是PACKED(无孔洞)的,V8操作数组时不需要额外判断某个索引是否存在,直接按内存偏移量读取即可,速度极快
  • 如果数组是HOLEY(有孔洞)的,每次访问索引时V8都需要额外做判断:这个索引是不是不存在?是不是要去原型链上查找对应的属性?会带来额外开销

✅ 重点注意:孔洞的标记是不可逆的,只要数组出现过孔洞,就会一直被标记为HOLEY类型,哪怕你后续把孔洞的位置补上值,也不会变回PACKED类型,性能开销会一直存在。

这个概念后面学漏洞利用会经常用到:很多V8越界读写漏洞的根源,就是V8对孔洞的校验逻辑出现了错误,导致攻击者可以绕过边界检查,读写数组范围外的内存。

思考2:快属性和慢属性之间的区别是什么?

二者之间本质区别:访问方式的差异

类型 存储结构 访问速度 依赖 适用场景
快属性(Fast Properties) 线性数组 极快(O(1),和C++访问结构体成员速度相当) 依赖Map的描述符数组 属性结构稳定、添加顺序固定的对象
慢属性(Slow Properties / 字典模式) 哈希表 表 慢(O(1)平均复杂度,但有哈希计算、冲突处理开销,比快属性慢几倍到几十倍) 不依赖Map,自包含哈希表 属性频繁增删、结构非常不规律的对象

1. 快属性详解

快属性是V8默认的属性存储模式,核心是用Map的描述符数组记录属性的位置,用线性数组存属性值

(1)核心结构

每个对象有三块核心内存:

1
2
3
对象内存首地址 → [ 指向Map的指针 | 指向Elements数组的指针 | 指向Properties数组的指针 | In-Object属性值... ]
Properties数组 → [ 属性1的值 | 属性2的值 | 属性3的值 ... ]
Map里的Descriptor数组 → [ 属性1的元信息(名字、类型、偏移量) | 属性2的元信息 | 属性3的元信息 ... ]

(2)两种快属性细分

  • In-Object属性:属性值直接存在对象本身的内存里,连Properties数组的跳转都不需要,是最快的属性。一般对象初始化时定义的前N个属性(不同V8版本N不同,一般是前几个)会被分配为In-Object属性,数量固定。
  • 普通快属性:属性值存在独立的Properties数组里,需要多一次内存跳转,速度稍慢,但可以动态扩展容量。

(3)访问流程示例

比如要访问obj.x

  1. 拿到obj的Map,查Map的Descriptor数组,找到x对应的偏移量是0
  2. 如果x是In-Object属性:直接按偏移量读对象本身的内存,拿到值
  3. 如果是普通快属性:去Properties数组读索引0的位置,拿到值
    整个过程不需要任何哈希计算,和C++里obj.x的访问速度几乎一样。

2. 慢属性(字典模式)详解

当对象的属性变化不符合Map的过渡规则时,V8会将对象不可逆地降级为慢属性模式,放弃Map优化,改用哈希表存储。

(1)触发降级的常见条件

  • 动态添加的属性太多(一般超过20个,阈值随V8版本调整)
  • 删除了非最后添加的属性(比如先加x、再加y,然后删除x,破坏了Map过渡的顺序)
  • 属性名非常不规律,比如大量随机字符串、数字字符串作为属性名

(2)核心结构

降级后,Map不再维护属性的偏移信息,对象的Properties位置直接指向一个哈希表:

1
Properties哈希表 → { "x": 1, "y": 2, "z": 3 ... }

(3)访问流程

访问obj.x时,需要对x做哈希计算、处理哈希冲突、查表才能拿到值,速度比快属性慢很多,优点是不需要维护Map过渡链,适合属性频繁增删的场景,内存开销更低。

✅ 验证方式

你可以在V8的d8 shell里用%DebugPrint(obj)直接看到对象的属性模式:

1
2
3
4
5
# 快属性模式的对象输出会有这个标记
- map: 0x025300259735 <Map[20](HOLEY_ELEMENTS)> [FastProperties]

# 慢属性模式的对象输出会有这个标记
- map: 0x025300259735 <Map[20](HOLEY_ELEMENTS)> [DictionaryProperties]

为什么漏洞利用会关注这个?

很多V8类型混淆漏洞的核心原理就是:攻击者通过特殊构造的JS代码,让V8错误地判断对象是快属性/慢属性模式,从而计算出错误的内存偏移,最终实现越界读写内存。

3、Map的完整结构

我们首先明确:这里的Map是V8内部的HiddenClass(隐藏类),也叫Shape,和JavaScript原生的Map对象是完全不同的概念,后面提到的Map默认都指V8内部的隐藏类。

Map是V8用来描述堆对象内存布局的「元数据」,是所有属性访问、类型判断的核心依据,完整结构如下:

3.1 Map核心字段(通用结构,不同V8版本字段顺序/数量略有差异)

你可以在d8调试时用%DebugPrint(obj)直接看到Map的所有字段,对应教程里的调试输出例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
0000025300259735: [Map] in OldSpace
- type: JS_OBJECT_TYPE
- instance size: 20
- inobject properties: 2
- elements kind: HOLEY_ELEMENTS
- unused property fields: 0
- enum length: invalid
- stable_map
- back pointer: 0x0253002596ed <Map[20](HOLEY_ELEMENTS)>
- prototype_validity cell: 0x0253002043cd <Cell value= 1>
- instance descriptors (own) #2: 0x02530010a539 <DescriptorArray[2]>
- prototype: 0x025300244669 <Object map = 0000025300243D25>
- constructor: 0x02530024422d <JSFunction Object (sfi = 000002530021BA25)>
- dependent code: 0x0253000021e1 <Other heap object (WEAK_ARRAY_LIST_TYPE)>
- construction counter: 0

每个核心字段的含义:

字段 作用
type 对象类型标记,比如JS_OBJECT_TYPE普通对象、JS_ARRAY_TYPE数组、JS_FUNCTION_TYPE函数等,V8通过这个字段判断对象的基础类型
instance size 该类型对象在堆上占用的总字节数,V8分配内存时会按这个大小分配
inobject properties In-Object快属性的数量,也就是对象本身内存里直接能存多少个属性,超过这个数量的属性会存到独立的Properties数组里
elements kind 数组/对象的索引属性类型,就是我们之前讲的PACKED_SMI_ELEMENTS/HOLEY_ELEMENTS等21种类型,是V8优化数组操作的核心依据
back pointer 反向过渡指针,指向当前Map是从哪个Map过渡来的(比如给空对象加x属性后生成新Map,新Map的back pointer就指向空对象的Map)
instance descriptors 指向描述符数组(DescriptorArray),是快属性的核心:数组每个元素对应一个属性的元信息:属性名、属性类型(数据属性/访问器属性)、属性在In-Object/Properties数组里的偏移量、可写/可枚举/可配置标记
prototype 指向该类型对象的原型对象(对应JS的__proto__
constructor 指向创建该对象的构造函数(对应JS的constructor
dependent code 指向依赖这个Map的优化后的机器码:如果这个Map被废弃(比如对象结构发生变化),所有依赖它的优化代码都会被标记为无效,触发去优化(deoptimize)
stable_map标记 表示当前Map是稳定的,TurboFan可以基于这个Map做优化,如果后续Map结构发生变化,这个标记会被清除

📌 Map的过渡树结构

Map不是孤立存在的,V8会把所有通过「顺序添加属性」生成的Map连成一个过渡树,相同结构的对象会复用同一个Map,避免重复创建:

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
// 1. 空对象,对应Map A
const a = {}
// 2. 加x属性,过渡到Map B(back pointer指向Map A)
a.x = 1
// 3. 加y属性,过渡到Map C(back pointer指向Map B)
a.y = 2

// 4. 新建另一个空对象,还是用Map A
const b = {}
// 5. 同样先加x再加y,不会新建Map,直接复用之前的Map B、Map C
b.x = 100
b.y = 200

重要规则!!!:

  1. 只有属性添加顺序完全一致,结构才会复用同一个Map。如果先加y再加x,会生成完全独立的另一条过渡链,不会复用上面的Map。
  2. 一旦删除非最后添加的属性、或者添加太多属性,对象会降级到字典模式,脱离过渡树,不再共享Map。

✅ 为什么Map是漏洞利用的核心?

V8完全依赖Map来判断对象的结构、计算属性的内存偏移,如果攻击者能篡改某个对象的Map指针,就可以欺骗V8:

  • 比如把一个普通对象的Map改成数组的Map,V8就会把这个对象当成数组来解析,从而可以越界读写对象外的内存
  • 比如把一个只读属性的Map描述符改成可写,就可以修改原本不能修改的内存区域
    大部分V8类型混淆漏洞的本质,就是通过特殊构造的JS代码,让V8错误地给对象分配了不符合预期的Map,或者让攻击者有机会篡改对象的Map指针。

4. 指针标记(Pointer Tagging)

4.1 为什么需要指针标记?

前面我们提过,V8里Smi小整数是直接存在指针里的,不需要单独在堆上分配,这就带来一个问题:V8怎么区分一个值到底是Smi整数,还是指向堆对象的指针? 指针标记就是用来解决这个问题的,利用的是x86/x64系统的内存对齐特性:堆上分配的对象都是按4/8字节对齐的,所以内存地址的最低两位一定是0,V8就用这两位来做标记。

4.2 x64架构下的标记规则

值类型 最低位标记 存储规则 示例
Smi(小整数) 0 整数左移1位,把最低位空出来写0 真实值是1,内存里存的是2(二进制10,最后一位是0
强指针(指向堆对象,GC需要引用计数) 1,次低位是0 真实的内存地址,最低位强制设为1 真实的对象地址是0x123456780,内存里存的是0x123456781
弱指针(指向堆对象,GC不需要引用计数) 1,次低位是1 真实的内存地址,最低两位设为11 真实的对象地址是0x123456780,内存里存的是0x123456783

4.3 强调实战技巧

你在WinDBG/GDB里看V8内存的时候,看到一个值:

  • 如果最后一位是0 → 右移1位就是真实的整数值
  • 如果最后一位是1 → 减1就是真实的堆对象内存地址

对应教程例的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
root@x2n:/home/x2n/Documents/v8/v8/out/x64_.release# ./d8 --allow-natives-syntax
V8 version 9.6.180.6
d8> var obj = {x:1, y:2};
undefined
d8> %DebugPrint(obj)
DebugPrint: 0x3791080498ed: [JS_OBJECT_TYPE]
- map: 0x379108207aa1 <Map(HOLEY_ELEMENTS)> [FastProperties]
- prototype: 0x3791081c41f5 <Object map = 0x3791082021b9>
- elements: 0x37910800222d <FixedArray[0]> [HOLEY_ELEMENTS]
- properties: 0x37910800222d <FixedArray[0]>
- All own properties (excluding elements): {
0x3791081d3111: [String] in OldSpace: #x: 1 (const data field 0), location: in-object
0x3791081d3121: [String] in OldSpace: #y: 2 (const data field 1), location: in-object
}
0x379108207aa1: [Map]
- type: JS_OBJECT_TYPE
- instance size: 20
- inobject properties: 2
- elements kind: HOLEY_ELEMENTS
- unused property fields: 0
- enum length: invalid
- stable_map
- back pointer: 0x379108207a79 <Map(HOLEY_ELEMENTS)>
- prototype_validity cell: 0x379108142405 <Cell value= 1>
- instance descriptors (own) #2: 0x37910804991d <DescriptorArray[2]>
- prototype: 0x3791081c41f5 <Object map = 0x3791082021b9>
- constructor: 0x3791081c3e2d <JSFunction Object (sfi = 0x37910814474d)>
- dependent code: 0x3791080021b9 <Other heap object (WEAK_FIXED_ARRAY_TYPE)>
- construction counter: 0

{x: 1, y: 2}

直接查看0x3791080498ed,发现内容是乱的。

image-20260323191204748

调试输出里的对象地址是0x3791080498ed,最后一位是d,二进制表示为1101,去掉最后一位1,就是1100c,所以真实地址为真实地址是0x3791080498ec

image-20260323195426610

⚠️这里的黄色箭头我指错了,我想指真实地址中的内容,后面仔细看了文章才发现指错了!。我想指x/10wx 0x3791080498ed-1地址中的内容,这里0x37910800222d0x2d080022明显对不上,下面的0x0800222d才是对的,这也正说明了x/10wx 0x3791080498ed-1才是真实的地址。

然后我们看绿色箭头部分。对象中x属性值是2,右移1位得到真实值1,y属性值是4,右移1位得到真实值2。

我们看内存中的值0x000000020x00000004,最后2和4分别为00100100,分别右移一位,就是00010010,就是真实的1和2值!!!。

image-20260323200046224

✅ 漏洞利用注意:你构造任意地址读写的时候,读出来的Smi值需要右移1位才是真实值;写Smi值的时候需要左移1位,把最后一位设成0,否则V8会把它当成指针处理。

5. 指针压缩(Pointer Compression)

5.1 为什么需要指针压缩?

x64架构下指针是8字节的,而V8堆上大部分对象里都存了大量指针,导致内存开销非常大。V8团队发现,V8的堆内存一般不会超过4GB,而且堆上的对象地址的高32位都是相同的,所以可以只存低32位的地址,高32位存在一个固定的寄存器(R13寄存器,叫Isolate Root指针)里,这样指针就从8字节压缩到了4字节,内存开销直接减少一半。

这里也可以在上面的调试中看出来,调试值只有后八位!例如:0x37910800222d,在查看内存中的内容时候,只有0x0800222d!!!

5.2 压缩规则

  • V8的整个堆内存会被映射到同一个4GB的虚拟内存区域,所有堆对象的高32位地址都相同,这个高32位的基地址存在R13寄存器里
  • 对象里存的指针只有低32位,访问的时候,用基地址 + 低32位地址就能得到完整的64位指针
    对应教程里的例子:你在DBG里看对象里存的Map指针,只有低32位是有效的,高32位全是0,加上R13寄存器里的基地址才是完整的Map地址。

5.3 漏洞利用注意

  • 不同版本V8的指针压缩开关可能不一样,新版本V8默认开启指针压缩,老版本可能关闭
  • 如果你写的POC要跨版本运行,需要先判断目标V8是否开启了指针压缩
  • 构造任意地址读写的时候,如果开启了指针压缩,只需要覆盖指针的低32位就可以控制指针指向的地址。

参考


  1. Chrome学习-V8和JavaScript内部机制介绍 https://x2nn.github.io/2026/03/18/Chrome学习-V8和JavaScript内部机制介绍/ ↩︎

  2. Chrome Browser Exploitation, Part 1: Introduction to V8 and JavaScript Internals https://jhalon.github.io/chrome-browser-exploitation-1/ ↩︎