1. WebAssembly内存模型的安全含义
1.1 线性内存模型(Linear Memory Model)
WebAssembly采用单片线性内存模型(monolithic linear memory model),整个内存空间是一块连续的字节数组,地址从0开始递增。模块通过memory.grow指令以64KB为单位扩展内存,通过load/store指令在边界内自由读写。这套设计让C/C++代码可以编译为Wasm并在浏览器中以接近原生速度执行,因为Wasm的线性内存可以直接承载dlmalloc/emmalloc等堆管理器和手动管理的栈区。
(module
(memory (export "mem") 1) ;; 1页 = 64KB 初始内存
;; 线性内存布局示意:
;; [0x00000 - 0x0FFFF] Data段(全局变量、字符串常量)
;; [0x10000 - 0x1FFFF] Heap(malloc分配区域,向上增长)
;; [0x3FFFF - 0x3FFxx] Stack(从顶部向下增长)
)
关键安全特征:Wasm模块内的所有指针都是线性内存内的偏移量(offset),绝对无法指向宿主机内存。V8引擎在每条内存访问指令前插入边界检查,确保offset + access_size <= memory.size * 64KB。但这仅保证不越界访问线性内存本身——在边界以内,所有内存区域之间没有任何隔离。
这意味着Wasm堆、Wasm栈和数据段共享同一线性地址空间。一个堆缓冲区溢出可以覆盖栈上的返回地址,一个栈缓冲区溢出可以向下污染堆元数据或数据段中的全局变量。这与原生进程地址空间中由MMU页表和分段机制提供的隔离有根本区别:
- 原生进程:代码段、数据段、堆、栈由OS虚拟内存系统映射到不同虚拟页,各区域间存在不可访问的guard page
- Wasm线性内存:所有区域在同一连续字节数组中,相邻区域之间无任何保护间隙
正如USENIX Security 2020论文《Everything Old is New Again: Binary Security of WebAssembly》所指出的:"WebAssembly's linear memory is a single, contiguous memory space without any holes, so every pointer in [0, max_mem] is valid. As long as the attacker stays within this bound, any read or write will succeed. This is a fundamental limitation of linear memory with severe consequences."
1.2 无ASLR的攻击面
Wasm线性内存内没有地址空间随机化(ASLR)。每次实例化同一Wasm模块时,数据段、堆起始位置、栈顶位置完全确定。这从根本上改变了内存破坏exploit的可靠性:
攻击面确定性分析:
- 数据段布局:编译时确定,链接后固定。全局变量、函数指针表、字符串常量在内存中的位置在所有运行实例间完全一致
- 堆布局:emscripten默认使用dlmalloc,首次
malloc返回的地址在固定偏移处(通常紧跟数据段之后),且堆分配顺序和大小由程序逻辑决定,在相同输入下完全可重现 - 栈布局:Wasm没有硬件栈,栈由编译器在线性内存中自行管理。全局栈指针的初始值在模块加载时固定
原生进程地址空间(有ASLR):
┌─────────────────────────┐ ← 随机基址
│ Stack (向下增长) │
│ ████████████████████ │ ← guard page(不可访问)
│ ▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼ │
│ │
│ ████████████████████ │ ← guard page
│ Heap (向上增长) │
│ ████████████████████ │ ← guard page
│ BSS / Data │
│ Text (RO) │
└─────────────────────────┘
Wasm线性内存(无ASLR):
┌─────────────────────────┐ ← 固定基址 0x00000
│ Data Segment │ ← 编译时确定
│ ────────────────────── │ ← 无隔离
│ Heap (向上增长) │ ← 首次malloc地址固定
│ ────────────────────── │ ← 无隔离
│ (空闲区域) │
│ ────────────────────── │ ← 无隔离
│ Stack (向下增长) │ ← 栈顶固定
└─────────────────────────┘ ← memory.size * 64KB
与原生exploit的对比:
| 维度 | 原生进程 | Wasm模块 |
|---|---|---|
| ASLR | 有(PIE + ASLR) | 无,布局完全确定 |
| 区域隔离 | MMU guard page | 无,连续字节 |
| 堆布局确定性 | 受ASLR影响,需heap spray | 完全确定,无需spray |
| 栈canary | 编译器插入 | 无(Wasm无硬件栈概念) |
| DEP/NX | 硬件强制 | Wasm无法执行内存中的数据 |
| 代码注入 | 可能(ROP/JOP) | 不可能(内存不可执行) |
| 数据指向代码 | 可能 | 不可能(代码在独立空间) |
注意最后三行:Wasm的代码段不在线性内存中,而是由引擎单独管理,线性内存中的数据无法被当作代码执行。这消除了传统的shellcode注入和ROP攻击,但数据破坏攻击——修改控制流数据、覆盖函数指针、污染对象元数据——依然完全有效。
1.3 安全边界
Wasm的安全依赖三层防线:
第一层:边界检查。 V8在每条memory.load/memory.store指令执行前验证addr + len <= memory.size * PAGE_SIZE,越界访问触发trap。这个检查是确定性的、不可绕过的——除非引擎本身存在漏洞。
第二层:类型验证。 Wasm模块在实例化前经过严格的类型检查(validation phase)。所有函数签名、局部变量类型、指令操作数类型在加载时即被验证。这消除了类型混淆类的低级错误。
第三层:浏览器沙箱。 即使Wasm模块被攻破,攻击者仍被限制在浏览器渲染进程的沙箱内。Chrome使用多层沙箱:站点隔离(Site Isolation)+ 渲染进程沙箱 + V8堆沙箱。
但沙箱并非不可穿透。CVE-2026-6307(Longinus)证明,单个V8 JIT编译器漏洞即可同时穿透Chrome渲染进程沙箱和V8堆沙箱,实现完整的RCE链。更根本的问题是:Wasm线性内存内的数据破坏可以直接影响宿主JavaScript环境。当Wasm通过externref持有对JS对象的引用时,破坏线性内存中的引用数据可能导致类型混淆,进而攻击JS引擎本身。
2. Wasm内存破坏攻击技术
2.1 堆溢出
C/C++编译为Wasm后,其内存管理行为(使用dlmalloc/emmalloc)在线性内存中完整保留。经典堆溢出在Wasm中的表现与原生环境几乎一致,且由于无ASLR,利用的确定性和可靠性更高。
// vulnerable.c - 编译为Wasm后存在堆溢出
// 编译: emcc vulnerable.c -O2 -s EXPORTED_FUNCTIONS="['_vulnerable_copy','_allocate_buffer']" -s WASM=1
#include <emscripten/emscripten.h>
#include <string.h>
#include <stdlib.h>
EMSCRIPTEN_KEEPALIVE
void vulnerable_copy(char *dst, const char *src, int size) {
// 没有边界检查的拷贝
memcpy(dst, src, size); // 如果size > dst分配的大小,溢出
}
EMSCRIPTEN_KEEPALIVE
char* allocate_buffer(int size) {
return (char*)malloc(size);
}
对应的攻击PoC(教学用途):
// attack_heap_overflow.js - 教学PoC,演示Wasm堆溢出原理
async function demonstrateHeapOverflow() {
const wasm = await WebAssembly.instantiateStreaming(
fetch('vulnerable.wasm'),
{ env: { emscripten_notify_memory_growth: () => {} } }
);
const { allocate_buffer, vulnerable_copy } = wasm.instance.exports;
// 分配两个相邻堆块
const bufA = allocate_buffer(32); // victim
const bufB = allocate_buffer(64); // 用于构造攻击数据
// bufA和bufB在线性内存中相邻
// 溢出bufB的数据将覆盖bufA及后续堆元数据
const payload = new Uint8Array(128); // 远超bufB的64字节
// 填充payload以观察溢出效果
for (let i = 0; i < 128; i++) payload[i] = 0x41;
// 触发溢出:将128字节写入64字节的缓冲区
// 在实际攻击中,溢出数据会覆盖相邻堆块的元数据(dlmalloc的chunk header)
// 从而实现堆风水布局和任意地址写
vulnerable_copy(bufB, payload, 128);
// 检查bufA是否被覆盖
const memory = new Uint8Array(wasm.instance.exports.mem.buffer);
const bufAData = memory.slice(bufA, bufA + 32);
console.log('bufA after overflow:', bufAData);
// bufA的内容已被溢出数据覆盖
}
Wasm堆溢出的关键差异:
在原生环境中,dlmalloc的chunk header包含prev_size和size字段,覆盖这些字段可以劫持free()的unlink操作实现任意写。在Wasm中,这套机制完全保留——因为emscripten直接将dlmalloc编译进Wasm模块。但由于无ASLR且布局确定,攻击者不需要heap spray来猜测地址,可以直接计算目标偏移。
此外,Wasm线性内存中没有guard page。在原生环境中,越界读写可能触发SIGSEGV;在Wasm中,只要不越过memory.size边界,所有读写都会成功。这意味着溢出可以精确地跨过多个堆块,直到触及线性内存边界。
2.2 栈缓冲区溢出
Wasm没有硬件栈,但C/C++编译为Wasm时,编译器会在线性内存中维护一个软件栈。全局栈指针(通常存储在Wasm全局变量__stack_pointer中)指向当前栈顶。函数序言(prologue)递减栈指针分配栈帧,函数结语(epilogue)恢复栈指针。
// stack_vuln.c - Wasm栈缓冲区溢出
#include <emscripten/emscripten.h>
#include <string.h>
EMSCRIPTEN_KEEPALIVE
int process_input(const char *input, int len) {
char local_buf[64]; // 栈上分配64字节
// 无边界检查:如果len > 64,溢出到栈帧上方
// 在Wasm线性内存中,"上方"可能覆盖:
// - 调用者的栈帧(保存的栈指针、返回地址)
// - 相邻的堆分配区域
memcpy(local_buf, input, len);
// 使用被污染的数据
return local_buf[0];
}
编译后的Wasm栈帧结构(简化):
;; 编译器生成的栈帧布局示意
(func $process_input (param $input i32) (param $len i32) (result i32)
(local $local_buf i32)
(local $saved_sp i32)
;; prologue: 保存栈指针,分配栈帧
(local.set $saved_sp (global.get $__stack_pointer))
(global.set $__stack_pointer
(i32.sub (global.get $__stack_pointer) (i32.const 80))) ;; 64 + 对齐
(local.set $local_buf (global.get $__stack_pointer))
;; memcpy - 无边界检查,直接使用$len
;; 如果$len > 64,写入将越过local_buf边界
;; 覆盖$saved_sp和调用者的栈帧数据
(call $memcpy (local.get $local_buf) (local.get $input) (local.get $len))
;; epilogue: 恢复栈指针
;; 如果$saved_sp被覆盖,恢复的栈指针将指向错误位置
(global.set $__stack_pointer (local.get $saved_sp))
(i32.load8_u (local.get $local_buf))
)
攻击PoC(教学用途):
// attack_stack_overflow.js - 教学PoC
async function demonstrateStackOverflow() {
const wasm = await WebAssembly.instantiateStreaming(
fetch('stack_vuln.wasm'),
{ env: { emscripten_notify_memory_growth: () => {} } }
);
const { process_input } = wasm.instance.exports;
const memory = new Uint8Array(wasm.instance.exports.mem.buffer);
// 构造超长输入触发栈溢出
const overflow = new Uint8Array(256); // 远超64字节栈缓冲区
// 在偏移64处写入目标数据
// 这将覆盖保存的栈指针和调用者栈帧
for (let i = 64; i < 256; i++) {
overflow[i] = 0x42; // 观察溢出模式
}
process_input(overflow, 256);
// 此时Wasm模块的内部状态已被破坏
// 后续的函数调用将使用被篡改的栈指针
}
关键区别: 在原生x86中,栈溢出覆盖的是硬件栈上的返回地址,攻击者可以劫持控制流。在Wasm中,"返回地址"是Wasm调用栈(call stack,不是线性内存中的数据栈)中的返回地址,存储在V8引擎管理的独立数据结构中,不在线性内存中。因此,Wasm栈溢出无法直接劫持控制流到任意地址。但它可以:
- 覆盖全局栈指针
__stack_pointer,导致后续所有函数调用使用错误的栈区域 - 覆盖存储在线性内存中的函数指针(如C++虚表)
- 跨越栈-堆边界,污染堆元数据
2.3 类型混淆
JavaScript与Wasm之间存在类型边界。Wasm有四种值类型(i32、i64、f32、f64)和引用类型,JavaScript有Number、BigInt、Object等。类型转换发生在JS-Wasm边界上,而V8引擎的类型转换逻辑本身可能存在漏洞。
// type_confusion.js - 教学PoC:JS-Wasm类型边界
async function demonstrateTypeConfusion() {
const wasmBytes = new Uint8Array([
0x00, 0x61, 0x73, 0x6d, // wasm magic
0x01, 0x00, 0x00, 0x00, // version 1
// ... 完整的Wasm二进制 ...
]);
// JS和Wasm的类型转换矩阵
// JS Number -> Wasm i32/f32/f64 (可能丢失精度)
// JS BigInt -> Wasm i64
// JS Object -> Wasm externref
// Wasm i32 -> JS Number
// Wasm i64 -> JS BigInt
// Wasm externref -> JS Object (tagged pointer)
// 类型混淆场景:如果一个Wasm函数的签名被错误地理解为
// (result i64) 而非 (result externref),
// 则返回的tagged pointer会被包装为BigInt而非对象引用
// 这是CVE-2026-6307的核心利用原理
}
i32/i64/f32/f64的位模式重解释:
;; 类型混淆的底层机制:同一寄存器位模式的不同解释
;; 返回值存储在 kReturnRegister0 (64位)
;; 如果函数签名为 (result i64):
;; deoptimizer 读取寄存器 -> static_cast<int64_t> -> 装箱为 BigInt
;; 寄存器中的 0x0000123456789ABC 被解释为整数
;; 如果函数签名为 (result externref):
;; deoptimizer 读取寄存器 -> Tagged<Object> -> 作为JS对象引用
;; 寄存器中的 0x0000123456789ABC 被解释为堆指针
;; 两者读取同一个寄存器,仅因FrameState中记录的返回类型不同
;; 就会将同一串比特位解释为完全不同的JS值
这正是CVE-2026-6307的核心漏洞机制——TurboFan编译器的全局值编号(GVN)错误地合并了两个具有不同Wasm返回类型的FrameState节点,导致deoptimizer用错误的类型解释返回寄存器中的值。
2.4 从Wasm漏洞到浏览器XSS
Wasm模块通过externref可以持有对JavaScript对象的引用。这些引用作为tagged pointer存储在线性内存中。如果攻击者能够通过堆溢出覆盖这些引用,就可能实现从Wasm内存破坏到JavaScript引擎攻击的跳板。
// wasm_to_xss.js - 攻击链概念演示(教学用途)
async function demonstrateWasmToXssChain() {
// 攻击链概述(概念层面)
//
// 1. Wasm模块通过externref持有JS对象引用
// let jsObj = { exec: () => eval(userInput) };
// wasm_instance.exports.store_reference(jsObj);
//
// 2. 攻击者通过堆溢出覆盖线性内存中的externref
// 将其替换为目标JS对象的地址(伪造对象引用)
//
// 3. Wasm模块将"被篡改的引用"返回给JS
// let leaked = wasm_instance.exports.get_reference();
// // leaked现在是一个类型混淆的对象引用
//
// 4. 利用类型混淆实现addrof/fakeobj原语
// (这是CVE-2026-6307的攻击路径)
//
// 5. 通过addrof/fakeobj进一步攻击V8引擎内部结构
// 最终逃逸沙箱实现RCE
// 注意:完整的攻击链需要精确的偏移计算和V8内部知识
// 此处仅展示概念,不提供可直接利用的代码
}
现实约束: 直接通过Wasm堆溢出实现XSS在实践中相当困难,因为:
externref的tagged pointer格式依赖V8内部实现(压缩指针、Smi编码等)- 线性内存中的指针只是偏移量,无法直接指向V8堆
- V8的类型验证会在JS-Wasm边界检查引用的有效性
但正如CVE-2026-6307所展示的,当V8引擎本身的JIT编译器存在类型混淆漏洞时,Wasm返回值可以被错误地解释为任意JS对象引用,从而绕过所有这些检查。这构成了一个更高级的攻击路径:不需要在Wasm内部精确伪造指针,而是利用引擎漏洞让deoptimizer自动完成类型混淆。
3. WALMA:机器学习驱动的内存完整性验证
3.1 WALMA的核心思路
WALMA(WebAssembly Linear Memory Attestation)[arXiv:2603.24167]由Oussama Draissi等人提出,是一种基于机器学习的Wasm线性内存完整性验证框架。其核心洞察是:内存损坏事件和外部篡改在线性内存中留下可区分的结构性痕迹(structural artifacts),这些痕迹可以通过图像分类方法检测。
WALMA将线性内存快照视为灰度图像:每个字节值(0-255)对应一个像素灰度值,内存按行对齐排列。编译后的代码在线性内存中强加的行对齐结构(row-aligned structure)形成了可学习的空间模式,而损坏会破坏这些模式。CNN能够捕获这些模式,而单纯的字节统计和纹理统计方法则会遗漏。
双向威胁模型:
WALMA的特别之处在于覆盖了双向威胁:
- 被攻破的Wasm模块攻击宿主:模块内部的内存损坏可能通过externref/共享内存影响宿主环境
- 恶意宿主篡改受信模块:在TEE(可信执行环境)部署场景中,不可信的宿主可能直接修改Wasm模块的线性内存状态
3.2 技术架构
┌──────────────────────────────────────────────────────────────┐
│ Wasm Runtime │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Linear Memory (单片线性内存) │ │
│ │ │ │
│ │ Offset 0x00000: Data Segment (全局变量、常量) │ │
│ │ Offset 0x10000: Heap Region (malloc/emmalloc) │ │
│ │ Offset 0x3FFFF: Stack Region (软件栈,向下增长) │ │
│ │ │ │
│ │ 字节值 [0x00-0xFF] 作为灰度像素 │ │
│ └──────────────────────┬─────────────────────────────────┘ │
│ │ 定期快照(Snapshot) │
│ ┌──────────────────────▼─────────────────────────────────┐ │
│ │ Snapshot Classification (快照分类) │ │
│ │ │ │
│ │ 输入: 灰度图像 (H x W) │ │
│ │ 模型: CNN (卷积神经网络) │ │
│ │ 输出: Normal / Corrupted │ │
│ │ │ │
│ │ 特征学习: │ │
│ │ - 行对齐结构模式(编译器强加的内存布局规律) │ │
│ │ - 堆元数据的空间分布(chunk header、free list) │ │
│ │ - 数据段的初始化模式 │ │
│ │ - 损坏引入的异常空间模式 │ │
│ └──────────────────────┬─────────────────────────────────┘ │
│ │ │
│ ┌──────────────────────▼─────────────────────────────────┐ │
│ │ Alert / Block / Recovery │ │
│ │ │ │
│ │ - 检测到损坏 → 告警/阻止/回滚 │ │
│ │ - TEE部署: 在可信环境中验证,不受恶意宿主影响 │ │
│ └────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
3.3 模型训练与分类
正常快照的特征:
- 数据段:编译时确定的初始值模式,包含对齐的常量池、字符串字面量、已初始化的全局变量
- 堆区域:由内存分配器管理的结构化模式。dlmalloc的chunk header(
prev_size+size+ flags)形成规律的空间周期。已分配区域包含应用程序数据,空闲区域包含free list指针 - 栈区域:函数调用产生的局部变量和保存的帧指针,形成与调用深度相关的模式
- 空闲区域:通常填充为零或未初始化值
这些区域在图像表示中形成可学习的空间结构——CNN的卷积核可以捕获局部模式(如chunk header的固定格式),而池化层和全连接层可以学习全局布局特征。
损坏快照的特征:
- 堆溢出:超出分配边界的写入破坏相邻chunk的header,产生异常的字节模式
- 栈溢出:覆盖保存的栈指针导致栈区域的数据混乱
- 外部篡改:恶意宿主直接修改内存特定区域,引入不符合编译器布局规律的异常值
- 类型混淆:错误解释的数据导致不一致的内存访问模式
分类精度与开销:
根据论文实验数据,在WABench套件的53个二进制上:
- 检测开销:1.07x - 1.69x(即运行时间增加7%到69%)
- 隐藏损坏的难度:需要在真实目标上覆盖数百KB到MB级别的内存才能逃避检测
- Shannon熵分析表明,良性行为的内存熵为异常检测提供了有效的先验边界
WALMA的关键创新——将内存视为图像:
# WALMA的核心概念(伪代码,非论文原始实现)
# 将Wasm线性内存快照转换为灰度图像供CNN分类
def memory_to_image(memory_snapshot: bytes, width: int = 256) -> np.ndarray:
"""
将线性内存快照转换为灰度图像
每个字节 [0x00, 0xFF] 对应一个像素的灰度值
"""
height = len(memory_snapshot) // width
image = np.frombuffer(memory_snapshot, dtype=np.uint8)
image = image[:height * width].reshape(height, width)
return image
# CNN分类器架构概念
# Input: (H, W, 1) 灰度图像
# Conv2D(32, 3x3) -> ReLU -> MaxPool2D
# Conv2D(64, 3x3) -> ReLU -> MaxPool2D
# Conv2D(128, 3x3) -> ReLU -> GlobalAvgPool
# Dense(128) -> ReLU -> Dense(2) [Normal, Corrupted]
# Output: binary classification
为什么CNN能检测内存损坏? 编译后的代码在线性内存中强加了高度结构化的布局:对齐的堆块、固定格式的元数据、初始化模式的规律性。这些结构在灰度图像中表现为可学习的空间模式。损坏——无论是来自溢出还是外部篡改——都会破坏这些模式,产生CNN可以捕获的异常空间特征。论文强调,简单的字节值统计和纹理统计无法达到相同的检测效果,因为它们丢失了空间位置信息。
3.4 与传统防御的对比
| 防御方案 | 侵入性 | 运行时开销 | 检测能力 | 适用威胁模型 |
|---|---|---|---|---|
| V8边界检查 | 低 | 低(硬件级) | 预防越界 | 越界读写 |
| 浏览器沙箱 | 低 | 低 | 隔离 | 限制攻击范围 |
| 二进制插桩(ASAN/MSAN) | 高 | 高(2-3x) | 精确定位 | 编译时已知漏洞模式 |
| 自定义运行时 | 高 | 中-高 | 依赖实现 | 特定场景 |
| 内存安全语言(Rust) | 高(重写) | 低 | 预防内存安全漏洞 | 新项目 |
| WALMA | 低 | 中(1.07x-1.69x) | 检测损坏+外部篡改 | 运行时完整性验证 |
WALMA的核心优势在于其非侵入性和运行时无关性(runtime-agnostic):不需要修改Wasm二进制本身,不需要自定义运行时,可以在宿主边界或TEE内部部署。它将执行与验证解耦(decouples execution from verification),使其可以覆盖传统防御无法处理的威胁模型——特别是恶意宿主对受信模块的篡改。
WALMA的局限性:
- 检测延迟:快照是周期性采集的,两次快照之间的瞬时损坏可能被遗漏
- 误报风险:正常程序的特定执行路径可能产生异常但合法的内存模式
- 隐藏成本:论文指出,攻击者需要覆盖大范围内存才能逃避检测,但这并不意味着不可能
- 模型泛化:在不同编译器和优化级别下训练的模型可能需要重新校准
4. CVE-2026-6307 Longinus:V8沙箱逃逸深度分析
4.1 漏洞概述
CVE-2026-6307(代号Longinus)由Nebula Security的Vega发现,是V8 JIT编译器(TurboFan/Turboshaft)中的一个类型混淆漏洞。该漏洞的独特之处在于:一个bug同时穿透两个安全边界——Chrome渲染进程沙箱和V8堆沙箱——实现完整的远程代码执行。
- 影响版本:Chrome 106至Chrome 146.0.7680.165
- 修复版本:Chrome 147.0.7727.101
- 漏洞存在时间:约4年(从Chrome 106引入至2026年6月修复)
- CVSS评分:Critical
- 发现者:Vega (Nebula Security)
该漏洞允许攻击者:
- 以100%成功率获得任意内存读写原语,无需任何堆喷射(heap spray)技巧
- 单独实现V8堆沙箱逃逸,无需配合其他漏洞
- 最终在渲染进程沙箱内实现RCE
4.2 技术原理
漏洞根源在于TurboFan编译器的全局值编号(Global Value Numbering, GVN)对FrameState节点的错误合并。
漏洞触发路径:
-
JS-to-Wasm调用内联:TurboFan将JavaScript到WebAssembly的调用包装器内联到优化图(Sea of Nodes)中。包装器的签名由Wasm函数的
CanonicalSig决定 -
双重内联路径:当同一JS函数通过不同的对象形状(shape)调用Wasm函数时,优化图中可以存在两条内联路径。例如,通过属性getter间接调用两个不同签名的Wasm函数
-
FrameState合并错误:两条路径各自创建
kJSToWasmBuiltinContinuation类型的FrameState节点。GVN的等值比较未正确区分具有不同JSToWasmFrameStateFunctionInfo::signature()的FrameState,将它们错误地合并 -
类型混淆触发:当lazy deoptimization发生时,deoptimizer从被合并的FrameState中读取Wasm返回类型。由于合并,可能使用了错误的返回类型(如
i64而非externref)来物化返回值
核心漏洞代码路径:
// V8 deoptimizer中物化Wasm返回值的逻辑
// 文件: src/deoptimizer/deoptimizer.cc
TranslatedValue Deoptimizer::TranslatedValueForWasmReturnKind(
std::optional<wasm::ValueKind> wasm_call_return_kind) {
if (wasm_call_return_kind) {
switch (wasm_call_return_kind.value()) {
case wasm::kI64:
// 将返回寄存器的64位值解释为 int64_t,装箱为 BigInt
return TranslatedValue::NewInt64ToBigInt(
&translated_state_,
static_cast<int64_t>(input_->GetRegister(kReturnRegister0.code())));
case wasm::kRefNull:
case wasm::kRef:
// 将返回寄存器的64位值解释为 Tagged<Object>,作为JS对象引用
return TranslatedValue::NewTagged(
&translated_state_,
Tagged<Object>(input_->GetRegister(kReturnRegister0.code())));
// ...
}
}
}
关键点:kI64和kRef/kRefNull都从同一个返回寄存器kReturnRegister0读取值。区别仅在于如何解释这64位:
kI64:int64_t->BigInt(数值解释)kRef:Tagged<Object>-> JS对象引用(指针解释)
当FrameState被错误合并后,一个返回externref的Wasm函数的返回值可能被当作i64处理(泄露完整的64位tagged pointer作为BigInt),或者反过来,一个返回i64的函数的返回值可能被当作对象引用处理(实现fakeobj原语)。
4.3 攻击链分析
完整攻击链:
1. 准备阶段
├── 创建两个Wasm模块,分别导出 (result i64) 和 (result externref) 的函数
├── 通过属性getter间接调用,让TurboFan内联两条路径
└── 触发TurboFan优化编译,生成包含错误合并FrameState的机器码
2. 触发类型混淆
├── 强制lazy deoptimization(使优化代码被标记为需要deopt)
├── Deoptimizer使用错误的FrameState
├── 返回 externref 的函数被当作 i64 处理
│ └── addrof 原语:获取任意JS对象的内部指针(作为BigInt泄露)
└── 返回 i64 的函数被当作 externref 处理
└── fakeobj 原语:将任意64位值包装为JS对象引用
3. V8堆沙箱内操作
├── addrof + fakeobj → 任意地址读写原语(在沙箱内)
├── 读取V8内部对象(Map、JSArrayBuffer等)的元数据
├── 构造任意地址读写的高层抽象(TypedArray + fake backing store)
└── 定位V8堆沙箱外的目标(JIT代码页、外部指针表)
4. 沙箱逃逸
├── 利用V8外部指针(external pointer)机制
├── 通过沙箱内的任意写修改JIT代码页的元数据
├── 将shellcode写入JIT代码区域(JIT页面是RWX的)
└── 跳转到JIT代码执行 → 渲染进程内RCE
5. 后渗透(取决于漏洞利用目标)
└── 配合Chromium其他漏洞或利用OS层面的漏洞进一步提权
addrof原语实现(概念演示):
// addrof_concept.js - 概念演示,非完整exploit
// 仅展示addrof原语的原理,省略触发deopt的完整setup
// 假设已通过Longinus漏洞实现了类型混淆
// 当返回externref的Wasm函数被错误地当作i64处理时:
// Wasm函数返回一个JS对象引用 (externref)
// Deoptimizer将其解释为 int64_t 并装箱为 BigInt
// 这个BigInt的数值就是对象的tagged pointer
function conceptAddrof(targetObj) {
// 调用被错误合并的Wasm函数路径
// Wasm函数内部: return targetObj; (externref)
// Deoptimizer看到: return kind = i64
// 结果: BigInt(targetObj的tagged pointer)
let leaked = callConfusedWasmFunction(targetObj);
// leaked 是一个 BigInt,其位模式等于 targetObj 的 V8 堆指针
return leaked;
}
function conceptFakeobj(addr) {
// 调用返回i64的Wasm函数,但deoptimizer认为是externref
// Wasm函数内部: return addr; (i64)
// Deoptimizer看到: return kind = externref
// 结果: 将addr作为Tagged<Object>返回
let fake = callConfusedWasmFunction(BigInt(addr));
// fake 被JS引擎当作一个有效的对象引用
return fake;
}
从沙箱内到沙箱外:
V8堆沙箱将所有堆指针限制在一个特定的虚拟地址范围内。Longinus的独特价值在于,它不仅能获得沙箱内的任意读写,还能单独实现沙箱逃逸。核心路径是通过修改JIT代码页的元数据。V8的JIT代码存储在RWX(可读可写可执行)的内存页中,但这些页面的地址存储在外部指针表中。通过沙箱内的任意写,攻击者可以篡改这些外部指针,将控制流重定向到攻击者控制的JIT代码区域。
值得注意的是,stratan的分析展示了另一种沙箱逃逸路径:通过实验性的WasmFX(栈切换提案)中的continuation类型混淆。WasmFX的resume指令从StackMemory结构中读取参数缓冲区地址,而该结构通过WasmStackObject的外部指针在V8堆中引用。如果攻击者能够篡改continuation的签名信息,就可以让resume使用错误的参数布局,从而实现沙箱外读写。
4.4 修复方案
Google在Chrome 147.0.7727.101中修复了该漏洞。修复的核心是确保TurboFan/Turboshaft的GVN在比较FrameState节点时,正确包含JSToWasmFrameStateFunctionInfo中的Wasm签名信息。具体而言,FrameState的等值比较必须将CanonicalSig的差异纳入考量,防止具有不同Wasm返回类型的去优化状态被错误合并。
// 修复要点(概念)
// FrameState等值比较必须包含Wasm签名
bool JSToWasmFrameStateFunctionInfo::Equals(
const FrameStateFunctionInfo& other) const {
// 修复前:可能只比较了bailout_id等通用字段
// 修复后:必须比较signature()
auto& o = static_cast<const JSToWasmFrameStateFunctionInfo&>(other);
return signature() == o.signature() // 关键:比较Wasm签名
&& FrameStateFunctionInfo::Equals(other);
}
5. 防御体系:从编译到运行时
5.1 编译期防御
Rust + Wasm:所有权系统保证内存安全
Rust编译为Wasm时,其所有权系统和借用检查器在编译期消除了大部分内存安全漏洞:
// safe_wasm.rs - Rust编译为Wasm的安全实践
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub struct SafeBuffer {
data: Vec<u8>,
}
#[wasm_bindgen]
impl SafeBuffer {
#[wasm_bindgen(constructor)]
pub fn new(size: usize) -> Self {
// Rust的Vec在堆上分配,边界检查由编译器保证
SafeBuffer {
data: vec![0u8; size],
}
}
pub fn copy_from(&mut self, src: &[u8]) -> Result<(), String> {
// Rust的切片边界检查:如果src.len() > self.data.len()
// 运行时panic,不会发生内存损坏
if src.len() > self.data.len() {
return Err("Source buffer too large".to_string());
}
self.data[..src.len()].copy_from_slice(src);
Ok(())
}
pub fn get(&self, index: usize) -> Option<u8> {
// 边界检查由Rust编译器自动插入
self.data.get(index).copied()
}
}
Rust编译为Wasm后,所有的边界检查都被编译为Wasm的if指令和trap指令。虽然这引入了少量运行时开销,但从根本上消除了缓冲区溢出的可能性。
编译器安全选项:
# emscripten编译安全选项对比
# -O0: 无优化,保留所有边界检查,但代码体积大、速度慢
emcc vulnerable.c -O0 -s WASM=1 -s SAFE_HEAP=1
# SAFE_HEAP=1: 额外插入运行时堆检查(性能影响显著)
# -O2: 激进优化,可能移除部分冗余边界检查
# 但Wasm的memory访问边界检查由引擎保证,不会被移除
emcc vulnerable.c -O2 -s WASM=1
# -O2 + 边界检查硬化
emcc vulnerable.c -O2 -s WASM=1 -s BOUNDS_CHECK=1
# 使用address sanitizer(编译到Wasm时开销极高)
emcc vulnerable.c -O0 -s WASM=1 -fsanitize=address
Wasm GC提案:
Wasm GC(Garbage Collection)提案引入了结构体类型和数组类型,由引擎管理其内存布局和生命周期。这从根本上消除了手动内存管理带来的UAF和double-free漏洞:
;; Wasm GC提案:引擎管理的结构体
(module
(type $point (struct
(field $x f64)
(field $y f64)
))
(type $rect (struct
(field $origin (ref $point))
(field $width f64)
(field $height f64)
))
;; struct.new 在GC管理的堆上分配
;; 不需要手动free,GC自动回收
;; 字段访问由引擎保证类型安全和边界安全
(func (export "make_rect") (result (ref $rect))
(struct.new $rect
(struct.new $point (f64.const 0.0) (f64.const 0.0))
(f64.const 100.0)
(f64.const 200.0)
)
)
)
5.2 运行时防御
V8边界检查的实现机制:
V8在执行Wasm内存访问时,对每条load/store指令进行边界验证。在Liftoff(基线编译器)中,这体现为显式的比较和条件trap指令。在TurboFan(优化编译器)中,编译器尝试通过范围分析(range analysis)消除冗余检查,但保证安全性——未证明安全的访问始终保留检查。
;; V8生成的边界检查(概念层面)
;; 原始: i32.load offset=0 (i32.add (local.get $ptr) (i32.const 4))
;; Liftoff基线编译器生成(伪汇编):
;; cmp ptr+4, memory_size * 64KB
;; if >=: trap (out of bounds)
;; load [memory + ptr + 4]
;; TurboFan优化编译器可能消除检查(如果ptr的范围已知安全):
;; range analysis: ptr in [0, 100), access_size=4, memory_size=1 page
;; → 100+4 < 65536, check eliminated
;; load [memory + ptr + 4]
浏览器沙箱的安全边界:
Chrome的多层沙箱架构:
- Site Isolation:不同站点在不同的渲染进程中运行
- Renderer Sandbox:渲染进程运行在受限的环境中(seccomp-bpf过滤、namespace隔离、挂载命名空间)
- V8 Heap Sandbox:即使攻击者在渲染进程内获得任意读写,V8堆沙箱将可访问的内存限制在V8堆的特定区域
Longinus证明了第3层可以被穿透。第2层的逃逸通常需要额外的漏洞(如mojo IPC漏洞或系统调用漏洞)。
内存访问监控:
// 运行时内存监控示例(教学演示)
class WasmMemoryMonitor {
constructor(memory) {
this.memory = memory;
this.originalGrow = memory.grow.bind(memory);
this.growHistory = [];
this.accessLog = [];
// 包装memory.grow以检测异常模式
memory.grow = (delta) => {
const timestamp = performance.now();
this.growHistory.push({ delta, timestamp });
// 检测异常频繁的内存增长
const recent = this.growHistory.filter(
e => timestamp - e.timestamp < 1000
);
if (recent.length > 10) {
console.warn('[SECURITY] Suspicious memory growth pattern detected');
console.warn(` ${recent.length} grow calls in last 1s`);
console.warn(` Total delta: ${recent.reduce((s, e) => s + e.delta, 0)} pages`);
}
// 检测单次异常大的增长
if (delta > 100) {
console.warn(`[SECURITY] Large memory growth: ${delta} pages (${delta * 64}KB)`);
}
return this.originalGrow(delta);
};
}
// 定期内存完整性快照(WALMA思路的简化版)
takeSnapshot() {
const buffer = new Uint8Array(this.memory.buffer);
const snapshot = {
timestamp: performance.now(),
size: buffer.length,
// 采样关键区域的统计特征
entropy: this._calculateEntropy(buffer),
zeroRatio: this._calculateZeroRatio(buffer),
checksum: this._checksum(buffer),
};
this.accessLog.push(snapshot);
return snapshot;
}
_calculateEntropy(data) {
const freq = new Array(256).fill(0);
for (let i = 0; i < data.length; i++) freq[data[i]]++;
let entropy = 0;
for (let i = 0; i < 256; i++) {
if (freq[i] > 0) {
const p = freq[i] / data.length;
entropy -= p * Math.log2(p);
}
}
return entropy;
}
_calculateZeroRatio(data) {
let zeros = 0;
for (let i = 0; i < data.length; i++) {
if (data[i] === 0) zeros++;
}
return zeros / data.length;
}
_checksum(data) {
let sum = 0;
for (let i = 0; i < Math.min(data.length, 4096); i++) {
sum = (sum + data[i]) & 0xFFFFFFFF;
}
return sum.toString(16);
}
}
// 使用示例
const memory = new WebAssembly.Memory({ initial: 256, maximum: 1024 });
const monitor = new WasmMemoryMonitor(memory);
// 定期快照,检测内存状态变化
setInterval(() => {
const snap = monitor.takeSnapshot();
// 将快照数据发送到分析服务
// WALMA使用CNN对这些快照进行分类
}, 5000);
5.3 最小权限原则
最小化Wasm导出函数:
;; 不安全:导出所有内部函数
(module
(func $internal_sort (param i32 i32) ...) ;; 内部排序
(func $internal_hash (param i32) (result i32) ...) ;; 内部哈希
(func $do_work (param i32 i32) ...)
(export "sort" (func $internal_sort)) ;; 暴露内部实现
(export "hash" (func $internal_hash)) ;; 暴露内部实现
(export "do_work" (func $do_work))
)
;; 安全:仅导出必要的入口函数
(module
(func $internal_sort (param i32 i32) ...) ;; 内部,不导出
(func $internal_hash (param i32) (result i32) ...) ;; 内部,不导出
(func $process (param $input i32) (param $len i32) (result i32)
;; 在内部调用sort和hash,但不暴露给JS
(call $internal_sort (local.get $input) (local.get $len))
(call $internal_hash (local.get $input))
)
(export "process" (func $process)) ;; 唯一入口
)
输入参数校验:
// safe_api.c - Wasm导出函数的输入校验
#include <emscripten/emscripten.h>
#include <string.h>
#include <stdlib.h>
#include <stdint.h>
#define MAX_INPUT_SIZE 4096
#define MIN_INPUT_SIZE 1
EMSCRIPTEN_KEEPALIVE
int safe_process(const uint8_t *input, uint32_t len) {
// 1. 长度校验
if (len < MIN_INPUT_SIZE || len > MAX_INPUT_SIZE) {
return -1; // 拒绝异常输入
}
// 2. 内容校验(根据业务逻辑)
for (uint32_t i = 0; i < len; i++) {
if (input[i] == 0x00) { // 示例:不允许空字节
return -2;
}
}
// 3. 使用安全的内存操作
uint8_t *buf = (uint8_t *)malloc(len);
if (!buf) return -3;
// memcpy的长度已经过校验,不会溢出
memcpy(buf, input, len);
// ... 处理逻辑 ...
free(buf);
return 0;
}
不信任Wasm模块内的任何数据:
// host_defense.js - 宿主侧的防御性编程
async function loadWasmSafely() {
const { instance } = await WebAssembly.instantiateStreaming(
fetch('untrusted-module.wasm'),
{ /* imports */ }
);
// 1. 不信任Wasm返回的任何指针/偏移
const resultOffset = instance.exports.process();
const memory = new Uint8Array(instance.exports.mem.buffer);
// 2. 验证返回的偏移在合法范围内
if (typeof resultOffset !== 'number' ||
resultOffset < 0 ||
resultOffset >= memory.length) {
throw new Error('Wasm returned invalid offset');
}
// 3. 读取返回数据时做长度限制
const MAX_READ = 1024;
const safeData = memory.slice(resultOffset,
Math.min(resultOffset + MAX_READ, memory.length));
// 4. 不将Wasm返回的数据直接传递给eval/innerHTML等危险API
// 使用TextDecoder安全解码
const decoder = new TextDecoder('utf-8', { fatal: true });
const text = decoder.decode(safeData);
// 使用textContent而非innerHTML
document.getElementById('output').textContent = text;
}
6. Wasm安全的未来方向
WASM GC提案对内存安全的改善:
GC提案将内存管理责任从应用程序代码转移到引擎,从根本上消除了手动内存管理相关的漏洞(UAF、double-free、堆溢出)。结构体和数组的字段访问由引擎保证类型安全和边界安全。当GC提案在主流浏览器中全面落地后,C/C++编译到Wasm的内存安全劣势将被显著削弱,因为开发者可以选择使用Rust或直接使用Wasm GC来编写安全关键的模块。
WASM Exception Handling:
异常处理提案为Wasm引入了结构化的异常机制,替代了当前的基于setjmp/longjmp的C++异常模拟。这不仅改善了异常处理的性能,还减少了异常处理路径中的内存安全隐患——当前的模拟实现需要在线性内存中保存和恢复执行状态,而原生异常处理由引擎管理这些状态。
硬件级隔离(CHERI+Wasm):
CHERI(Capability Hardware Enhanced RISC Instructions)提供硬件级的内存安全能力。将CHERI的能力模型(capability)应用于Wasm的内存访问,可以在硬件层面强制每个内存访问的权限和边界。这意味着即使Wasm模块存在逻辑漏洞,硬件也会阻止越界访问。目前这是研究阶段的方向,但代表了内存安全的终极形态——从软件检查到硬件强制的范式转变。
WALMA类ML检测方法的演进:
WALMA证明了将内存快照视为图像并用CNN分类是可行的。未来的演进方向包括:
- 实时检测:降低快照采集和分类的延迟,从周期性检测转向接近实时的连续监控
- 自适应模型:根据目标程序的正常行为动态调整检测阈值,降低误报率
- 多模态融合:结合内存访问模式(trace-based)和内存状态(snapshot-based)进行联合检测
- 轻量化部署:模型压缩和边缘推理,使ML检测可以在浏览器中直接运行
- 形式化保证:为ML分类器提供可证明的检测下界——论文的Shannon熵分析是这一方向的起点
Wasm的安全生态正在从"依赖引擎边界检查"向"纵深防御"演进。编译期(Rust所有权、GC提案)、运行时(WALMA、沙箱硬化)、硬件级(CHERI)三层防御的协同,将使Wasm从"比JavaScript安全"进化到"接近内存安全的理论极限"。
参考资料:
- WALMA: Learning to See Memory Corruption in WebAssembly (arXiv:2603.24167), Draissi et al., 2026
- Longinus: CVE-2026-6307 Writeup, Nebula Security (Vega), 2026
- CVE-2026-6307 Part 3: Escaping the V8 Sandbox Through WasmFX, stratan, 2026
- Everything Old is New Again: Binary Security of WebAssembly, Lehmann et al., USENIX Security 2020
- V8 Heap Sandbox, V8 Project
- WasmFX / Stack Switching Proposal
- WebAssembly Security, WebAssembly Working Group
浙公网安备 33010602011771号