Chrome学习-V8和JavaScript内部机制介绍
2026-04-30 19:15:18 # 二进制安全

前言

英语不错的师傅们,建议直接看英文原版,英文可能还更好理解一些。文章内容,大部分来自作者@jack_halon。该文章仅作为自己的学习笔记,如果有错误的地方,还希望师傅们批评指正。

V8是什么?

我们都知JavaScript,Javascript代码的执行依赖于JavaScript引擎,而V8就是JavaScript引擎中的一种。

目前实际有很多不同的JavaScript引擎在使用,例如:

  • V8 - Google 的开源高性能 JavaScript 和 WebAssembly 引擎,用于 Chrome 浏览器。
  • SpiderMonkey - Mozilla 的 JavaScript 和 WebAssembly 引擎,用于 Firefox。
  • Charka - 微软开发的专有 JScript 引擎,用于 IE 和 Edge 浏览器。
  • JavaScriptCore - 苹果公司为 Safari 浏览器内置的 WebKit JavaScript 引擎。

众所周知,JavaScript 是一种轻量级、 解释型 、面向对象的脚本语言。在解释型语言中,代码逐行执行,执行结果立即返回,因此无需在浏览器运行前将代码编译成其他形式。但出于性能方面的考虑,这通常会导致此类语言性能不佳。在这种情况下,就需要用到编译技术,例如即时编译(Just-In-Time ,JIT)。JIT 将 JavaScript 代码解析成字节码(机器代码的抽象形式),然后进行进一步优化,从而显著提高代码效率,使其运行速度更快。

虽然上述各种 JavaScript 引擎可能拥有不同的编译器和优化器,但它们的设计和实现方式几乎完全相同,都基于 EcmaScript 标准(也常与 JavaScript 互换使用)。EcmaScript 规范详细说明了浏览器应如何实现 JavaScript,以确保 JavaScript 程序在所有浏览器中都能以完全相同的方式运行。

JavaScript引擎的工作流程

那么,执行JavaScript代码之后,究竟发生了什么呢?下面提供了一张图标,该图标展示了JavaScript引擎的一般“流程”,也被称为编译管道[1](compilation pipeline of JavaScript engines)。

image-20260318221941907

乍一看可能有点复杂,但别担心——其实并不难理解。那么,让我们一步一步地分解这个“流程”,并解释每个组成部分的作用。

  1. 解析器(Parser):执行 JavaScript 代码后,代码会被传递给 JavaScript 引擎,然后我们进入第一步,即解析代码。解析器会将代码转换为以下格式:

    • 词法单元(Tokens):代码首先被分解成“词法单元(tokens)”,例如:Identifier, Number, String, Operator等。这被称为“词法分析(Lexical Analysis)”或“词法单元化(Tokenizing)”。

      • 例如:var num = 42被分解为 var,num,=,42 ,然后每个“标记(token)”或项目都用其类型进行标记,因此在本例中将是:Keyword,Identifier,Operator,Number
    • 抽象语法树(Abstract Syntax Tree, AST):代码被解析成tokens后,解析器会将这些tokens转换成抽象语法树(AST)。这部分称为“语法分析(Syntax Analysis)”,顾名思义,它的作用是检查代码中是否存在语法错误

      • 例如:上面的代码样例,AST看起来像下面这样:

        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
        {
        "type": "VariableDeclaration",
        "start": 0,
        "end": 13,
        "declarations": [
        {
        "type": "VariableDeclarator",
        "start": 4,
        "end": 12,
        "id": {
        "type": "Identifier",
        "start": 4,
        "end": 7,
        "name": "num"
        },
        "init": {
        "type": "Literal",
        "start": 10,
        "end": 12,
        "value": 42,
        "raw": "42"
        }
        }
        ],
        "kind": "var"
        }
  2. 解释器(Interpreter):AST生成后,会被传递给Interpreter,Interpreter会遍历AST并生成字节码(bytecode),字节码生成后,会被执行,然后 AST 会被删除。

    V8的字节码,可以在这里找到

    下面就是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
  3. 编译器(Compiler):Compiler会预先使用一种叫做“性能分析器(Profiler)”的工具来监控和观察需要优化的代码。如果存在所谓的“热点函数(hot function)”,Compiler会获取该函数并生成优化后(optimized)的机器代码来执行。否则,如果Compiler发现某个已优化的“hot function”不再被使用,它会将其“反优化(deoptimize)”回字节码。

谷歌的 V8 JavaScript 引擎的编译流程与之非常相似。不过,V8 包含一个额外的“非优化”编译器,该编译器于 2021 年新增。现在,V8 的每个组件都有特定的名称,它们如下:

  • Ignition:V8 的快速底层寄存器解释器,用于生成字节码
  • SparkPlug:V8 的新型非优化(non-optimizing) JavaScript 编译器,它通过迭代bytecode并为每个访问到的bytecode生成机器代码(machine code),从字节码进行编译。
  • TurboFan:V8 的优化编译器(optimizing compiler),它使用更多、更复杂的代码优化功能将bytecode翻译成machine code。它还包含 JIT(Just-In-Time)编译功能。

综上所述,V8编译流程的概览如下:

image-20260319115925618

现在,如果像编译器(compilers)和优化(optimizations)之类的概念或特性你现在还不太理解,也不用担心。这篇文章并不需要你完全理解整个编译流程,但你应该对引擎的整体工作原理有一个大致的了解。

在此之前,如果你想了解更多关于管道(pipeline)的信息,建议观看“JavaScript Engines: The Good Parts[2]”,以获得更好的理解。

目前,你只需要理解这个编译流程中的一点:解释器(interpreter)是一个“栈式机器(stack machine)”,或者说本质上是一个虚拟机(VM),bytecode就是在这个虚拟机中执行的。而对于Ignition(V8的interpreter)来说,它实际上是一个带有累加寄存器的“寄存器式机器(register machine)”。Ignition仍然使用栈,但它更倾向于将数据存储在寄存器中以提高速度。

建议阅读“Understanding V8’s Bytecode[3]”和“Firing up the Ignition Interpreter[4]”,以便更好的掌握这些概念。

JavaScript和V8内部机制

现在我们已经对 JavaScript 引擎及其编译器管道的结构有了一些基本的了解,是时候深入了解 JavaScript 本身的内部结构,看看 V8 如何在内存中存储和表示 JavaScript 对象,以及它们的值和属性。

如果你想利用 V8 以及其他 JavaScript 引擎中的漏洞,那么理解这一部分至关重要 。因为事实证明,所有主流引擎对 JavaScript 对象模型的实现方式都大同小异。

众所周知,JavaScript 是一种动态类型语言。这意味着类型信息与运行时值相关联,而不是像 C++ 那样与编译时变量相关联。因此,JavaScript 中的任何对象都可以在运行时轻松修改其属性。JavaScript类型系统定义了诸如 UndefinedNullBooleanStringSymbolNumberObject(包括arraysfunctions)等数据类型。

简单来说,这意味着什么?嗯,这通常意味着,与 C++ 不同,JavaScript 中的对象或基本类型,例如 var 可以在运行时改变其数据类型。例如,让我们在 JavaScript 中创建一个名为 item 新变量,并将其值设置为 42

通过在 item 变量上使用typeof运算符,我们可以看到它返回其数据类型——即 number

image-20260319144426814

如果我们将 item 设置为字符串,然后检查它的数据类型,会发生什么情况?

image-20260319144525574

你看, item 变量现在被设置为 string 类型,而不是 number 类型。这就是 JavaScript 的“动态(dynamic)”特性。与 C++ 不同,如果我们尝试创建一个 int 或整数类型的变量,然后再尝试将其设置为字符串类型,就会失败——就像这样:

1
2
3
int item = 3;
item = "Hello!"; // error: invalid conversion from 'const char*' to 'int'
// ^~~~~~~~

虽然这在 JavaScript 中很酷,但它确实给我们带来了一个问题。V8 和 Ignition 是用 C++ 编写的,因此Interpreter和Compiler需要弄清楚 JavaScript 打算如何使用某些数据。这对于高效的代码编译至关重要,尤其是在 C++ 中,像 intchar 这样的数据类型占用的内存大小不同。

除了效率之外,这对于安全性也至关重要,因为如果Interpreter和Compiler错误地 “interpret”了 JavaScript 代码,导致我们得到的是一个字典对象(dictionary object)而不是数组对象(array object),那么我们就遇到了类型混淆漏洞(Type Confusion vulnerability)。

那么 V8 如何将所有这些信息与每个运行时值一起存储,引擎又是如何保持高效的呢??

在 V8 中,这是通过使用一种名为Map的专用信息类型对象来实现的(不要与Map Objects混淆了),它也被称为“Hidden Classs”。有时你可能会听到Map被称为“Shape”,尤其是在 Mozilla 的 SpiderMonkey JavaScript 引擎中。

V8 还使用一种称为指针压缩(pointer compression)或指针标记(pointer tagging)的内存技术来减少内存消耗,并允许 V8 将内存中的任何值表示为指向对象的指针。

但是,在我们深入研究所有这些功能的细节之前,我们首先必须了解什么是 JavaScript 对象以及它们在 V8 中是如何表示的。

Object Representation 对象表示

在 JavaScript 中,对象本质上是一组属性的集合,这些属性以键值对的形式存储——这意味着对象本质上类似于字典(dictionaries)。对象可以是数组(Arrays)、函数(Functions)、布尔值(Booleans)、正则表达式(RegExp)等等。

在JavaScript中,每个对象都关联着属性(properties),属性可以简单地理解为定义对象特征的变量。例如,一个新创建的car对象可以拥有诸如makemodelyear之类的属性,这些属性有助于定义这辆car。你可以通过简单的点号运算符(例如objectName.propertyName)或方括号运算符(例如objectName['propertyName'])来访问对象的属性。

此外,每个对象的属性都映射到属性特性(property attributes),这些特性用于定义和解释对象属性的状态。下面展示了JavaScript对象中这些属性特性的示例。

image-20260319200419733

现在我们对对象有了一些了解,下一步是了解对象在内存中的结构以及它的存储位置。

每当创建一个对象的时,V8都会创建一个新的JSObject对象,并在堆上为其分配内存。该对象的值是指向JSObject,其结构中包含以下内容:

  • Map:指向HiddenClass对象的指针,详细说明对象的Shape或者结构。

  • Properties:指向包含命名属性(named properties)的对象的指针。

  • Elements:指向包含编号属性(numbered properties)的对象的指针。

  • In-Object Properties:指向在对象初始化时定义的命名属性(named properties)的指针。

为了帮助更好的理解,下图详细说明了基本的V8 JSObject在内存中的结构。

image-20260319202310194

观察JSObject的结构,我们可以看到属性(Properties)和元素(Elements)分别存储在两个独立的FixedArray数据结构中,这使得添加和访问Properties或Elements更加高效。

Elements结构主要存储非负整数(non-negative)或数组索引(array-indexed)属性(keys),这些通常被称为元素(Elements)。

Properties结构,如果对象的Property键不是非负整数,例如string,则该属性将存储为内联对象属性(Inline-Object Property)(稍后会解释)或者存储在Elements结构中,有时也称为对象的属性后备存储(objects properties backing store)。

需要注意的是,虽然命名属性(named properties)的存储方式与数组元素类似,但它们在属性访问方面却并不相同。与元素不同,我们不能直接使用键(key)来查找命名属性(named properties)在属性数组中的位置;我们需要一些额外的元数据(additional metadata)。如前所述,V8使用一个名为HiddenClassMap特殊对象,该对象与每个JSObject相关联。这个Map存储了JavaScript对象的所有信息,从而使V8能够“动态(dynamic)”运行。

因此,在进一步了解JSObject结构及其属性之前,我们首先需要了解V8中的HiddenClass是如何工作的。

HiddenCLass(Map)and Shape Transitions

如前所述,我们知道 JavaScript 是一种动态类型语言。正因如此,JavaScript 中没有类的概念。在 C++ 中,如果你创建了一个类或对象,就无法像在 JavaScript 中那样动态地添加或删除它的方法和属性。在 C++ 和其他面向对象语言中,你可以将对象属性存储在固定的内存偏移量中,因为给定类的实例的对象布局永远不会改变,但在 JavaScript 中,对象布局会在运行时动态变化。为了解决这个问题,JavaScript 使用了一种称为“ 基于原型的继承 ”的机制,其中每个对象都引用一个原型对象(prototype object)或 “ shape ”,并继承其属性。

那么V8是如何存储对象的布局的呢?

这时就需要用到HiddenClassMap了。HiddenClass的工作方式类似于固定对象布局,其中属性值(或指向这些属性的指针)可以存储在特定的内存结构中,然后通过固定的偏移量进行访问。这些偏移量由Torque生成,可以在V8的/torque-generated/src/objects/*.tq.inc目录中找到。这实际相当于对象的“shape”标识符,从而使V8能够更好地优化JavaScript代码并缩短属性访问时间。

如上文JSObject示例中所示,Map是对象中的另一种数据结构。该Map结构包含以下信息:

  • 对象的动态类型,例如String、JSArray、HeapNumber等。
  • 对象的大小(对象内部属性(in-object properties)等)
  • 对象属性及其存储位置
  • 数组元素类型
  • 对象的Prototype或Shape(如果有的话)

为了帮助理解Map对象在内存中的结构,我在下图提供了一个较为详细的V8 Map结构图。更多关于结构的信息可以在V8的源代码中找到,具体位置在 /src/objects/map.h/src/objects/descriptor-array.h 源文件中。

image-20260319214218205

现在我们了解了Map的布局,接下来解释一下我们经常提到的“Shape”。如你所知,每个新创建的JSObject都会有一个hidden class,其中包含每个属性的内存偏移量。有趣的是,如果该对象的属性被创建、删除或动态更改,则会创建一个新的hidden class,这个新的hidden class保留了现有属性的信息,并包含了新属性的内存偏移量。请注意,只有在添加新属性时才会创建新的hidden class,添加数组索引属性不会创建新的hidden class。

那么,这在实践中是如何体现的呢?我们以以下代码为例:

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

首先,我们创建一个名为obj1新对象,该对象会被创建并存储在V8的堆内存中。由于这是一个新创建的对象,显然需要创建一个hidden class,即使该对象尚未定义任何属性,这个hidden class也会被创建并存储在V8的堆内存中。为了便于示例,我们将这个初始hidden class称为 “C0”。

image-20260319220000868

当执行到下一行代码并执行obj1.x = 1时,V8将创建一个名为 “C1” 的第二个hidden class,该类基于C0。C1是第一个用于描述属性 x 在内存中位置的hidden class。但是,它存储的不是指向 x 值的指针,而是 x 的偏移量,偏移量为 0

image-20260319220904045

这里为什么是一个对属性(property)的偏移,而不是它的值???🤔

在V8中,这是一种优化技巧。Map对象在内存使用方面相对来说比较昂贵。如果我们把属性的键值对以字典形式存储在每个新创建的JSObject中,那么由于解析字典速度较慢,这将导致大量的计算开销!

其次,如果创建一个新对象obj2,它与obj1共享相同的属性,例如xy,会发生什么情况?即使它们的值可能不同,但这两个对象实际上共享了相同名称且顺序相同的属性,或者我们称之为相同的“Shape”。在这种情况下,将相同的属性名称存储在两个不同的位置会造成浪费。

这正是V8速度快的原因:它经过优化,尽可能在结构相似的对象之间共享Map。由于相同结构的所有对象的属性名称相同且顺序一致,我们可以让多个对象指向内存中同一个HiddenClass,并通过属性偏移量而非值指针来访问它们。此外,由于Map和JSObject一样都是在HeapObject中分配内存,因此也便于垃圾回收。

为了更好地解释这个概念,我们暂时跳出上面的例子,来看看HiddenClass的关键部分。HiddenClass中最重要的两个部分是 DescriptorArrayInt Field 3(第三个位字段),它们共同构成了Map的“Shape”。如果你回顾一下上面的 Map 结构,你会发现Int Field 3存储了属性的数量,而 DescriptorArray 则包含了关于已命名属性(named properties)的信息,例如属性名称(name)、值存储的位置(offset)以及属性的具体属性值(properties attributes)。

例如,假设我们创建一个新对象var obj { x : 1}x 属性将被存储在JavaScript对象的“对象内属性(In-Object properties)” 或 称“属性存储(Properties store)”。由于创建了一个新对象,同时也会创建一个新的HiddenClass。在该HiddenClass中,描述符数组(descriptor array)和Int Field 3将被填充。由于我们只有一个属性,Int Field 3会将numberOfOwnDescriptors 设置为 1 ,然后描述符数组(descriptor array)会填充与属性x相关的详细信息,包括键(key)、详细信息(detail)和值(value)。该描述符(descriptor)的值(value)将被设置为0

为什么是0呢?因为对象内属性(In-Object properties)和属性存储(Properties store)本质上就是一个数组。因此,通过描述符的值设置为0,V8就知道对于任何相同类型的对象,key的value都将位于该数组的偏移量0处。

下面可以看到我们刚才解释内容的直观示例:

image-20260320143942738

V8调试

让我们看看在V8中会是什么样子。首先使用 --allow-natives-syntax参数启动v8,并执行以下JavaScript代码:

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

完成后,我们将使用%DebugPrint()命令来显示对象的属性(properties)、映射(map)以及其他信息,例如实例描述符(instance descriptor)。执行后,注意以下事项:

image-20260320201725825

黄色部分显示的是我们的对象obj1红色部分显示的是指向HiddenClass或Map的指针。在该HiddenClass中,我们找到了指向DescriptorArray的实例描述符(instance descriptors)。使用%DebugPrintPtr()函数访问该数组的指针,我们可以看到该数组在内存中的更多细节,这些细节以蓝色显示

请注意,我们有3个属性,这与Map中instance descriptors部分的descriptors数量一致。在下方,我们可以看到descriptors数组保存着属性键,而const data field则保存着属性存储中对应属性值的偏移量。现在,如果我们沿着箭头从偏移量向上找到对象,我们会发现偏移量确实匹配,并且每个属性都已分配了正确的值。

另外,请注意这些属性的右侧,您可以看到每个属性的location ;正如我之前提到的,这些属性位于in-object 。这几乎可以证明,这些偏移量指向的是对象内部(In-Object)和属性存储(Properties store)中的属性。

好了,现在我们明白了为什么要使用偏移量(offset),让我们回到之前的HiddenClass示例。正如我们之前所说,通过向obj1添加属性x,我们现在会创建一个名为“C1”的新HiddenClass,其偏移量为x。由于我们创建了一个新的HiddenClass,V8会使用“类转换(class transition)”更新“C0”,该转换表明,如果创建了一个具有属性x的新对象,则HiddenClass应直接切换到“C1”。

然后,当我们执行obj1.y = 2,这个过程会重复进行。此时会创建一个名为C2的新HiddenClass,并在C1中添加一个类转换,该转换声明对于任何具有属性x对象,如果添加了属性y,则HiddenClass应转换到C2。最终,所有这些类转换会形成一个称为“转换树(transition tree)”的结构。

image-20260320225406762

此外,需要注意的是,类转换取决于属性添加到对象的顺序。因此,如果属性 z 是在属性 y 之后添加的,“Shape”将不再相同,也不会遵循从 C1 到 C2 的相同转换路径。取而代之的是,将创建一个新的HiddenClass,并添加一条从 C1 到 C2 的新转换路径来处理该新属性,从而进一步扩展转换树。

image-20260320232112033

现在我们理解了这一点,让我们来看看当两个形状相同的对象共享一个 Map 时,对象在内存中的样子。

首先,使用 --allow-natives-syntax 参数再次启动 d8 ,然后输入以下两行 JavaScript 代码:

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

完成后,我们将再次使用 %DebugPrint() 命令对每个对象进行测试,以显示它们的properties、map和其他信息。执行后,请注意以下事项:

image-20260321101201559

黄色区域,我们可以看到两个对象obj1obj2。请注意,它们都是JS_OBJECT_TYPE类型,但在堆内存中拥有不同的地址,因为它们显然是具有不同属性的独立对象。

我们知道,这两个对象shape相同,因为它们都包含顺序相同的xy属性。在这种情况下,在蓝色区域,我们可以看到这些属性位于同一个FixedArray中,xy的偏移量分别为0何1。这是因为,正如我们所知,shape相同的对象共享一个HiddenClass(红色区域),该HiddenClass具有相同的描述符数组(descriptor array)。

从图中可见,对象的大多数属性和Map地址都是相同的,因为这两个对象共享同一个Map。

现在我们来关注一下绿色高亮显示的back_pointer。回顾一下我们之前提到的从 C0 到 C2的映射转换示例,你会注意到我们提到过一个叫做“转换树(transition tree)”的东西。每次创建一个新的HiddenClass时,V8都会在后台创建一个transition tree,并将新旧HiddenClass连接起来。这个back_pointer就是transition tree的一部分,因为它指向了转换发生的父映射。这样,V8就可以沿着back_pointer链遍历映射,直到找到保存对象属性的映射,即它们的shape。

下面用d8来深入了解一下它的工作原理。我们将再次使用%DebugPrintPtr()命令来打印V8中地址指针的详细信息。这次我们将使用back_pointer地址来查看其详细信息。完成后,你的输出应该和我的类似。

image-20260321104344785

绿色部分,我们可以看到back_pointer解析到内存中的JS_OBJECT_TYPE,而它实际上是一个Map!这个Map就是我们之前讨论的C1 Map。我们知道Map如何回溯到它之前的Map,但是当添加属性时,它如何知道要转换到哪个Map呢?如果我们仔细观察Map中的信息,就会发现实例描述符下方有一个红色”transitions“部分。这个transitions部分包含了Map结构中原始转换指针(Raw Transition Pointer)指向的信息。

在 V8 中,Map 的转换使用名为 TransitionsAccessor 的组件。这是一个辅助类,它封装了 Map 在其 Map::kTransitionsOrPrototypeInfo 字段(也称为我们之前提到的Raw Transition Pointer)中存储与其他 Map 转换的各种方式。该指针指向一个名为 TransitionArray 的对象,它本身也是一个 FixedArray ,用于保存 Map 属性更改的转换信息。

回顾红色高亮部分,我们可以看到该转换数组(transition array)中只有一个转换(transition)。在该数组中,我们可以看到transitions #1详细描述了当y属性添加到对象时没发生的transition。如果添加了y属性,他会指示Map更新为存储在0x2b2508207aa1的Map,该Map与我们当前的Map匹配!如果存在另一个transition,例如,将z属性添加到x而不是y属性,那么该transition array中将会有2个元素,分别指向该对象shape对应的Map。

那么,如果我们删除一个属性,transition tree会发生什么变化呢?在这种情况下,V8每次删除属性时都会创建一个新的Map,这其中存在一些微妙之处,我们知道,Map在内存使用方面相对昂贵,因此,当属性删除到一定程度时,继承(inheriting)和维护transition tree的成本会越来越高,速度也会越来越慢。如果删除对象的最后一个属性,Map只会调整其back pointer,使其返回到之前的Map,而不会创建一个新的Map。但是,如果我们删除对象的中间属性(middle property)会发生什么呢?在这种情况下,每当我们添加过多属性或删除非最后一个元素时,V8都会放弃维护transition tree,并切换到一种称为字典模式(dictionary mode)的较慢模式。

那么,什么是dictionary mode呢?既然我们已经了解了 V8 如何使用HiddenClass来跟踪对象的shape,现在我们可以回过头来,进一步了解这些Properties和Elements在 V8 中是如何存储和处理的。

Properties

如前所述,我们知道JavaScript对象有两种基本属性:命名属性(named properties)和索引元素(indexed elements)。下面首先介绍named properties。

如果你还记得我们之前关于映射(Maps)和描述符数组(Descriptor Array)的讨论,我们提到过named properties可以存储在对象内部(In-Object)或属性数组(Property array)中。

我们所说的对象内部属性(In-Object Property)是什么呢?

在 V8 中,这种模式是一种非常快速的直接在对象上存储属性的方法,因为无需任何间接层即可访问属性。虽然速度很快,但它也受限于对象的初始大小。如果添加的属性数量超过了对象的空间,则新属性将被存储在属性存储区(properties store)中——这会增加一层间接层(one level of indirection)。

一般来说,JavaScript引擎使用两种”模式(modes)“来存储属性,它们被分别称为:

  • Fast Properties: 通常用于定义存储在线性属性存储(linear properties store)中的属性。这些属性可以通过访问HiddenClass中的描述符数组(Descriptor Array),并根据在属性存储(properties store)中的索引查找来访问。
  • Slow Properties:也称为“dictionary mode”,当添加或删除的属性过多,导致大量内存开销时,就会使用此模式。因此,具有慢属性(slow properties)的对象会使用一个独立的字典(a self-contained dictionary)作为属性存储。所有属性的元信息(meta information)不再存储在HiddenClass中的描述符数组(Descriptor Array)中,而是直接存储在属性字典中(properties dictionary)。V8 将使用哈希表(hash table)来访问这些属性。

下面可以查看 Map 在过渡到(transitions to)具有自包含字典(self-contained dictionary)的慢属性(slow Properties)时的示例。

image-20260321232028666

这里还需要注意一点。形状过渡(Shape transitions)仅适用于快速属性(Fast Properties),而不适用于慢速属性(Slow Properties),因为字典形状(dictionary shapes)仅供单个对象使用,因此无法在不同对象之间共享,因而没有过渡(transitions)效果。

Elements

好了,至此,我们已经基本讲完了命名属性(named properties)。现在让我们来看看数组索引属性(array indexed properties)或元素(elements)。你可能会认为索引属性(indexed properties)的处理会更简单…但是你的想法是错误的。元素(elements)的处理并不比命名属性(named properties)简单。尽管所有索引属性(indexed properties)都保存在元素存储区(elements store)中,但V8会非常精确地区分每个数组包含的元素类型。实际上,该存储区可以跟踪大约21种不同的元素类型!这使得V8能够针对特定类型的元素(elements)优化对数组(array)的任何操作。

这是什么意思你?下面来看这行代码的例子:

1
const array = [1,2,3]

image-20260322170624371

在JavaScript中,如果我们对这个数组执行typeof操作,它会判定该数组包含numbers因为JavaScript无法区分整数(integer)、浮点数(float)和双精度浮点数(double)。然而,V8的区分更加精确,会将该数组归类为PACKED_SMI_ELEMENTS,其中SMI指的是小整数(Small Integers)。

那么,SMI到底有什么用呢?V8会跟踪每个数组包含哪些类别的元素。然后,它会利用这些信息来优化针对该类型元素的数组操作。在V8中,我们需要了解三种不同的元素类型,它们分别是:

  • SMI_ELEMENTS用于表示包含小整数(例如1、2、3等)的数组。
  • DOUBLE_ELEMENTS用于表示包含浮点数(floating-point)的数组,例如4.5、5.5等。
  • ELEMENTS用于表示包含字符串字面量元素(string literal elements)或无法表示为SMIDouble例如:x的数组。

那么V8是如何使用数组的这些元素类型的呢?它们是针对整个数组设置的,还是针对每个元素设置的?

答案是,元素类型是针对整个数组设置的。我们需要记住的重要一点是,元素类型(element kinds)之间存在一个”转换(transition)“,这个transition只能单向进行,我们可以采用”自顶向下(top down)“的方法来查看这个转换树(transition tree)。

image-20260322173329762

例如,让我们以之前提到的数组示例为例:

1
2
const array = [1,2,3];
// Elements Kind: PACKED_SMI_ELEMENTS

如你所见,V8 将此数组的元素类型跟踪为打包的SMI(packed SMI),packed SMI是什么?这个后面会说到。现在,如果我们添加一个浮点数(floating-point number),则数组元素的类型将“转换(transition)”为 Double元素类型,如下所示。

1
2
3
4
const array = [1,2,3];
// Elements Kind: PACKED_SMI_ELEMENTS
array.push(3.337)
// Elements Kind: PACKED_DOUBLE_ELEMENTS

这种transition的原因很简单,就是为了优化运算。因为我们使用的是浮点整数(floating-point integer),V8需要能够对这些值进行优化,所以它向下过渡(transitions)到DOUBLES,因为可以用SMI表示的数字集合是可以用双精度浮点(double)表示的数字集合的子集。

由于元素类型的transitions是单向的,一旦数组被标记为较低类型(lower elements)的元素,例如PACKED_DOUBLES_ELEMENTS,即使我们替换或删除浮点整数,它也无法再”升级“(go back “up”)回PACKED_SMI_ELEMENTS。一般来说,创建数组时指定的元素类型越具体,可实现的优化就越精细(fine-grained)。元素类型越低,对对象的操作速度可能就越慢。

接下来,我们还需要了解V8在跟踪索引被删除或为空时,元素后端存储方式的第一个主要区别。这些区别是:

  • PACKED:用于表示密集数组(dense),意味着数组中的所有可用元素都已填充。
  • HOLEY:用于表示数组中存在”空位(holes)“,例如索引元素被删除或未定义时。这也被称为使数组”稀疏“(making an array ”sparse“)。

那么让我们仔细看看。例如,我们来看一下两个数组:

1
2
3
4
const packed_array = [1,2,3,5.5,'x'];
// Elements Kind: PACKED_ELEMENTS
const holey_array = [1,2,,5,'x'];
// Elements Kind: HOLEY_ELEMENTS

正如你所见,holey_array数组中存在”空洞(holes)“,因为我们忘记在索引上加上3,而是将其留空或设为undefined。V8之所以区分这两种数组,是因为对packed arrays的操作可以比对holey_array的操作进行更积极的优化。

如果你想了解更多相关信息,我建议你观看Mathias Bynens的演讲"V8 internals for JavaScript Developers"[5],其中对此有非常详细的讲解。

V8还实现了前面提到的PACKEDHOLEY数组上的元素类型转换,从而形成一个“晶格(lattice)”。下面是V8博客中对这些转换的简单可视化。

image-20260323142108914

再次强调,元素类型在这个lattice中,只能单向向下转换。例如,向SMI数组(SMI array)中添加一个浮点数(floating-point)会将其标记为双精度浮点数(double);类似的,一旦数组中出现空位(hole),即使之后填充了该空位,它也会被永久标记为空位。

V8对元素(elements)还有第二个需要我们理解的重要区别。在元素存储中(in the element backing stores),元素也可以是fast模式或dictionary-mode(slow)。快速元素(fast elements)就是一个数组,其中属性索引(property index)映射到元素存储(elements store)中对应项的偏移量。至于慢速数组(slow array),这种情况通常发生在只有少量条目被使用的大型稀疏数组(large sparse arrays)中。在这种情况下,数组存储(array backing store)会使用类似属性存储(properties store)中的字典表示(dictionary representation)来节省内存,但会牺牲一些性能。该字典会将键(key)、值(value)和元素属性存储(element attributes)在字典三元组值中。

Viewing Chrome Objects In-Memory

到目前为止,我们已经探讨了许多关于JavaScript和V8内部机制的复杂主题。希望你现在对V8底层运行的一些概念已经有了一定的了解。既然我们已经掌握了这些知识,接下来就让我们通过GDB来观察 V8 及其对象(objects)在内存中的运行情况,以及它们使用了哪些优化。这里我使用的是Linux中的Chrome V8,使用GDB调试V8程序,查看内存中的内容。

我们知道我们已经研究过对象(objects)和映射(maps)的内存结构,也接触过d8库,所以我们应该对指针指向什么以及内存中数据的位置有了大致的了解。但是,不要以为事情,就这么简单。就像 V8 的所有功能一样,优化在保证其速度和效率方面起着至关重要的作用,这同样适用于它处理和存储内存值的方式。

这是什么意思呢?下面不妨使用d8和gdb快速了解一下简单的V8对象结构。

1
./d8 --allow-natives-syntax

完成后,我们继续使用 %DebugPrint() 函数打印出对象的信息。

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}

之后,启动gdb并将其附加到d8进程。

1
gdb -p $(pidof d8)

我们将使用x/10wx 0x3791080498ed,查看0x3791080498ed地址的内容。

image-20260323191204748

查看GDB输出,我们可以看到对象使用的内存地址是正确的。但是,当我们查看内存内容时,第一个地址(如果你还记得我们的 JSObject 结构,它应该是一个指向map的指针)似乎已损坏。嗯,人们可能会认为它损坏了,更有经验的逆向工程师或漏洞利用开发者甚至可能会认为存在偏移/对齐(offset/alignment)问题,从技术上讲,你的猜测很接近,但并不完全正确。

朋友们,这又是 V8 引擎的优化在起作用。你们应该能理解为什么我们需要讨论这些优化,因为对于非专业人士来说,内存中发生的一切都会让人感到困惑不已。我们在这里实际看到的是两件事——指针压缩(Pointer Compression)和指针标记(Pointer Tagging)。

我们首先来了解一下 V8 中的指针(Pointer)或值标记(Value tagging)。

Pointer Tagging

那么,什么是指针标记(pointer tagging)?我们为什么要使用它?

我们知道,在 V8 中,值(values)被表示为对象(objects)并分配在堆上——无论它们是对象(objects)、数组(array)、数字(number)还是字符串(string)。现在,许多 JavaScript 程序实际上会对整数值(integer values)进行计算,因此,如果我们每次递增或修改值时都必须在 JavaScript 中创建一个新的 Number() 对象,那么就会增加创建对象和堆跟踪(heap tracking)的时间开销,并增加内存占用,从而导致效率低下。

在这种情况下,V8 的做法是,它不会每次都创建一个新对象,而是将部分值直接存储到对象内部。虽然这种方法可行,但却带来了第二个问题:如何区分对象指针(object pointer)和内联值(inline value)?这就需要用到指针标记(pointer tagging)了

指针标记技术(Pointer tagging’s technique)基于这样的观察:

在 x32 和 x64 系统中,已分配的数据必须按字对齐(4 字节)边界排列。由于数据以这种方式对齐,最低有效位 (LSB) 始终为零。标记技术(Tagging)随后使用最低两位来区分堆对象指针(heap object pointer)和整数(an integer)或 SMI。

在x64架构上,使用以下标记方案:

1
2
3
            |----- 32 bits -----|----- 32 bits -------|
Pointer: |________________address______________(w1)|
Smi: |____int32_value____|000000000000000000(0)|

如示例所示,0 用于表示 SMI,1 用于表示指针。需要注意的是,你看到的是内存中的 SMI,虽然它们以内联方式存储(stored inline),但实际上会被复制两份以避免指针标记(pointer tag)。因此,如果原始值为 1,则在内存中它将是 2。

指针内部,第二个最低有效位(LSB)上还有一个 “w” ,它用于区分强指针和弱指针(strong or weak pointer)。如果您不熟悉强指针和弱指针的概念,我来解释一下。简单来说, 强指针(strong pointer)表示其指向的对象必须保留在内存中(它代表一个对象),而弱指针(weak pointer)则指向可能已被删除的数据。当垃圾回收器(GC)删除对象时,它必须同时删除强指针,因为它保存着引用计数(reference count)。

在这种指针标记(pointer tagging)方案下,对整数进行算术(arithmetic)或二进制运算(binary operations)时可以忽略标记(tag),因为低 32 位将全部为零。然而,当需要解引用(dereferencing a HeapObject)堆对象时,V8 需要先屏蔽(mask off)最低有效位,为此使用了一个特殊的访问器(accessor)来清除最低有效位。

现在我们了解了这一点,回到GDB中的例子,通过从地址中减去 1 来清除最低有效位 (LSB)。这样应该就能得到有效的内存地址。完成后,输出应该如下所示。

image-20260323195426610

如您所见,清除最低有效位 (LSB) 后,内存中就出现了有效的指针地址!具体来说,我们有了映射 (map)、属性 (properties)、元素 (elements) 以及内联对象 (inline objects)。再次提醒,SMI 的值双倍了(doubled) ,因此原本存储值为 1 的 x 在内存中实际上是 2,2 也是如此,现在变成了 4。

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

image-20260323200046224

眼尖的人可能已经注意到,指针只有一半指向内存中的对象。这是为什么呢?

如果你回答“这是另一种优化”,那就对了。这叫做指针压缩(Pointer Compression),我们接下来会详细讨论。

Pointer Compression

Chrome和V8中的指针压缩(Pointer Compression)利用了堆上对象的一个有趣特性:堆对象通常彼此靠近,因此指针的最高有效位( the most significant bits)很可能相同。在这种情况下,V8只将指针的一半(最低有效位,the least significant bits)保存到内存中,并将V8堆(称为isolate root)的最高有效位(高32位)放入root register(R13)中。每当我们需要访问指针时,只需将寄存器中的值与内存中的值相加,即可得到完整的地址。该压缩方案在V8的/src/common/ptr-compr-inl.h 源文件中实现。

基本上,V8 团队的目标是想方设法将两种类型的标记值(tagged values)都放入 64 位架构的 32 位中,特别是为了减少 V8 的开销,从而尽可能多地在 x64 架构中回收浪费的 4 个字节。

AI总结版

Map(隐藏类) + Properties(命名属性) + Elements(索引属性) = 完整描述一个 JS 对象

我会用你这个例子贯穿讲:

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

一、整体结构:V8如何表示一个对象?

在 V8 内部,一个 JS 对象大致是这样:

1
2
3
4
5
6
JSObject {
map ---> Map(隐藏类)
elements ---> Elements(数组索引属性)
properties ---> Properties(普通属性存储)
in-object fields(直接内嵌的属性值)
}

👉 关键点:

  • map:描述“对象长什么样”
  • properties:存“字符串属性”
  • elements:存“数字索引属性”

官方总结也很明确:

  • named properties 和 elements 是分开存的 (v8.dev)

二、Map(隐藏类):对象的“结构定义”

1. Map 是什么?

Map(Hidden Class)本质是:

对象的结构描述(shape / layout)

它记录:

  • 有哪些属性(x, y)
  • 每个属性存在哪里(offset)
  • 属性类型(数据属性 / accessor)
  • 属性顺序

📌 类比:

1
Map ≈ C语言 struct 定义

2. Map 内部关键结构

一个 Map 关联三个重要结构:

(1) DescriptorArray(描述符数组)

记录:

1
2
[x → offset 0]
[y → offset 1]

👉 本质是:

属性名 → 内存位置 的映射 (v8.dev)

(2) TransitionArray(状态迁移)

当对象“加属性”时:

1
2
{} --add x--> {x}
{x} --add y--> {x,y}

👉 每一步都会生成一个新 Map (v8.dev)

(3) Map 指针

每个对象第一个字段就是:

1
obj.map → Map

👉 用来快速判断对象“类型是否相同”

3. 用你的例子解释 Map

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

V8内部大致过程:

  • Step 1:创建空对象
1
Map0: {}
  • Step 2:加 x
1
2
Map1: { x }
Descriptor: [x → offset0]
  • Step 3:加 y
1
2
Map2: { x, y }
Descriptor: [x → offset0, y → offset1]

👉 最终:

1
obj1.map = Map2

三、Properties:普通属性存储

1. Properties 是什么?

存储字符串 key 的属性值

例如:

1
2
obj1.x
obj1.y

2. 两种存储方式

✅ (1) In-object properties(最重要)

直接存在对象内部:

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

👉 最快访问方式 (v8.dev)

❗ (2) properties backing store

如果属性太多:

1
2
3
4
5
obj1:
[map]
[x]
[y]
properties → [z, k, ...]

👉 Map 决定:

  • z 在 properties[0]
  • k 在 properties[1]

3. Map 和 Properties 的关系(核心)

Map 不存值,只存:

1
2
x → offset 0
y → offset 1

而值在:

1
obj + offset

👉 所以访问:

1
obj1.x

变成:

1
load obj1 + offset0

🚀 这就是为什么 JS 能像 C 一样快

四、Elements:数组/索引属性

1. Elements 是什么?

key 是整数(0,1,2…)的属性

例如:

1
var arr = [10, 20];

内部:

1
elements = [10, 20]

2. 为什么要单独分?

因为访问模式不同:

  • elements:连续访问(数组)
  • properties:随机访问(对象)

👉 V8 专门优化两套结构 (v8.dev)

3. obj1 有没有 elements?

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

👉 没有

1
elements = []

如果你这样:

1
obj1[0] = 100;

就会变成:

1
2
elements = [100]
properties = {x, y}

五、三者关系总结(最关键)

我们把三者串起来:

1. Map = “结构说明书”

描述:

1
属性名 → 存储位置

2. Properties = “数据区(字符串key)”

存:

1
2
x = 1
y = 2

3. Elements = “数组数据区(数字key)”

存:

1
2
0 = xxx
1 = xxx

4. 最终 obj1 内部结构

1
2
3
4
5
6
7
8
obj1:
map ------> Map2
elements -> []
properties -> (可能为空)

in-object:
offset0 → 1 (x)
offset1 → 2 (y)

六、从 exploitation 角度再补一层(非常重要)

👉 Map 是类型系统

漏洞利用常见套路:

1. 类型混淆(Type Confusion)
1
2
obj1.map != obj2.map
但被当成一样用

👉 导致:

  • 读取错误 offset
  • 越界访问
2. Map 控制内存布局

攻击目标:

1
2
3
伪造 Map
→ 控制 property offset
→ 任意读写
3. elements vs properties 混淆

因为:

1
2
elements 是数组
properties 是对象

👉 利用点:

  • HOLEY_ELEMENTS
  • PACKED_ELEMENTS
  • 转换 bug

七、一句话总结(非常重要)

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

更精确一点:

1
2
3
Map = layout(布局)
Properties = named storage
Elements = indexed storage

参考


  1. JavaScript引擎的一般流程 https://jhalon.github.io/chrome-browser-exploitation-1/ ↩︎

  2. JavaScript: The Good Parts https://www.youtube.com/watch?v=5nmpokoRaZI ↩︎

  3. Understanding V8’s Bytecode https://medium.com/dailyjs/understanding-v8s-bytecode-317d46c94775 ↩︎

  4. Firing up the Ignition Interpreter https://v8.dev/blog/ignition-interpreter ↩︎

  5. V8 internals for JavaScript Developers https://www.youtube.com/watch?v=m9cTaYI95Zc ↩︎