Chrome 学习:Ignition、Sparkplug 和 TurboFan JIT 编译

二进制安全 浏览器安全

前言

这篇笔记主要整理自 Jack Halon 的文章 Chrome Browser Exploitation, Part 2: Introduction to Ignition, Sparkplug and JIT Compilation via TurboFan,并结合 Chromium/V8 官方文档做了补充。

如果还没看过上一篇 Chrome Browser Exploitation, Part 1: Introduction to V8 and JavaScript Internals,建议先看。因为这篇默认读者已经理解了对象布局、Map/Shape、指针标记、指针压缩、内联缓存(IC)这些基础概念。

版本说明

本文重点整理的是经典的 Ignition -> Sparkplug -> TurboFan 这条学习主线,它非常适合理解旧版 V8 的 JIT 行为,也很适合阅读浏览器利用相关文章。

但如果把它当成 2026 年 V8 的完整现状就不准确了。V8 官方在 2023 年 12 月 5 日发布的 Maglev 文章里说明,Chrome M117 已经引入了 Maglev;又在 2025 年 3 月 25 日发布的 Land ahoy: leaving the Sea of Nodes 中说明,TurboFan 的 JavaScript 后端已经大量迁移到 Turboshaft。也就是说,本文更适合建立“漏洞分析语境下的经典 V8 心智模型”,而不是追求当前实现的逐行对应。

本文重点回答 4 个问题:

  1. JavaScript 代码在 V8 里到底是怎么一步步执行起来的?
  2. Ignition、Sparkplug 和 TurboFan 各自负责什么?
  3. 反馈向量、类型守卫、反优化(deopt)这些概念为什么重要?
  4. 这些优化为什么会演变成 JIT 漏洞?

先记住整条主线

如果只想先抓住主干,可以先记住下面这条链路:

1
2
3
4
5
6
7
8
9
10
JavaScript 源码
-> Parser
-> AST
-> BytecodeGenerator
-> BytecodeArray
-> Ignition 解释执行
-> 收集 IC / FeedbackVector 反馈
-> Sparkplug 快速编译为基线机器码
-> TurboFan 基于反馈做推测优化
-> 类型守卫失败时 deopt 回到未优化代码

从漏洞分析角度看,最关键的不是“它会不会优化”,而是:

  • 它根据什么信息做优化。
  • 它做了哪些推测。
  • 它如何验证这些推测。
  • 推测失效时如何回退。

这几件事一旦有一处做错,就可能出现类型混淆、越界访问或者错误的机器码生成。

Chrome 安全模型

在看编译器之前,先把浏览器的大框架放在脑子里。V8 并不是孤立运行的,它是嵌在 Chrome/Blink 里的。而浏览器利用往往不是“拿下一个 JavaScript bug 就等于拿下整个系统”,原因就在于 Chrome 的进程模型和沙箱。

多进程 + 沙箱

Chromium 采用多进程架构,把不同职责拆到不同进程里。最核心的两个角色是:

  • browser process:主进程,负责 UI、资源协调、进程管理。
  • renderer process:渲染进程,负责页面内容、Blink、V8 等。

在实际运行中,你通常会看到一个主进程和多个子进程。具体是“一标签一进程”还是“多个标签共享进程”,还会受到站点隔离、进程复用等策略影响,但对于理解漏洞利用来说,抓住“渲染器和浏览器主进程是隔离的”这一点就够了。

Chrome 还会进一步把渲染进程放进沙箱里,限制它对文件系统、网络、Cookie、显示输出和用户输入的直接访问。于是一个典型结论就出来了:

拿到 renderer 内的代码执行权限,并不等于拿到整个系统权限。

这也是为什么浏览器利用里经常会区分:

  • renderer RCE
  • sandbox escape
  • full chain exploitation

V8 的 Isolate 和 Context

在 Blink 中,V8 不是“全浏览器只有一个实例”。更准确地说,V8 通过 IsolateContext 来做执行环境隔离。

  • Isolate 可以理解为一个独立的 V8 虚拟机实例,拥有自己的堆、GC、运行时状态。
  • Context 可以理解为某个全局对象对应的执行上下文,比如一个 window/iframe 所在的 JavaScript 世界。

通常可以把关系理解为:

  • 一个线程对应一个 Isolate
  • 一个 Isolate 里可以有多个 Context

这件事对安全很重要,因为不同脚本、不同 frame 的对象不能乱串。如果进入了错误的 Context,轻则读写错对象,重则直接变成安全问题。

想继续深挖的话,可以看:

Ignition:V8 的字节码解释器

现在回到 V8 本身。

在经典流水线里,JavaScript 源码先被解析成 AST,然后由 BytecodeGenerator 生成字节码,再交给 Ignition 执行。

为什么 Ignition 很重要

Ignition 不只是“先跑一遍”的解释器。它至少做了 3 件后续编译阶段离不开的事:

  1. 把 AST 变成字节码。
  2. 建立函数的解释器栈帧。
  3. 在执行过程中收集反馈数据,供后续优化器使用。

所以从 JIT 漏洞视角看,Ignition 是整个优化链条的起点。

寄存器机器,其实是“栈上的虚拟寄存器”

Ignition 是一个基于寄存器的解释器,并带有一个累加器(accumulator)。

这里的“寄存器”并不是 CPU 的物理寄存器,而是函数栈帧里的一组槽位,也就是所谓的虚拟寄存器。每条字节码会把输入和输出写到这些槽位上,而不是直接操作真实硬件寄存器。

这也是为什么理解 Ignition 时,栈帧布局特别重要。

上图里可以把内容分成几块来看:

  • 红色部分:函数参数。
  • 绿色部分:局部变量和表达式求值需要的临时值。
  • 浅绿色部分:当前 Context、调用信息、JSFunction 指针等。

这里的 JSFunction 指针通常也可以理解成 closure,它会把你带到很多关键对象上,比如:

  • SharedFunctionInfo
  • Context
  • FeedbackVector

另一个容易混淆的点是:为什么图里看不到 accumulator?

因为 accumulator 不是解释器帧里的一个普通槽位。它更像解释器执行状态的一部分,由解释器配合机器寄存器和帧信息来维护。

谁来创建解释器栈帧

当 JavaScript 函数第一次以解释模式执行时,入口通常会走到 InterpreterEntryTrampoline

它负责做几件关键的事:

  • 为函数建立解释器栈帧。
  • 为寄存器文件分配空间。
  • 把这些寄存器槽先初始化成 undefined

最后一点很关键。因为 GC 会扫描栈帧,如果里面留下了未初始化垃圾值,GC 可能把它误当成有效引用。

字节码到底长什么样

V8 的字节码定义在 v8/src/interpreter/bytecodes.h

很多名字都很有规律:

  • Lda*:把值加载到 accumulator。
  • Sta*:把 accumulator 里的值存出去。
  • Star*:把 accumulator 存入某个寄存器。

例如:

1
V(LdaSmi, ImplicitRegisterUse::kWriteAccumulator, OperandType::kImm)

这表示 LdaSmi 会把一个立即数形式的小整数(SMI)加载到 accumulator。

除了字节码本身,函数还会关联一个 BytecodeArray。可以把它理解成“这个函数的字节码序列对象”。它不仅保存字节码流,还保存:

  • frame size
  • register count
  • constant pool
  • handler table 等辅助信息

其中 constant pool 也很重要。像字符串属性名、常量引用这些堆对象,不会直接硬编码在字节码指令里,而是通过 constant pool 间接引用。

用一个例子看懂字节码

考虑这个函数:

1
2
3
function incX(obj) {
return 1 + obj.x;
}

d8 --print-bytecode 下,可以看到类似下面的输出:

1
2
3
4
5
6
7
8
9
10
11
Bytecode length: 11
Parameter count 2
Register count 1
Frame size 8
0 : 0d 01 LdaSmi [1]
2 : c3 Star0
3 : 2d 03 00 01 LdaNamedProperty a0, [0], [1]
7 : 38 fa 00 Add r0, [0]
10 : a8 Return
Constant pool (size = 1)
0: <String #x>

可以按顺序理解:

  1. LdaSmi [1]
    把整数 1 放进 accumulator。

  2. Star0
    把 accumulator 里的 1 存进寄存器 r0

  3. LdaNamedProperty a0, [0], [1]
    从第一个参数寄存器 a0 里取出 obj,再根据 constant pool 中索引 0 对应的属性名 x,把 obj.x 加载到 accumulator。后面的反馈槽索引用于属性访问反馈收集。

  4. Add r0, [0]
    r0 里的 1 和 accumulator 中的 obj.x 做加法。

  5. Return
    返回 accumulator 里的结果。

这就是为什么我觉得字节码虽然一开始看着很“汇编”,但一旦摸清命名规律,理解难度其实不高。

Ignition 对漏洞分析意味着什么

如果站在利用角度看,Ignition 本身给你的最重要信息不是“它慢”,而是:

  • 栈帧是怎么布的。
  • 寄存器操作数怎么映射到槽位。
  • FeedbackVector 是从哪里开始被填充的。
  • SharedFunctionInfoBytecodeArray 如何成为后续编译阶段的输入。

后面 Sparkplug 和 TurboFan 的很多问题,本质上都是在消费这些信息时出了错。

Sparkplug:极快的基线编译器

在 Ignition 和 TurboFan 之间,V8 又插入了一层 Sparkplug。

官方在 2021 年 5 月 27 日发布的文章 Sparkplug — a non-optimizing JavaScript compiler 中,把它定义为一个极快的、非优化的 JavaScript 编译器。

Sparkplug 为什么快

核心原因就两个字:省事。

它快,不是因为它更聪明,而是因为它少做事:

  1. 它不再从 JavaScript 源码出发,而是直接从 Ignition 已经生成好的字节码出发。
  2. 它不做 TurboFan 那种重量级 IR 构建和复杂优化。
  3. 它尽可能复用解释器已经做好的工作,比如变量解析、控制流结构、函数帧布局。

可以把 Sparkplug 理解成:

不是“深入优化代码”,而是“尽快把字节码变成能直接跑的机器码”。

官方实现里,它基本上是对字节码做非常线性的遍历,并为每条字节码生成对应的机器码序列。它的优化机会非常有限,主要是局部的、便宜的改进,而不是全局重写程序。

它和 Ignition 的关系比想象中更近

Sparkplug 最巧妙的一点,不在于它能生成机器码,而在于它尽量保持“解释器兼容”的栈帧。

官方文档的说法是:Sparkplug 维护 interpreter-compatible stack frames。这意味着:

  • 调试器、异常处理、栈回溯、性能分析器不需要为它单独重写一整套逻辑。
  • 从 Ignition tier-up 到 Sparkplug 的成本很低。
  • OSR(on-stack replacement)更容易做。

这也是为什么很多资料都会强调 Sparkplug 和 Ignition 栈帧几乎是 1:1 对应的。

对学习来说,抓住这句话就够了:

Sparkplug 的核心价值不是“优化得多猛”,而是“几乎不增加额外复杂度,却能显著降低解释器的调度开销”。

Sparkplug 带来了什么收益

解释器运行字节码时,每一步都要反复做这些事:

  • 读当前字节码
  • 解码操作码
  • 查 dispatch table
  • 跳到对应 handler

这些步骤本身就有开销。Sparkplug 通过把这条“解释流程”固化成机器码,避免了重复的解码和分发开销,所以能明显提高执行速度。

OSR:为什么相似栈帧很重要

OSR(on-stack replacement)可以粗暴理解成:

程序执行到一半,把“当前正在跑的版本”切换成另一个版本。

在 V8 里,热点函数可能先由 Ignition 跑,之后换成 Sparkplug,再进一步进入更重的优化 tier。栈帧布局越接近,这种切换越容易做,也越不容易出错。

从利用视角看 Sparkplug

Sparkplug 自身不是最常见的 JIT bug 温床,因为它做的优化不多。但它仍然很重要,原因是:

  • 它要正确理解解释器帧布局。
  • 它要正确处理 tier-up 和 OSR。
  • 它要正确维护栈状态和元数据。

所以一旦 frame layout、寄存器计数、OSR 元数据出了问题,仍然可能变成可利用漏洞。历史上就出现过这类问题,比如 Issue 1179595

TurboFan:真正做“推测优化”的地方

前面的 Ignition 和 Sparkplug,更多是在解决“如何先把代码跑起来、再尽快跑得更快”。

TurboFan 解决的是另一个问题:

当某段代码已经足够热,能不能根据运行时反馈,做更激进的推测和优化,换来更高的峰值性能?

在经典 V8 模型里,这就是 TurboFan 的职责。

热点函数如何进入 TurboFan

我们看一个简单例子:

1
2
3
4
5
6
7
function hot_function(obj) {
return obj.x;
}

for (let i = 0; i < 10000; i++) {
hot_function({ x: i });
}

第一次看这个函数时,V8 并不会立刻为它生成重优化后的机器码,而是先让它跑起来,并观察它是不是“热函数”。

d8 --trace-opt 里,经常能看到类似输出:

1
2
3
[marking <JSFunction ...> for optimization to TURBOFAN ...]
[compiling method <JSFunction ...> (target TURBOFAN) OSR ...]
[completed optimizing <JSFunction ...> (target TURBOFAN) OSR]

这说明 V8 认为:这段函数调用次数足够多,值得把它送去更高 tier 重新编译。

为什么 TurboFan 必须“猜”

TurboFan 的难点在于:JavaScript 是动态语言,很多类型信息只有到运行时才知道。

例如:

1
2
3
function add(i) {
return 1 + i;
}

这里的 i 到底是什么?

  • number
  • string
  • object
  • 可以被 ToPrimitive 的值

从 ECMAScript 语义上看,+ 不是单纯的整数加法,它可能是:

  • 数值加法
  • 字符串拼接
  • 先做 ToPrimitive
  • 再做 ToString 或数值转换

如果编译器完全不做假设,那就只能保守地生成一大堆检查和慢路径代码,速度不会好。

所以 TurboFan 必须“猜”,也就是做 speculative optimization

它靠什么来猜:FeedbackVector 和 IC

这些猜测不是拍脑袋。TurboFan 主要依赖两类信息:

  • Inline Cache(IC)收集到的对象形状/访问模式信息
  • FeedbackVector 里记录的反馈槽

例如对 add(i) 这种二元操作,反馈向量里通常会有一个 BinaryOp 槽位,记录历史上看到的输入类型。

如果运行了一段时间后,它观察到 i 一直是小整数,那么对 TurboFan 来说,一个非常自然的推断就是:

这段代码大概率可以按“整数加法”来优化。

推测优化长什么样

继续以上面的 add(i) 为例。

如果历史反馈告诉 TurboFan:i 一直是 number,那么它可以走一条非常快的路径:

  • 不再按完整 ECMAScript 语义处理所有可能类型。
  • 直接把 1 + i 当成数值加法处理。
  • 生成更少、更直接的机器指令。

但这会立刻引出一个安全问题:

如果后面突然有人传进来的是字符串怎么办?

这时候就需要 类型守卫(type guard)

类型守卫和 deopt

TurboFan 的优化代码不是盲信历史反馈,而是会在关键位置插入守卫。只有守卫通过,才能继续跑优化后的机器码。

例如,你可能会在优化后的汇编里看到类似逻辑:

1
2
3
movq rcx,[rbp-0x38]
testb rcx,0x1
jnz bailout

它表达的意思非常直接:

  • 先把值取出来
  • 检查它是不是符合预期表示
  • 不符合就跳走

跳走之后去哪?去未优化代码,也就是回到解释器/较低 tier 的执行路径,这个过程就叫 deoptimization,简称 deopt

这一点非常关键:

TurboFan 的“快”,不是无条件的快,而是“在假设成立时走快路径;假设失效时立刻回退”。

一个典型 deopt 场景

还是用 add(i) 举例:

1
2
3
4
5
6
7
8
9
10
11
function add(i) {
return 1 + i;
}

for (let i = 0; i < 10000; i++) {
if (i < 7000) {
add(i);
} else {
add("string");
}
}

前 7000 次,add(i) 基本只看到了 number,于是 TurboFan 很可能先按 number-only 路径优化。

后面突然出现 "string",守卫不成立,于是发生 deopt,回到较低 tier 继续执行。

更有意思的是:如果这段函数仍然足够热,TurboFan 还可能再次编译它,只不过这一次的反馈更宽了,优化后的代码会兼容更多类型。

这也解释了为什么:

  • 同一个函数会被优化
  • 又被 deopt
  • 之后再被重新优化

Feedback Lattice:反馈为什么只会“越看越宽”

反馈系统可以理解成一种单向扩张的状态机。

一个简化的心智模型如下:

1
2
3
4
None
-> SignedSmall / Number / String / ...
-> 更宽的组合类型
-> Any

这里的重点不是精确枚举每个节点,而是理解两个性质:

  1. 反馈通常只会朝“更宽泛”的方向发展。
  2. 一旦退化到 Any,说明这段代码已经变得比较多态了,优化空间会变小。

这也对应两个常见术语:

  • monomorphic:反馈很稳定,比如某个属性访问总看到同一种 shape。
  • polymorphic:反馈开始变复杂,比如同一位置看到了多种 shape 或多种类型。

对 JIT 来说,可预测性越高,越容易出激进优化;可预测性越差,越容易走保守路径。

Sea of Nodes、SSA 和中间表示

当 TurboFan 决定优化一个函数后,它不会直接从字节码“硬翻”到最终机器码。中间还会先构建 IR(中间表示)。

经典 TurboFan 最有名的点,就是它使用了 Sea of Nodes 风格的 IR,并以 SSA(Static Single Assignment) 作为基础。

先看一个很小的 SSA 例子:

1
2
3
4
// function add(i) { return 1 + i; }
i1 = argument
r1 = 1 + i1
return r1

SSA 的核心思想是:每个值只赋值一次。这样编译器更容易追踪依赖关系,也更容易做优化。

在 Sea of Nodes 里,程序会被表示成“节点 + 边”的图:

  • 节点:操作、常量、加载、存储、调用、检查等
  • Value 边:谁依赖谁的值
  • Control 边:控制流顺序
  • Effect 边:有副作用操作的顺序约束

这套表示法对编译器很强大,但人类看起来并不直观。这也是为什么官方后来在 2025 年明确写过,TurboFan 的 JavaScript 后端已经大量从 Sea of Nodes 转向了更传统的 CFG 风格 IR(Turboshaft)。

不过对于读旧版 JIT 漏洞文章来说,Sea of Nodes 这套语言你还是得会。

Turbolizer:看 TurboFan 图最方便的工具

如果想真正看图,可以用:

  • d8 --trace-turbo your.js
  • 然后把生成的 turbo-*.json 扔到 Turbolizer

它能帮你看到:

  • 原始 bytecode graph
  • 各个优化阶段后的 graph
  • 最终机器码

这一点对理解具体优化非常有帮助,因为很多所谓的“JIT bug”,本质就是:

某个节点本来应该存在,但在某个 pass 之后被错误替换、错误合并,或者错误消除了。

TurboFan 里最常见的几类优化

理解 JIT bug,不需要把每个 pass 全背下来,但有几类优化必须知道。

Typer

Typer 是比较早期的优化阶段之一。它做的事很直接:

给图上的节点推导类型。

例如:

  • Int32 + Int32 可以推出结果仍然是整数范围
  • 常量节点可以推出固定的 Range
  • 某些推测算术节点可以推出“安全整数”范围

一旦图上有了这些类型信息,后续很多优化才有条件展开。

从漏洞视角看,Typer 重要的原因是:

后续 pass 会相信它的推导结果。

如果类型推导错了,后续一连串优化都可能建立在错误前提上。

Range Analysis

范围分析是类型推导的继续。它不仅关心“这是个整数”,还关心:

  • 最小值可能是多少
  • 最大值可能是多少

例如:

1
2
3
4
5
6
function hot_function(obj) {
let values = [0, 13, 1337];
let a = 1;
if (obj == "leet") a = 2;
return values[a];
}

这里的 a 不再是单一常量,而是两条控制流汇合后的结果。SSA 里通常会出现 Phi 节点来表达这种“二选一”的合流。

于是范围分析会推导出:

  • a 可能是 12
  • 因而 values[a] 的索引范围是受限的

如果编译器对范围的理解有误,就可能:

  • 错误删掉边界检查
  • 错误选择更窄的机器表示
  • 进而引入越界访问或类型混淆

Bounds Checking Elimination(BCE)

这是利用文章里最经典的一类优化之一。

它的目标是:

如果编译器已经证明数组索引一定在合法范围内,那就没必要每次都做边界检查。

听起来很合理,但这类优化历史上非常危险。因为一旦“证明过程”错了,删掉的就不是无关紧要的代码,而是安全边界本身。

在老一些的 TurboFan 语境里,这类问题和 TyperCheckBoundsSimplifiedLowering 非常相关。后来 Chromium 团队也专门做过 hardening,目的就是减少“因为 typer 错误而直接删掉 bounds check”的利用面。

对学习来说,抓住这个原则就够了:

任何“编译器确信这里不会越界,所以把检查删了”的地方,都是潜在危险区。

Redundancy Elimination

另一类常见优化是冗余消除。

目标也很好理解:

两个检查如果等价,或者前一个检查已经足以保证后一个检查成立,那后一个检查就可以去掉。

问题在于,这里依赖的不只是“值相等”,还依赖副作用顺序(effect chain)是否被正确建模。

举个抽象例子:

1
obj[x] = obj[x] + 1;

编译器必须非常清楚:

  • 什么时候读了 obj[x]
  • 什么时候做了加法
  • 什么时候写回去
  • 中间有没有可能触发副作用、导致对象状态变化

如果 effect chain 建模错了,或者某个 reducer 错把一个检查当成“已经保证过”,那就可能出现:

  • 错误删除 CheckMap
  • 错误删除类型检查
  • 错误把两个不该等价的节点合并

而这正是很多 JIT 类型混淆漏洞的根子。

其他常见优化

除了上面几类,TurboFan 里还经常能看到这些名字:

  • Control Flow Optimization:优化分支和控制流结构。
  • Alias Analysis:分析不同内存访问是否可能指向同一位置。
  • Global Value Numbering(GVN):识别等价计算,避免重复做同样的事。
  • Dead Code Elimination(DCE):删除不可能执行到,或其结果不会再被使用的代码。

它们本身都很正常,但一旦前提判断错了,依然会把“该保留的检查”或者“该存在的副作用顺序”一起删掉。

为什么 JIT 优化会演变成漏洞

到这里,其实就能回答最关键的问题了:

为什么性能优化会变成安全漏洞?

因为 JIT 优化的本质,就是在“尽量保持语义不变”的前提下,把动态语言执行得更像静态语言。

而这个过程非常依赖假设。

一旦假设错了,常见的漏洞模式就会出现:

  1. 错误的类型假设
    编译器以为这里始终是某种 shape/某种表示,结果实际传进来了别的对象。

  2. 错误的副作用建模
    编译器以为某个操作不会改对象状态,结果它会。

  3. 错误的范围推导
    编译器以为索引绝不会越界,结果某条路径上可以越界。

  4. 错误的检查消除
    编译器以为某个 CheckMap/CheckBounds 是冗余的,结果它恰恰是最后一道防线。

  5. 错误的 deopt 元数据
    编译器在回退时没法正确恢复解释器状态,也可能造成状态错乱。

  6. 传统 C++ 内存安全问题
    别忘了,V8 编译器和运行时本身也是 C++ 实现的,照样可能有 UAF、OOB、整数溢出等经典 bug。

浏览器利用文章里最常见的一类说法就是 type confusion。它本质上是:

编译器把一个值当成 A 类型来用,但运行时它其实是 B 类型。

一旦这个错位发生在对象布局、元素种类、数组长度、指针解释等关键位置,后面就很容易走向任意地址读写、伪造对象或任意代码执行。

读 JIT Bug 时我会重点看什么

如果后面继续读 V8/Chrome 漏洞分析文章,我觉得可以按下面的顺序看:

  1. 先看原始 JavaScript 语义,确认作者到底利用了哪个动态特性。
  2. 再看字节码,确认 Ignition 眼里的真实执行步骤。
  3. 看反馈是否稳定,是单态还是多态。
  4. 看优化后 graph 里哪些检查被插入了,哪些检查被删掉了。
  5. 看 deopt 点和 side-effect 建模,确认“编译器为什么相信这里安全”。
  6. 最后再看漏洞触发点,判断它属于类型推导错、范围分析错、检查消除错,还是副作用建模错。

只要把这条阅读路径建立起来,后面看任何 JIT bug,都会顺很多。

d8 调试速查

下面这些命令在学习 V8 JIT 时非常常用。需要注意两点:

  • d8 的具体 flag 和输出格式会随 V8 版本变化。
  • 如果你分析的是某个特定 Chrome 漏洞,最好尽量使用与目标版本接近的 d8/V8 构建环境。

下面这些命令在学习 V8 JIT 时非常常用。不同版本输出会有差异,但用途基本一致。

目的 命令
打印字节码 d8 --print-bytecode
允许使用 %DebugPrint 等内部语法 d8 --allow-natives-syntax
看函数当前入口/反汇编 %DisassembleFunction(fn)
打印函数对象、FeedbackVector %DebugPrint(fn)
观察何时进入优化 d8 --trace-opt
观察何时 deopt d8 --trace-deopt
导出 TurboFan 图 d8 --trace-turbo your.js
观察 graph reducer 做了哪些替换 d8 --trace_turbo_reduction your.js

总结

把这一篇压缩成一句话就是:

Ignition 负责把 JavaScript 变成可执行字节码并收集反馈,Sparkplug 负责以极低成本把字节码变成基线机器码,TurboFan 则基于运行时反馈做推测优化,并依靠类型守卫和 deopt 保证“快路径”不偏离 JavaScript 语义。

而浏览器 JIT 漏洞,往往就出在这条链路最微妙的地方:反馈被错误理解、守卫被错误消除、范围被错误推导、副作用被错误建模,或者回退状态被错误恢复。

理解了这条主线,后面再去看具体漏洞,比如 CheckMapCheckBoundsLoadEliminationRedundancyEliminationdeopt 这些词,就不会再是一团雾了。

参考