CVE-2024-0517
前言
该漏洞源于 V8 引擎的 Maglev 编译器在处理具有父类的类时的编译机制。在此场景下,编译器需遍历所有父类及其构造函数进行查找,而此过程中引入了安全漏洞,该漏洞的补丁和详细信息可以从chrome issues中来进行查看,下面我们将对此漏洞进行详细的分析以及对v8 shell的提权操作
环境搭建
本次我们使用的是 e73f620c2ef1230ddaa61551706225821a87c3b9]分支来进行此次的漏洞分析,下面我们来进行简单的环境搭建
1 | # 首先获取V8源码拉取工具 |
基础知识
V8引擎简介
V8引擎包含*Ignition(解释器)、Sparkplug(基线编译器)、Maglev(中层优化编译器)和TurboFan(优化编译器)*。Ignition*作为一种寄存器式虚拟机,负责将解析后的*AST转换为字节码。其优化阶段最关键的步骤之一就是识别hot code,随后将这些代码送入Maglev进行初步优化。若代码进一步被频繁执行,则会进入TurboFan进行深度优化。
Maglev
Maglev是位于基线编译器之后和优化编译器之前,其优化决策完全依赖于解释器运行期间收集的反馈数据。为实现静态层面的高效优化,Maglev 通过构建由节点组成的控制流图(Control Flow Graph, CFG即 **Maglev 中间表示(Maglev IR)**来支撑其优化流程。
我们可以通过如下代码来进行对其有初步的了解
1 | function add(a, b) { |
执行./v8/out/x64.debug/d8 --allow-natives-syntax --print-maglev-graph ./exp/test1.js首先会打印其字节码
1 | 0x3473000021d8 @ 0 : 0b 04 Ldar a1 |
然后就会打印其IR图
1 | Graph |
我们可以很清楚的看到Block b1是入口块,Block b2是主逻辑块,所以控制流就是b1 - b2 - Return
关于b1块我们可以清楚的看到其执行的是初始化与安全检查,而b2块则是乘法运算逻辑
Ubercage
Ubercage(也称为 V8 沙盒,注意区别于 Chrome 沙盒)是 V8 引擎内部引入的一种新型防御机制,其目标是在攻击者成功利用 V8 漏洞后,仍能强制限制内存读写边界。
该机制的设计核心是将 V8 堆内存重新定位到一个预保留的虚拟地址空间(称为沙盒)。此设计假设攻击者能够破坏 V8 堆内存,但通过沙盒隔离,将内存访问限制在沙盒内部,从而阻止攻击者在成功利用 V8 漏洞后实现任意代码执行。Ubercage 本质上为 V8 创建了一个进程内沙盒,将潜在的任意内存写入转化为受边界约束的写入,且性能开销极低。
Uberage的另一种机制是代码指针沙盒化。其实现方式就是从JS对象中移除直接存储的代码指针,改为使用一个索引指向内存中独立隔离区域的代码指针表。
最后,Ubercage 还移除了 Typed Array 对象中完整的 64 位指针,此前,攻击者可利用这些对象的数据指针(backing store) 构造任意读写原语但,Ubercage 的部署使此攻击路径彻底失效。
垃圾收集
垃圾收集是一个内存的管理过程,是一种自动释放无引用对象内存的管理机制。在V8引擎中,存在新生代和老生代两个概念:当新生代的From-Speace被填满时,就会触发新生代的GC清理无用对象,而另外一侧的To-Speace则会以相同的方式工作,若对象在经历两次GC之后仍然存活,就会移入老生代空间。可以阅读这个博客来对此知识有更加深入的了解。
V8中的对象表示
V8在64位构建的时候会采用指针压缩技术,所有指针在V8堆中均以32位值存储。为了区分当前的值时指针还是小整数(SMI),V8会采用指针标记机制,即指针的末位设为1,则将该位与V8堆基值相加,解压缩为完成指针,而SMI则会将数值左移一位,末位保留0。读取时右移一位即可,下面我们来看代码具体了解一下。

下面我们查看一下gdb中的内存视图
1 | ##### array a |
上图可以看出0x1e右移一位刚好与数组的15对应,下面我们来看b数组
1 | ##### array b |
V8 对象包含两种属性类型:
- 数字属性(如
obj[0]、obj[1]):- 通常存储在一个由
elements指针指向的连续数组中。
- 通常存储在一个由
- 命名属性(如
obj["a"]或obj.a):- 默认存储在对象自身的内存块内(内联属性)。
- 当新增属性数量超过默认阈值(通常为 4 个)时,后续属性将存储在一个由
properties指针指向的连续数组中。
漏洞分析
补丁分析
惯例我们先从补丁开始看
1 | @@ -5597,6 +5597,7 @@ |
补丁很简单就添加了一个函数ClearCurrentRawAllocation(),该函数如下,其目的就是将current_raw_allocation_指针置为空,下面我们来分析一下这个指针的作用以及为何会触发漏洞。
1 | void MaglevGraphBuilder::ClearCurrentRawAllocation() { |
分配折叠
Maglev试图通过将多次内存分配合并为单个大分配来优化内存分配策略。其核心机制是维护一个指向最后一次执行内存分配的节点(AllocateRaw节点)的指针。当后续出现新的内存请求时,系统会执行若干检查:若条件满足,则会直接扩展前次分配的容量,将新请求的大小累加到原有分配上。例如,若先前请求分配了12字节内存,后续又请求88字节,Maglev会将第一次分配扩展为100字节,并完全消除第二次独立分配。此时前12字节用于原始请求,后续88字节空间则服务于第二次请求。
当Maglev执行代码降级(lowering)并遇到需要内存分配的场景时,会调用MaglevGraphBuilder::ExtendOrReallocateCurrentRawAllocation()函数。该函数的源代码实现如下所示。
1 | // File: src/maglev/maglev-graph-builder.cc |
函数的目标是 合并连续的内存分配请求 以减少内存分配次数。通过复用或扩展前一次分配,避免频繁调用内存分配器,提升性能。假设连续两次调用:
ExtendOrReallocateCurrentRawAllocation(12, NEW_SPACE)- 首次无当前分配 → 创建
AllocateRaw(12),current_raw_allocation_指向它。
- 首次无当前分配 → 创建
ExtendOrReallocateCurrentRawAllocation(88, NEW_SPACE)- 类型相同且未超限 → 扩展
AllocateRaw至 100 字节,返回FoldedAllocation节点指向偏移 12。
- 类型相同且未超限 → 扩展
内存布局:
1 | [AllocateRaw(100)][FoldedAllocation(offset=12)] |
Maglev通过这种方式优化内存分配次数,在代码中该技术被称为分配折叠(Allocation Folding),而那些通过扩展前一次分配大小而被优化掉的分配则称为折叠式分配(Folded Allocations)。然而,这里存在一个与**垃圾回收(Garbage Collection, GC)**相关的隐患。如之前章节所述,V8引擎采用**移动式垃圾回收器(Moving Garbage Collector)**。因此,如果在两次“折叠式”分配之间触发了GC,会导致以下问题:
- 第一次分配的对象被移动
GC会将第一个分配已初始化的对象移动到堆中的其他位置(例如内存整理期间)。 - 为第二次分配保留的空间被释放
由于GC发生在第一个对象初始化之后、第二个对象初始化之前,GC无法感知到第二个分配的存在(此时第二个对象尚未初始化)。因此,GC会认为原分配空间中为第二个对象预留的部分是“空闲内存”,并将其释放。 - 越界写入风险
当后续尝试初始化第二个对象时,FoldedAllocation节点会基于原始分配的偏移量(例如偏移12字节)计算地址。但由于第一个对象已被移动,原始分配的内存可能已被回收或重新分配,此时基于旧地址的偏移量写入数据将导致越界写入(Out-of-Bounds Write)。
BuildAllocateFastObject函数
BuildAllocateFastObject() 函数是对 ExtendOrReallocateCurrentRawAllocation() 的封装函数,其核心功能是**通过多次调用 ExtendOrReallocateCurrentRawAllocation()**,代码如下
1 | // File: src/maglev/maglev-graph-builder.cc |
如 [1] 处代码所示,BuildAllocateFastObject() 函数在需要分配内存时会调用 ExtendOrReallocateCurrentRawAllocation(),随后将分配的内存初始化为对象数据。此过程中有一个关键设计细节:该函数不会在完成后主动清除 current_raw_allocation_ 变量,而是将此责任交给调用方,由其在适当时机通过调用 MaglevGraphBuilder 的辅助函数 ClearCurrentRawAllocation() 将 current_raw_allocation_ 置为 NULL。若未正确清理此变量,可能导致跨 GC 边界的错误折叠分配,进而引发越界写入漏洞。
VisitFindNonDefaultConstructorOrConstruct函数
FindNonDefaultConstructorOrConstruct(查找非默认构造函数或构造)字节码操作码用于构建对象实例。该操作码会从构造函数的超构造函数开始沿着原型链向上遍历,直到发现一个非默认构造函数。正如我们之前在测试案例中看到的情况,如果最终遍历到默认的基构造函数(如基础场景),则会创建该对象的实例。
Maglev编译器通过调用VisitFindNonDefaultConstructorOrConstruct()函数,将这个操作码转换为Maglev中间表示(IR)。该函数代码如下。
1 | // File: src/maglev/maglev-graph-builder.cc |
我们可以发现其在[1]处调用了**TryBuildFindNonDefaultConstructorOrConstruct()**函数,该函数就是我们此次漏洞所在的地方,下面我们来看一下这个函数
TryBuildFindNonDefaultConstructorOrConstruct函数
这个函数代码如下
1 | bool MaglevGraphBuilder::TryBuildFindNonDefaultConstructorOrConstruct( |
这个函数的本质就是遍历正在构造对象的原型链,以便找出的一个非默认构造函数,并利用该信息来构造对象实例。为了确保漏洞利用条件,我们可以先看一下这个函数。
首先在开头我们可以发现被构造实例的对象必须是”常量”。而后面则开始遍历原型链。循环中的current_function保存当前父对象的构造函数,若某个父类构造函数并非函数类型,则循环会终止。
后面则会通过FunctionKind枚举来判断当前父类构造函数的类型,若为默认派生构造函数,则会跳转至结尾,反之则会进入处理非默认构造函数的代码块。之后会有一个关键操作就是验证new.target的常量性,之后会进一步确定其是否是有效常量,若是则会通过条件,之后便会调用函数BuildAllocateFastObject(new_target),该函数内部会调用ExtendOrReallocateCurrentRawAllocation(int size, AllocationType allocation_type)函数,而在调用BuildAllocateFastObject()函数后,其不会清理current_raw_allocation_常量,那么如果原始分配和折叠分配的期间,触发了垃圾回收,就会导致越界读写。
exp
下面我们就可以开始进行对漏洞的利用了
漏洞演示
首先我们来写一段poc来了解一下该漏洞可以完成的操作以便后续来进行漏洞利用
1 | let empty_object = {}; |
我们在realse模式下执行该代码,并使用gdb查看布局结果如下
1 | ------- this ------- |
我们可以发现两者的elements是相同的,这就是因为在分配折叠之前发生了GC进一步导致了x数组的elements被覆盖为了a数组的elements,之后我们查看一下gdb里面的内存
1 | ----- this ------ |
我们可以发现其内存布局如下

初始OOB
有了上述的结论之后我们就可以实现最初版本的OOB了。
泄露地址
1 | function addrof_tmp(obj) { |
写入地址
在写入的时候我们需要创建一个double类型的数组,然后对其进行操作
1 | let rwarr = [1.1, 2.2, 2.2]; |
上述代码是计算rwarr数组的elements和 a数组数据区起始位置的偏移,运行上述代码然后我们使用gdb可以查看到如下内存布局
1 | ----------代码运行结果-------------- |
我们可以数一下rwarr数组的elements距离起始位置的偏移刚好是我们所计算的mark值是一样的,因此我们可以通过此方式来获取和修改rwarr数组elements所指向的区域。
下面是实现该功能的函数
1 | function write(where, what) { |
获取GC抗性
由于V8垃圾回收机制的存在,我们需要分配若干对象并利用垃圾回收机制将其迁移至老生代内存空间,随后使用初始原语对这些对象进行篡改,并最后将保留在新生代的对象进行修复。
下面是完成该步骤的代码
1 | let changer = [1.1,2.2,3.3,4.4,5.5,6.6] |
这里就是获取GC对抗的代码,下面我们就可以开始书写真正的读写原语了。
最终OOB
1 | function v8h_read64(addr) { |
绕过V8沙箱
我们需要创建两个WASM实例来绕过V8沙箱,一个用于存储shellcode,另一个用于篡改指向shellcode的64位指针
首先我们来看第一段wasm实例
1 | let shell_wasm_code = new Uint8Array([ |
上述代码的0x48n是wasm段距离wasm实例开始的偏移,之后的0xB40n和0x2Dn则是通过调试得到的函数地址和shellcode起始地址,我们可以在gdb中查看一下
1 | pwndbg> x/10i 0x27b04622e000 + 0xB40 |
执行shellcode
1 | var wasmCode = new Uint8Array([ |
执行exp

exp
1 | let empty_object = {}; |