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的可靠性:

攻击面确定性分析:

  1. 数据段布局:编译时确定,链接后固定。全局变量、函数指针表、字符串常量在内存中的位置在所有运行实例间完全一致
  2. 堆布局:emscripten默认使用dlmalloc,首次malloc返回的地址在固定偏移处(通常紧跟数据段之后),且堆分配顺序和大小由程序逻辑决定,在相同输入下完全可重现
  3. 栈布局: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_sizesize字段,覆盖这些字段可以劫持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栈溢出无法直接劫持控制流到任意地址。但它可以:

  1. 覆盖全局栈指针__stack_pointer,导致后续所有函数调用使用错误的栈区域
  2. 覆盖存储在线性内存中的函数指针(如C++虚表)
  3. 跨越栈-堆边界,污染堆元数据

2.3 类型混淆

JavaScript与Wasm之间存在类型边界。Wasm有四种值类型(i32i64f32f64)和引用类型,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在实践中相当困难,因为:

  1. externref的tagged pointer格式依赖V8内部实现(压缩指针、Smi编码等)
  2. 线性内存中的指针只是偏移量,无法直接指向V8堆
  3. 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的特别之处在于覆盖了双向威胁:

  1. 被攻破的Wasm模块攻击宿主:模块内部的内存损坏可能通过externref/共享内存影响宿主环境
  2. 恶意宿主篡改受信模块:在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)

该漏洞允许攻击者:

  1. 以100%成功率获得任意内存读写原语,无需任何堆喷射(heap spray)技巧
  2. 单独实现V8堆沙箱逃逸,无需配合其他漏洞
  3. 最终在渲染进程沙箱内实现RCE

4.2 技术原理

漏洞根源在于TurboFan编译器的全局值编号(Global Value Numbering, GVN)对FrameState节点的错误合并。

漏洞触发路径:

  1. JS-to-Wasm调用内联:TurboFan将JavaScript到WebAssembly的调用包装器内联到优化图(Sea of Nodes)中。包装器的签名由Wasm函数的CanonicalSig决定

  2. 双重内联路径:当同一JS函数通过不同的对象形状(shape)调用Wasm函数时,优化图中可以存在两条内联路径。例如,通过属性getter间接调用两个不同签名的Wasm函数

  3. FrameState合并错误:两条路径各自创建kJSToWasmBuiltinContinuation类型的FrameState节点。GVN的等值比较未正确区分具有不同JSToWasmFrameStateFunctionInfo::signature()的FrameState,将它们错误地合并

  4. 类型混淆触发:当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())));
      // ...
    }
  }
}

关键点kI64kRef/kRefNull都从同一个返回寄存器kReturnRegister0读取值。区别仅在于如何解释这64位:

  • kI64int64_t -> BigInt(数值解释)
  • kRefTagged<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的多层沙箱架构:

  1. Site Isolation:不同站点在不同的渲染进程中运行
  2. Renderer Sandbox:渲染进程运行在受限的环境中(seccomp-bpf过滤、namespace隔离、挂载命名空间)
  3. 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分类是可行的。未来的演进方向包括:

  1. 实时检测:降低快照采集和分类的延迟,从周期性检测转向接近实时的连续监控
  2. 自适应模型:根据目标程序的正常行为动态调整检测阈值,降低误报率
  3. 多模态融合:结合内存访问模式(trace-based)和内存状态(snapshot-based)进行联合检测
  4. 轻量化部署:模型压缩和边缘推理,使ML检测可以在浏览器中直接运行
  5. 形式化保证:为ML分类器提供可证明的检测下界——论文的Shannon熵分析是这一方向的起点

Wasm的安全生态正在从"依赖引擎边界检查"向"纵深防御"演进。编译期(Rust所有权、GC提案)、运行时(WALMA、沙箱硬化)、硬件级(CHERI)三层防御的协同,将使Wasm从"比JavaScript安全"进化到"接近内存安全的理论极限"。


参考资料: