Chrome 学习:V8和JavaScript内部机制介绍

二进制安全 浏览器安全

前言

这篇文章主要整理自 Jack Halon 的 Chrome Browser Exploitation, Part 1: Introduction to V8 and JavaScript Internals,目标不是把 V8 的所有实现细节一次讲完,而是先建立一套够用的心智模型。

如果你是从浏览器漏洞利用的角度学习 V8,那么这一篇最重要。因为后面很多 type confusion、数组越界、JIT 假设错误,最后都会落回这几个基础问题:

  1. JavaScript 引擎是怎么执行代码的?
  2. V8 在内存里怎么表示一个对象?
  3. MapPropertiesElements 分别是什么?
  4. 为什么指针标记和指针压缩会让调试结果看起来“不对劲”?

本文以“经典 V8 学习模型”为主,重点是帮助理解对象模型和漏洞分析语境下的核心概念,而不是逐行追踪最新版本 V8 的全部实现细节。

V8 是什么?

JavaScript 代码本身不能直接在浏览器里“凭空执行”,它需要一个 JavaScript 引擎。V8 就是 Google Chrome 使用的 JavaScript 引擎之一。

常见的 JavaScript 引擎包括:

  • V8:Google 的 JavaScript / WebAssembly 引擎,用于 Chrome。
  • SpiderMonkey:Mozilla 的引擎,用于 Firefox。
  • ChakraCore:微软曾使用过的 JavaScript 引擎。
  • JavaScriptCore:Apple 在 Safari / WebKit 中使用的引擎。

它们的实现方式并不完全相同,但都要遵守 ECMAScript 规范。也就是说,浏览器内部怎么优化可以不同,但 JavaScript 语义最终必须一致。

先看整体流程:JavaScript 引擎是怎么工作的

先别急着扎进对象布局。先把整条执行链路记住,后面很多细节才有落点。

可以把 JavaScript 引擎的执行过程粗略理解成 3 步:

  1. Parser(解析器)
    负责把源码拆成 token,再进一步组织成 AST。

  2. Interpreter(解释器)
    负责把 AST 转成字节码,并执行字节码。

  3. Compiler(编译器)
    负责观察热点代码,并把它进一步编译成优化后的机器码。

1. Parser:从源码到 AST

以这行代码为例:

1
var num = 42;

解析器首先会把它切成 token,例如:

1
var, num, =, 42

并分别标记类型:

1
Keyword, Identifier, Operator, Number

之后再把这些 token 组装成 AST。AST 可以理解成“代码的结构化语法树”,它不是源码文本,而是更适合引擎继续处理的数据结构。

2. Interpreter:从 AST 到字节码

当 AST 生成之后,解释器会继续把它转成字节码。对于 V8 来说,这部分由 Ignition 完成。

例如 var num = 42; 可能会生成类似下面这样的字节码:

1
2
3
4
5
6
7
8
LdaConstant [0]
Star1
Mov <closure>, r2
CallRuntime [DeclareGlobals], r1-r2
LdaSmi [42]
StaGlobal [1], [0]
LdaUndefined
Return

先不用记住每条指令的含义。这里真正重要的是:

JavaScript 在 V8 里不会直接变成 CPU 机器码,而是会先变成字节码,再交给解释器执行。

3. Compiler:热点代码会被进一步优化

解释执行足够灵活,但也会有额外开销。于是当某些函数足够“热”时,编译器就会介入。

在经典 V8 流水线里,你可以把角色理解成:

  • Ignition:解释器,负责生成和执行字节码。
  • Sparkplug:基线编译器,快速把字节码变成机器码。
  • TurboFan:优化编译器,负责更激进的优化。

这一篇不深入讲 JIT 优化细节,但有一件事需要先记住:

V8 想要“快”,就必须知道对象长什么样、属性放在哪、数组里装的是什么类型。

这正是后面 MapPropertiesElements 这些结构存在的原因。

为什么 JavaScript 的“动态性”会给引擎带来困难

JavaScript 是动态类型语言。类型不是绑定在变量名上的,而是绑定在运行时值上的。

例如:

1
2
var item = 42;
typeof item; // "number"

然后你完全可以在运行时把它改成字符串:

1
2
item = "Hello!";
typeof item; // "string"

这在 JavaScript 里很正常,但对 V8 这种用 C++ 写的引擎来说就不那么轻松了。因为它需要在运行时持续判断:

  • 这个值到底是对象、整数、浮点数还是字符串?
  • 这个对象当前有哪些属性?
  • 这些属性存在哪?
  • 这个数组里装的是整数、double,还是对象引用?

如果这些判断和假设出了错,性能会下降;如果优化阶段基于错误假设生成了机器码,甚至可能演变成安全漏洞。

从漏洞分析角度看,很多类型混淆问题,本质上就是“引擎对对象布局或值类型的理解和真实情况不一致”。

JavaScript 对象在 V8 里是怎么表示的

要理解这一点,先从 JavaScript 世界的“对象”说起。

在语言层面,对象本质上是一组键值对。它可以是普通对象、数组、函数、布尔包装对象、正则对象等等。

每个属性还带有一组属性特性,比如:

  • value
  • writable
  • enumerable
  • configurable

但站在 V8 内部看,一个 JavaScript 对象不会直接以“高层语义”形式存在。每当创建一个对象时,V8 都会在堆上分配一个 JSObject

一个简化后的 JSObject 可以先记成这样:

1
2
3
4
5
6
JSObject {
map -> 对象的结构说明书
properties -> 命名属性的存储区
elements -> 索引属性的存储区
in-object fields -> 直接放在对象内部的属性槽
}

先记住一句最重要的话:

Map 决定“属性在哪”,Properties / Elements 决定“值放在哪”。

Map 是什么:V8 里的 Hidden Class

在 V8 中,Map 不是 ECMAScript 里的 Map 对象,而是另一种内部数据结构。它也常被称为:

  • Hidden Class
  • Shape

它的职责可以粗略概括为:

描述一个对象当前的结构和布局。

Map 里记录了什么

一个 Map 通常会记录这些信息:

  • 对象类型,例如 JSObjectJSArrayHeapNumber
  • 对象大小
  • 对象有多少个 in-object 属性槽
  • 命名属性的布局信息
  • 数组元素的 kind
  • 原型相关信息

所以如果把它类比成静态语言中的概念,Map 更接近于:

1
“这个对象的内存布局说明书”

而不是“属性值仓库”本身。

为什么 Map 存的是偏移,不是值

假设你有两个对象:

1
2
var obj1 = {x: 1, y: 2};
var obj2 = {x: 2, y: 3};

它们的值不同,但结构相同:

  • 都有 x
  • 都有 y
  • 顺序也相同

这就意味着,它们可以共享同一个 Map。如果每个对象都重复存一份完整的“属性名到属性位置”的描述,不仅浪费内存,也会拖慢访问速度。

所以 V8 的做法是:

  • Map 只记录属性名及其偏移
  • 具体值存在对象本身或独立的 backing store 中

也就是说,Map 更像这样:

1
2
x -> offset 0
y -> offset 1

而不是:

1
2
x -> 1
y -> 2

DescriptorArray:命名属性布局的关键

Map 之所以能描述对象形状,关键就在于它关联的 DescriptorArray

可以把 DescriptorArray 理解成:

“属性名 -> 属性位置/属性描述” 的元数据表

它里面保存的不是最终值,而是类似下面这种信息:

1
2
x -> offset 0
y -> offset 1

于是 V8 就知道:对于所有共享这个 Map 的对象,x 应该去哪个槽位读,y 应该去哪个槽位读。

对象形状是怎么变化的:Shape Transition

JavaScript 对象不是静态的,运行时可以继续加属性:

1
2
3
var obj1 = {};
obj1.x = 1;
obj1.y = 2;

V8 不会在原地“模糊修改”原有形状,而是会随着属性增加,不断让对象沿着一条 transition path 演进。

你可以把上面这个过程理解成:

1
2
3
4
5
C0: {}
-- add x -->
C1: {x}
-- add y -->
C2: {x, y}

继续加 x 后,对象会从 C0 过渡到 C1

再加 y,就会继续过渡到下一个 Map

这些过渡连起来,就形成了一棵 transition tree。

属性顺序也会影响 Shape

这一点非常重要。

下面两个对象看起来只是“属性一样”,但它们的 shape 不一定相同:

1
2
3
4
5
6
7
var a = {};
a.x = 1;
a.y = 2;

var b = {};
b.y = 2;
b.x = 1;

原因是属性添加顺序不同,transition path 也不同。

从优化角度看,这就是为什么“结构稳定、构造顺序稳定”的对象更容易被优化。

d8 看看 Map 到底长什么样

只讲概念很容易发虚,最好的方式还是直接看 V8 的调试输出。

先在 d8 里创建一个对象:

1
var obj1 = {a: 1, b: 2, c: 3};

然后用:

1
%DebugPrint(obj1)

这个输出里最值得关注的几块信息是:

  • 对象本身的地址
  • map 指针
  • instance descriptors
  • 各个属性的 location

你会看到:

  • 对象关联了某个 Map
  • Map 里有对应的 DescriptorArray
  • 属性可能位于 in-object

这正好和前面的理论对应上:对象负责存值,Map 负责告诉 V8 这些值该怎么解释。

多个同形对象会共享一个 Map

如果我们创建两个 shape 一样的对象:

1
2
var obj1 = {x: 1, y: 2};
var obj2 = {x: 2, y: 3};

再分别 %DebugPrint(),会发现它们虽然地址不同,但会共享同一个 Map

这正是 Hidden Class 优化最核心的一点:

结构相同的对象,共享同一份布局描述。

这样一来,属性访问就不需要每次都像字典那样查找字符串键,而可以更像:

1
2
3
先看 map
确认 layout
按固定 offset 取值

这也是 JavaScript 引擎能把对象访问做得很快的重要原因。

back_pointer 和 transition tree

如果继续沿着 Map 里的 back_pointer 看,还能回溯到前一个 shape。

这说明 transition tree 不是抽象概念,而是真实存在于 Map 之间的链接关系。

Properties:命名属性的存储

讲完 Map,再回到对象的“值到底放哪”。

命名属性,也就是像下面这种字符串 key:

1
2
3
obj.x
obj.y
obj["name"]

在 V8 里通常有两种主要存法。

1. In-object properties

最理想的情况,是属性直接放在对象本体内部:

1
2
3
4
obj:
[map]
[x]
[y]

这种方式最快,因为访问时几乎没有额外间接层。

2. Properties backing store

如果对象属性变多,超出了对象初始预留的 in-object 槽位,剩余属性就会被放到 properties backing store 里。

这时访问逻辑会变成:

1
2
Map 记录 offset
对象或 properties store 存实际值

Fast Properties 和 Slow Properties

V8 还会根据对象变化的频率和复杂度,区分两种模式:

  • Fast Properties
    通过 Map + DescriptorArray + offset 来访问,适合结构稳定的对象。

  • Slow Properties
    也常被叫做 dictionary mode。属性元数据不再主要依赖共享的 Map,而是直接存进字典结构。

这背后的直觉也很简单:

如果一个对象老是在增删属性,继续维护漂亮的 transition tree 成本太高,不如退化成字典模式。

所以:

  • 结构稳定的对象更快
  • 经常增删属性的对象更容易进入慢路径

Elements:数组和整数索引属性

除了命名属性,V8 还专门把“整数索引属性”单独拿出来处理,也就是 Elements

例如:

1
2
3
const arr = [1, 2, 3];
arr[0];
arr[1];

这些就不走普通的 named properties,而是走 elements 存储。

之所以要单独拆开,是因为数组访问模式和对象属性访问模式差异很大:

  • 对象属性更像字符串键查找
  • 数组元素更像连续索引访问

因此 V8 会对 elements 做非常细的分类和优化。

V8 会跟踪数组的 Elements Kind

JavaScript 语言层面上,[1,2,3] 看起来只是“number 数组”。但在 V8 眼里,这还不够精细。

以这行代码为例:

1
const array = [1, 2, 3];

V8 往往会把它标记为 PACKED_SMI_ELEMENTS

  • SMI:小整数
  • PACKED:数组比较紧凑,没有洞

对学习来说,先记住 3 种基本元素类别就够了:

  • SMI_ELEMENTS:小整数数组
  • DOUBLE_ELEMENTS:浮点数数组
  • ELEMENTS:对象引用、字符串等更一般的元素

Elements Kind 是按“整个数组”跟踪的

重点不是每个元素单独贴标签,而是:

V8 会给整个数组一个当前的 elements kind。

例如:

1
2
3
4
5
const array = [1, 2, 3];
// PACKED_SMI_ELEMENTS

array.push(3.337);
// PACKED_DOUBLE_ELEMENTS

一旦数组里出现了更宽的值类型,elements kind 就会沿着一个单向路径下降。一般来说:

  • SMI 可以降到 DOUBLE
  • DOUBLE 还可以继续降到更通用的 ELEMENTS

这个过程通常是不可逆的。也就是说,数组一旦“变杂”,优化空间往往只会变小,不会自动回到最理想状态。

PACKED 和 HOLEY

除了元素类型,V8 还非常在意数组里有没有“洞”。

例如:

1
2
3
4
5
const packed_array = [1, 2, 3, 5.5, "x"];
// PACKED_ELEMENTS

const holey_array = [1, 2, , 5, "x"];
// HOLEY_ELEMENTS

PACKED 表示数组是稠密的;HOLEY 表示数组存在空洞。

为什么这个区别重要?

因为 packed array 更容易优化。数组没有洞时,V8 可以更激进地假设:

  • 索引范围更规整
  • 访问路径更简单
  • 检查更少

而一旦变成 holey array,很多访问都必须更保守。

Elements 也会退化成字典模式

不仅 named properties 会退化成 dictionary mode,elements 也会。

典型场景是:

  • 超大稀疏数组
  • 索引分布很离散

这时如果还强行维持紧凑数组存储,浪费太大,于是 V8 会用类似字典的结构来保存元素。

现在回到 exploitation 视角:为什么这些细节重要

到这里,其实可以把问题压缩成一句话:

V8 为了优化速度,需要持续记录对象的 shape、属性布局和数组元素类型。

而浏览器漏洞里常见的问题,恰恰就是这些“运行时事实”被错误理解了。

最常见的几条主线是:

  1. 对象 shape 被错误假设
    编译器以为对象还是老的 Map,但实际已经不是。

  2. elements kind 被错误假设
    编译器以为数组还是 PACKED_SMI_ELEMENTS,但实际已经过渡到更宽的 kind。

  3. 属性布局解释错位
    本来该按对象 A 的 layout 读,结果按对象 B 的 layout 去读。

  4. 字典模式 / 快模式切换处理出错
    某些优化假设对象仍处于快模式,实际却已经退化。

这就是很多 type confusion 的底层背景。

在内存里看对象:为什么调试输出会让人困惑

学到这里,你已经知道:

  • 对象在堆上
  • 对象里有 map / properties / elements
  • 小整数和对象引用不一定按最直观的方式存

于是接下来就可以进入一个非常容易让初学者卡住的部分:为什么在调试器里看到的地址和 DebugPrint 输出对不上?

先看一个 d8 例子:

1
2
var obj = {x: 1, y: 2};
%DebugPrint(obj);

如果你再用调试器直接去读这块内存,第一眼往往会怀疑:

  • 地址是不是错了?
  • 指针是不是坏了?
  • 为什么 map 指针看起来不像 map 指针?

答案通常不是“你看错了”,而是 V8 的两种优化一起生效了:

  • pointer tagging
  • pointer compression

Pointer Tagging

V8 不是把所有值都老老实实地当成完整堆对象来处理。特别是对小整数这类高频值,如果每次都专门分配一个堆对象,成本太高。

于是它会把一部分值以内联的形式编码进去。但这样立刻带来一个问题:

眼前这个 machine word,到底是堆对象指针,还是一个被编码过的立即值?

这就是 pointer tagging 要解决的事。

可以怎样理解它

一个简化理解是:

  • 某些低位 bit 被拿来当“标签”
  • 标签决定这个值是对象指针还是 SMI

在经典 V8 调试语境里,常见的理解方式是:

1
2
Pointer: ... ... ... ... (w1)
SMI: int32_value ... (0)

也就是说:

  • 低位用于区分“这是指针还是 SMI”
  • 对象指针在真正解引用前,需要先去掉 tag

原文里的 WinDBG/GDB 场景之所以看着“差一位”,本质就在这里。

为什么 SMI 在内存里看起来像“翻倍”

如果你在调试器里看到:

  • 逻辑上的 1
  • 内存里却像 2

不要立刻怀疑对象坏了。很多时候,这是因为 SMI 的编码方式导致它看起来像被左移/翻倍过。

这也是为什么直接肉眼看内存时,很容易误判。

结合调试图来看

原文的调试过程里,去掉低位 tag 之后,地址就变得合理了。

你文中后来补的这张图也很好,正好说明了“真正该对照的是去掉 tag 后的地址内容”。

Pointer Compression

接下来还有第二层优化:pointer compression。

它利用了一个非常现实的事实:

同一个 isolate / heap 里的对象,地址通常彼此很接近。

既然高位经常相同,那就没必要每个指针都完整存 64 位。

于是 V8 会:

  • 在内存里只保存压缩后的低位部分
  • 把高位基址放在专门的寄存器或根基址里
  • 真正访问时再把两部分拼回完整地址

这就是为什么你有时会看到:

  • DebugPrint 里是完整地址
  • 内存转储里只有“半截看起来能对上的指针”

这不是坏数据,而是压缩指针的正常表现。

到这里,应该把什么记住

如果你只带走几句话,我建议记住这几条:

  1. JavaScript 引擎不是只会“解释执行”,现代 V8 会经过解释、基线编译和优化编译多个阶段。
  2. V8 想优化对象访问,就必须记录对象的 shape,这就是 Map / Hidden Class 的意义。
  3. Map 存布局,PropertiesElements 存实际值。
  4. 结构相同的对象会共享 Map,结构变化会触发 shape transition。
  5. 数组不仅有值,还有 elements kind,比如 SMIDOUBLEPACKEDHOLEY
  6. 调试内存时看到“地址不对劲”,优先考虑 pointer tagging 和 pointer compression,而不是先怀疑对象损坏。

总结

这篇文章其实是在给后面的 JIT 和漏洞利用内容打地基。

如果不先理解:

  • 对象在 V8 里长什么样
  • Map 怎么描述布局
  • Properties / Elements 怎么分工
  • 数组为什么会有不同的 elements kind
  • 指针为什么会被标记和压缩

那么后面看 type confusion、越界访问、错误优化时就很容易失去方向。

所以在我看来,这一篇最重要的收获不是记住所有结构名,而是建立这样一个直觉:

V8 的很多优化,本质上都建立在“对象结构稳定、值类型可预测”这个前提上;而浏览器漏洞,恰恰经常发生在这个前提失效、却仍被错误相信的时候。

下一篇再回到编译器流水线,就会更容易理解 Ignition、Sparkplug 和 TurboFan 到底在优化什么。

参考