前两天复现了cve-2026-7899, 也是一种没见过的漏洞路子, 而且感觉这类错误还挺经典的. 和TDZ都有点像是代码实际执行路径和编译器的状态传播路径不一致导致的bug.

修复commit为bb38f8914db99bd3bed6758132b104a9af00ca04.

漏洞背景和概念

ssa

1
2
3
4
5
6
if (c) {
x = 1;
} else {
x = 2;
}
y = x + 3;
1
2
3
4
5
6
7
if_true:
x1 = 1
if_false:
x2 = 2
merge:
x3 = phi(x1, x2)
y1 = x3 + 3

理论上每当一个变量被赋值, 就会生成一个新的ssa节点.

phi

1
2
3
4
5
6
7
let a;
if (cond) {
a = x;
} else {
a = y;
}
use(a);
1
2
3
4
5
6
7
then:
a1 = x
else:
a2 = y
merge:
a3 = Phi(a1, a2)
use(a3)

一个很典型的phi合流的例子, 通过这个概念, 编译器可以追踪变量的状态和前驱节点.

load elimination

1
2
x = obj.field;
y = obj.field;
1
2
x = load obj.field
y = x

Load elimination 是一种编译器优化:如果编译器能证明某次 load 读出来的值已经知道了,就把这次 load 去掉,直接复用已有值.

漏洞原因

1
2
3
4
5
6
7
8
9
10
11
12
13
diff --git a/src/compiler/turboshaft/wasm-load-elimination-reducer.h b/src/compiler/turboshaft/wasm-load-elimination-reducer.h
index 95f6833ab2a..a7ccbff8994 100644
--- a/src/compiler/turboshaft/wasm-load-elimination-reducer.h
+++ b/src/compiler/turboshaft/wasm-load-elimination-reducer.h
@@ -995,6 +995,8 @@ void WasmLoadEliminationAnalyzer::ProcessPhi(OpIndex op_idx, const PhiOp& phi) {
}
if (same_inputs) {
replacements_[op_idx] = first;
+ } else {
+ replacements_[op_idx] = OpIndex::Invalid();
}
}
}

replacements_是一个替代表, 会记录某个操作可以被另一个操作替代. 例如下面的例子中, 第二个 struct.get 就可以被替换为第一个

1
2
x = struct.get holder.field
y = struct.get holder.field

看到diff中, 在!same_inputs的情况下, 没有重置replacements_[op_idx]. 这也就意味着只要被记录进replacements_[op_idx], 后续即使编译器发现same_inputs的假设被破坏, 也不会重置被替换的操作.

伪代码poc

在第一次进入循环时, holder.ref的值是big, 这就导致两个分支情况下arr都是big. 于是编译器暂时的认为arr可以被big替代.
而在第二轮循环时, holder.ref已经被改为small, 但由于前文说过的原因, replacements_[op_idx]没有被重置为OpIndex::Invalid. 于是编译器继续认为arr一定big. 但实际上arr会被赋值为holder.ref, 此时holder.ref为small. 于是编译器删去了这里本应有的bound check.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
big = new Arr(8);
small = new Arr(1);
victim = new Arr(1);

holder.ref = big;

for (i = 2; i > 0; i--) {
if (i == 2)
arr = big;
else
arr = holder.ref;

arr[3] = 1000; // 第 2 轮应 trap

holder.ref = small;
}