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
中:
- 跳过对 hole 值的检查
- 在整个可选链操作过程中保持 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 的检查:
- 如果某个变量之前已经被确认不是 hole
- 如果某个上下文没有明确需要检查
因此旧版与未修复的版本中当 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创作,禁止抄袭转载
(完)