概述
该漏洞于 2025 年 6 月 30 号被 Google 修复并证实存在在野漏洞,之后时隔两天,相关 poc 便被研究员 @DarkNavyOrg 在 X 上公开,运行后效果如下。

详细分析
漏洞的 poc 非常简单,可以看到核心就是函数 f,结合崩溃所示,可知这里 f 最终返回的实际上是一个 hole,hole 是 v8 中一个特殊的变量,用于表示形如 [1,,3] 中间空的变量位置。
function f() {
let x;
delete x?.[y]?.a;
return y;
let y;
}
let hole = f();
let map = new Map();
map.delete(hole);
Hole 如此特殊,是因为该值是 v8 中内部一个不应公开给用户的变量,通过 hole,往往能构造出可利用的读写原子,从而实现代码执行,而距今最近的通过 hole 实现代码执行的就是 2023 年的在野漏洞 CVE-2023-3079,安全研究员 mistymntncop 也公开了相关的利用方法 https://github.com/mistymntncop/CVE-2023-3079,而这次的 CVE-2025-6554 的出现也表明,一种新的 hole 利用方法已经被攻击者使用。
来看核心的 f 函数,其通过 let 的方式定义了两个变量 x,y。
1. x 变量通过 let 定义之后并没有进行赋值,因此这种情况下 x 的值是 undefined 的状态。
2. y 变量也是通过 let 定义的,但是这里 y 在定义前就被使用了,因此实际上 y 是处于一个暂时性死区 (TDZ) 的状态。
暂时性死区 (TDZ) 指 javascript 中,使用 let 或 const 声明的变量在初始化前是无法访问的。如果尝试访问,会抛出一个 ReferenceError 的错误,如下面的代码就是这样的情况。
function g() {
return x;
let x = 1; // ReferenceError
}
实际上 f 中也是如此,而漏洞的核心应该是 delete x?.[y]?.a; 毕竟整个 poc 中也没有别的代码了,因此初步分析就是 delete x?.[y]?.a; 及 y 的暂时性死区导致 return y 返回的是一个 hole。
来看这段核心代码 delete x?.[y]?.a;
function f() {
let x;
delete x?.[y]?.a;
return y;
let y;
}
直接从代码层面理解,x?.[y] 使用了可选链(Optional Chaining)操作符 ?.,如果 x 不是 null 或 undefined,则访问 x[y],否则返回 undefined。x?.[y]?.a 同理,如果 x[y] 存在,则访问其属性 a,否则返回 undefined,因为 x 是 undefined,所以 x?.[y] 返回 undefined,delete 用于删除对象上的属性,由于 x?.[y] 会返回 undefined,整个表达式变成 delete undefined.a,delete undefined.a 是合法的,结果为 true。
但是由于 let y 是在 return y 之后,此时 y 处于 TDZ。因此抛出 ReferenceError,函数终止,不执行 return y,因此 y 乍一看不会被赋值。当然以上的情况是没有漏洞的情况下的单纯分析,但是实际的情况是此时 return y 返回了 hole,造成了该漏洞。
该漏洞的修复 diff 如下,在函数 OptionalChainNullLabelScope 中增加了 hole_check_scope_,也就是说漏洞版本中该函数这个位置确实是有可能参数 hole 变量的。
https://chromium.googlesource.com/v8/v8.git/+/22e9d9621de58ec6fe6581b56215059a48451b9f%5E%21/#F0

查看对应函数的引用位置,发现一共有两处:

两处函数都在 BytecodeGenerator 这个类中,其中一个函数为 BuildOptiontChain。

另一个函数则是 VistDelete。

核心的修改总结如下,BuildOptionalChain 函数中,相应 HoleCheckElisionScope 从函数 BuildOptonalChain 中删除了,但是介于 hole_check_scope_ 删除的前一句代码就是补丁函数 OptionalChainNullLabelScope,其中增加了 hole_check_scope_,这里 BuildOptonalChain 更像是一处代码的优化,因此我们的主要精力会集中到 VistDelete。

这里我们的首先的第一个疑问,在以下的函数中 y 为什么返回的是 hole,这里一开始我以为和 delete x?.[y]?.a; 有关,但是实际上这种想法是错误的。
function f() {
let x;
delete x?.[y]?.a;
return y;
let y;
}
这就要详细说明前面提到的 TDZ 机制,如前文所说,暂时性死区(TDZ)指 javascript 中,使用 let 或 const 声明的变量在初始化前是无法访问的,如漏洞函数中的 y 其实就是这样的情况,y 在 delete 的表达式中被使用,但是定义其 let y 却是在有使用之后,在 ES6 的定义中提到,当访问处于 TDZ 状态下的变量时,将会导致一个 ReferenceError 的错误,这在我们前面的分析中也提到,当 x?.[y] => x.[y] 访问到 y 时,对应的 REferenceError 将会抛出,对应的函数将直接返回,不会往下执行。
而在 v8 中这一特性的实现就是通过 hole 来实现的,即处于类似 TDZ 状态这样的变量,在 v8 中其值就是 hole,当 v8 检测到 hole 这个特殊的值时,将会通过函数 ThrowReferenceErrorIfHole 返回 ReferenceError,详细见以下的 v8 文档 。
https://docs.google.com/document/d/1klT7-tQpxtYbwhssRDKfUMEgm-NS3iUeMuApuRgZnAw/edit?tab=t.0

到这里就明白实际上在 v8 中,poc 中定义的 y 当其处于 TDZ 的状态时,其默认的值就是 hole,我们的研究问题也从之前的 delete x?.[y]?.a; 是如何导致的 y 为 hole,变成了 delete x?.[y]?.a; 到 return y 中为何没有检测到 y 这个 hole,从而导致 hole 值返回。
这里通过 d8 的 --print-bytecode 输出 poc 生成的 bytecode,如下 leak_hole 函数对应的 bytecode 可以看到这里 y 保存到 r1 寄存器中,其初始值就是一个 hole,而 r2 则是 let x,由于 x 没有进行初始化,其值对应的就是 undefined,往下到了第七行 JumpIfUndefinedOrNull,判断 x 是否为 undefined 或 null,如果是则跳转到 25 行,实际上我们的代码在这里就是符合这个跳转逻辑的,跳转到 25 行,之后往下 26 行 Ldar r1(y),并直接返回,可以看到 hole 因此就直接返回,其并没有在返回的时候发生检测,而 bytecode 中 9 行处的检测则是 x?.[y] 是生成,这里的前提是 x 不能为 undefined/null,在这种情况下 11 行会通过 ThrowReferenceErrorIfHole 判断 y 是否为 hole,如果是 hole 直接触发抛出一个 ReferenceError,因此从 bytecode 上来看,当 x 的值为为 undefined 或 null 时的执行路径上确实没有对 y 的 hole 检测,因此直接导致了 hole 值的返回。

那我们来看看,为什么 bytecode 的阶段在 x 的值为 undefined 或 null 的分支中没有对返回时的 y 调用 ThrowReferenceErrorIfHole ?
搜索 ThrowReferenceErrorIfHole 函数的调用,尽管有很多,但是通过前面相关函数,可知该漏洞涉及的函数基本位于 Ignition 解释器的字节码生成相关代码 bytecode-generator.cc 中,而该文件中涉及 ThrowReferenceErrorIfHole 的调用只有一处。

其调用的函数是 BytecodeGenerator::BuildThrowIfHole,该函数用于在字节码生成阶段插入一条指令,用于检查某个变量是否为“hole”(即未定义或处于 TDZ 中,也就是我们前面字节码中 11 行处的 ThrowReferenceErrorIfHole ),如果是,则抛出相应的错误,函数的检测中首先需要对 this 进行特殊的处理,因为 this 使用 hole 值来跟踪派生类构造函数中是否调用了 supre,除 this 外,统一通过函数 ThrowReferenceErrorIfHole 来检测该变量是否为 hole,最后调用 RememberHoleCheckInCurrentBlock,该函数很重要,但是这里先将其放到后面再细说。

BytecodeGenerator::BuildThrowIfHole 在 bytecode-generatior.cc 中的调用有以下几处:

其中一处对应的函数用于生成赋值相关的字节码,和我们的 poc 关联不大。

而其余的引用都集中在 BytecodeGenerator::BuildVariableLoad,为 JavaScript 中的变量读取操作生成相应的 Ignition 字节码。

函数的核心是一个 switch 语句,根据 variable->location() 的值选择不同的加载方式。

其中的第一项局部变量加载:VariableLocation::LOCAL 就对应了以下的代码,可以看到和 y 的 return 是完全一致的。
function foo() {
let x = 42;
return x; // 这里会触发 LOCAL 加载
}
而在 VariableLocation::LOCAL 的代码模块中可以看到,对应的 BuildThrowIfHole 是有调用的,但是通过前面的字节码分析可知,return 之后其实并没有 ThrowReferenceErrorIfHole 相关的生成,也就是我们的 BuildThrowIfHole 有可能没有调用,那影响 BuildThrowIfHole 调用的除了 BuildThrowIfHole 本身外,在这里映入我们眼帘的就是函数 VariableNeedsHoleCheckInCurrentBlock。

可以直接对 BuildThrowIfHole 下断点,来看 poc 中具体对哪些变量进行了 hole 的检测,运行之后可以看到 BuildThrowIfHole 只调用了一次,其就是在前面提到的 VisitDelet 中,该函数的作用是为 JavaScript 中的 delete 表达式生成对应的字节码(当 x 的值非为 undefined 或 null 时,11 行 bytecode 出的 ThrowReferenceErrorIfHole 检测),也就是说确实在 return y 相关的指令生成时并没有对应的 BuildThrowIfHole 调用,以用于对 y 变量是否为 hole 进行检测,从而导致了 return 返回一个 hole。

结合以上的堆栈调用,通过分析 VisitDelete 可知,其正好是在处理可选链(Optional Chaining),else if (expr->IsOptionalChain() ) 这个分支后(其主要就是处理类似 delete obj?.prop; 的指令,和我们的 poc 中是一个模式),在 VisitForAccumulatorValue 这个访问 y 的操作中最终调用了 BuildThrowIfHole 以用于 y 变量的 hole 检测,而 VisitForAccumulatorValue 的前一句代码就是该漏洞的主要补丁函数 OptionalChainNullLabelScope 。

OptionalChainNullLabelScope 函数中只是简单的加了一处 hole_check_scope_,其定义的核心是 HoleCheckElisionScope ,该函数是如何实现修补从而防止 return y 中的 hole 返回的了?

有意思的是 HoleCheckElisionScope 和我们前面提到的 RememberHoleCheckInCurrentBlock 及影响 BytecodeGenerator::BuildVariableLoad 中 BuildThrowIfHole 调用的函数 VariableNeedsHoleCheckInCurrentBlock 都有着紧密的联系,要理清楚这其中的关联,就需要引出 hole_check_bitmap_ 这个关键变量。
hole_check_bitmap_,该变量为 BytecodeGenerator 类中用于记录一个 basic block 里的变量是否已经完成了 hole 检测,其主要被函数 HoleCheckElisionScope 和 HoleCheckElisionMergeScope 进行管理使用,有点类似 Windows 内核中 cfg 里的 bitmap。

如下所示,HoleCheckElisionScope 类是一个作用域类,用于在类似条件执行的 basic block 中忽略 hole 检查,简单来说就是,当进入一个基本的 basic block 时,调用生成一个 HoleCheckElisionScope 类,其会保存之前的 hole_check_bitmap_,并生成一个全新的空白 hole_check_bitmap_,在这个作用域中,一旦有对变量进行过 hole 的检测,该记录会写入到 hole_check_bitmap_ 中,从而保证在同一作用域下,一个变量的 hole 只需要一次 hole 检测,以此提高引擎的执行效率,当一个作用域结束,对应的 HoleCheckElisionScope 类的析构函数调用,hole_check_bitmap_ 恢复成之前的状态。

hole_check_bitmap_ 通常被 RememberHoleCheckInCurrentBlock 和 VariableNeedsHoleCheckInCurrentBlock 使用,RememberHoleCheckInCurrentBlock 主要在之前提到的函数 BuildThrowIfHole 中使用,当对一个变量通过 ThrowReferenceErrorIfHole 进行 hole 检测后,调用 RememberHoleCheckInCurrentBlock 在 hole_check_bitmap_中记录下该变量已经通过了 hole 检测,之后在该 block 中无需再做检测,其具体实现在函数 RememberHoleCheckInBitmap 中。

VariableNeedsHoleCheckInCurrentBlock 则通常用于判断一个变量在当前 block 中是否需要进行 hole 检测,其也是依赖于 hole_check_bitmap_ 中的记录。

通过以上的代码我们就能总结如下,v8 中通过 hole_check_bitmap_记录一个 basic black 中的变量 hole 检测情况,每一个新条件执行 basic block 都会调用 HoleCheckElisionScope 类记录之前的 hole_check_bitmap_ privbitmap,并生成一个空白的 hole_check_bitmap_ currentbitmap 来记录当前的 basic block 的变量的 hole 检测情况,在当前的 basic black 中调用 VariableNeedsHoleCheckInCurrentBlock 通过检测 currentbitmap 来判断一个变量是否已经检测过 hole 状态,如果没有则调用 BuildThrowIfHole 检测该变量是否为 hole,本质上会生成一个 ThrowReferenceErrorIfHole 字节码用于该变量的 hole 检测,如果是 hole,则抛出一个 ReferenceError,之后通过函数 RememberHoleCheckInCurrentBlock 在 currentbitmap 中留下该变量为 hole checked 的记录,表征该变量之后在该 block 中无需检测,当该 basic block 返回,会清空当前 currentbitmap 中的状态,并将 hole_check_bitmap_ 设置为 privbitmap。
也就是说对于一个 js 的函数,除了一开始生成的空白 hole_check_bitmap_,每进入一个条件 basic block,都会生成一个独立空白的 hole_check_bitmap_ currentbitmap 以记录其中变量的 hole checked 状态,并在结束该 basic block 时恢复前一个 hole_check_bitmap_ privbitmap,这样做的目的就是为了保证每一个基础的 basic block 的作用域完整性,如果进入一个 basic block 时没有调用 HoleCheckElisionScope 生成独立的 hole_check_bitmap_ currentbitmap,则在该 basic block 中任何对 hole_check_bitmap_的修改就有可能被带出该 basic block,而从造成漏洞。
最为典型的例子,进入一个函数 vul,生成默认基础的 hole_check_bitmap_ basicbitmap,basicbitmap 为空白,进入一个 basic block,该 block 中没有调用 HoleCheckElisionScope 生成独立的 currentbitmap,但是在该 block 中却有 BuildThrowIfHole 的调用,并生成了 ThrowReferenceErrorIfHole 检测字节码,由于没有生成独立的 currentbitmap,y 变量为 hole checked 的状态实际上通过 RememberHoleCheckInCurrentBlock 写入到了 basicbitmap。此时由于 x 为 undefine,字节码层面将直接跳转到 return y,则在对应的 VariableNeedsHoleCheckInCurrentBlock 函数中确认 y 是否被检测过 hole 状态,此时基于 basicbitmap,这里的 y 已经被检测过,BuildThrowIfHole 函数跳过,return 直接将值为 hole 的 y 返回,从而造成漏洞。

这里我们针对漏洞版本直接对 BuildThrowIfHole 下断点,可以看到 poc 触发后 BuildThrowIfHole 只调用了一次,其源头是函数 VisitDelete。

而在 VisitDelete 中最终调用 BuildThrowIfHole 的位置是 VisitForAccumulatorValue,通过前面的分析可知,该漏洞的修补的位置就是 VisitForAccumulatorValue 前面的 OptionalChainNullLabelScope,在该函数中增加了 HoleCheckElisionScope ,这就确保了函数 VisitDelete 中之后的 BuildThrowIfHole 的 y hole checked 标记仅仅维持在 VisitDelete 函数内,从而防止之后 return 时跳过 y 的 hole 检测从而将 y 作为 hole 返回。

这里在补丁的 v8 版本中运行该 poc,可以看到如下所示在 return y 返回前,多生成了一个 ThrowReferenceErrorIfHole 指令,保证该 hole 的 y 值将不会被返回。

具体来看,同之前一样第一个 BuildThrowIfHole 是在 VisitDelet 中返回。

继续向下,补丁版本 VisitReturnStatement 的返回操作中,BuildVariableLoad 调用 BuildThrowIfHole 再次生成一条 ThrowReferenceErrorIfHole ,从而防止 hole 值的返回,其根本原因在于前面 VisitDelete 中调用 BuildThrowIfHole,并通过 RememberHoleCheckInCurrentBlock 记录的 y hole checked 状态,被 VisitDelete 中的补丁函数 OptionalChainNullLabelScope 里的 HoleCheckElisionScope 限制在了 VisitDelete 生成的 bytecode 范围内。

当 return y 对应的 BuildVariableLoad 调用时,VariableNeedsHoleCheckInCurrentBlock 检测的 hole_check_bitmap_中并没有记录 y 值已进行 hole checked 检测,因此会再次调用 BuildThrowIfHole,并在检测中确认 y 为 hole 并抛出一个 ReferenceError,从而防止 y 值返回。

具体来看,此时 VariableNeedsHoleCheckInCurrentBlock 中已经是需要检测的状态。

同时函数 HasRememberHoleCheck 也返回的 false,表明 y 值需要进行 hole 检测,从而保证调用 BuildThrowIfHole。

该漏洞的本质上是 VisitDelete 中没有调用 HoleCheckElisionScope,导致在这个函数中调用 BuildThrowIfHole 时的 y hole check 状态外泄到了父级的 basic bitmap 中,而 let x 的设置,使得 x 为 undefine,从而在字节码执行层面不会进入到 BuildThrowIfHole 生成的用于检测 y hole 状态的 ThrowReferenceErrorIfHole ,而是直接跳转到 return y,在 renturn y 时判断是否进行 y hole 检测是基于这个父级的 basic bitmap,此时 basic bitmap 中 y 已经 hole checked,因此直接跳过了检测,导致 y hole 返回。
HoleCheckElisionScope 和 BuildThrowIfHole 必须对称调用,再直白点就是 BuildThrowIfHole 的调用必须在一个独立的 HoleCheckElisionScope 中,否则 BuildThrowIfHole 设置后将导致变量的 hole 状态检测外泄到父级从而造成漏洞。

参考链接
[1].https://gist.github.com/mistymntncop/37c652c2bf7373b4aa33bb50f52ee0f2
[2].https://github.com/DarkNavySecurity/PoC/blob/main/CVE-2025-6554/poc.js