Jul 15 2026 Off 摘要 WebAssembly 通过线性内存和严格的类型系统为宿主环境提供安全隔离,但当 Wasm 模块被编译为原生代码(如通过 wasm2c)嵌入宿主时,安全边界完全依赖于自动生成的“沙箱胶水代码”的正确性。这些胶水代码负责将 Wasm 的虚拟指令映射为 C 语言操作,强制进行边界检查和类型转换。然而,胶水代码中用于模拟 Wasm 内存和函数调用的 memcpy 与 wasm_rt_typecheck 存在一类隐蔽的 Type Confusion 漏洞:在零长度拷贝、类型嵌套及 OOB 指针算术等边界条件下,胶水代码可能错误地信任 Wasm 模块内部提供的类型标签,导致将攻击者控制的整数视为指针或函数地址,从而打破宿主进程的内存安全约束。 Wasm 线性内存与 wasm2c 沙箱模型 隔离WebAssembly 的安全模型核心是线性内存——一个单一的、字节寻址的、从零开始的地址空间,与宿主进程的堆栈和堆严格隔离。Wasm 模块不能直接访问宿主内存,所有内存操作(i32.load、i64.store 等)都必须经过编译后的边界检查:i32.load 必须验证 address + offset + 4 不超过当前内存大小,否则抛出陷阱。这种机制在设计上杜绝了越界读写。wasm2cwasm2c 是 WABT 工具集中的一个工具,可将 WebAssembly 二进制模块(.wasm)转换为等效的 C 源文件(.c 和 .h)。生成的 C 代码不依赖任何外部运行时,可被直接编译并与宿主程序链接。其核心包括:操作数栈模拟:Wasm 值栈被转换为 C 局部变量或结构体字段。函数调度:通过函数指针表(wasm_rt_func_table)实现间接调用,并通过签名索引进行类型检查。内存访问:所有 load/store 由内联的边界检查守卫,访问超出 mem_size 则调用 wasm_rt_trap。类型判断:胶水代码中大量使用 wasm_rt_typecheck 宏,比较 Wasm 模块提供的函数签名是否与调用点期望的类型匹配。整个转换与运行的流程如下: 沙箱的安全性此时完全迁移到了这些胶水代码的正确性上:C 编译器不再理解 Wasm 的类型安全,任何对边界检查或类型比较逻辑的偏离都可能将 Wasm 的越界或混淆转化为宿主的内存破坏。 类型混淆 零长度拷贝与未定义行为Wasm 规范允许 memory.copy 指令使用零长度参数(n=0)。wasm2c 将 memory.copy 转换为 memcpy 调用。根据 C 标准,如果源或目标指针为 NULL,即使长度为零,memcpy 也会引发未定义行为——而某些编译器(如 GCC 在优化模式下)可能利用这一未定义行为移除后续的安全检查。在 wasm2c 生成的胶水代码中,边界检查通常在 memcpy 调用之前完成,但若检查失败,生成的陷阱代码可能因为编译器优化而被跳过。更微妙的是:如果 Wasm 模块传递一个故意越界的偏移,胶水代码可能先计算 源地址 = base + offset,得到一个 NULL 指针(由于整数环绕),然后传入零长度的 memcpy——这便触发了未定义行为。攻击者可以借此绕过边界检查,实现越界读写。wasm_rt_typecheck标签混淆间接函数调用(call_indirect)依赖于类型签名索引。wasm2c 在函数表条目中存储一个 func_type 字段,在调用时执行: if (func->func_type != expected_type_index) { wasm_rt_trap(WASM_RT_TRAP_CALL_INDIRECT); } 这个比较看似简单,但它是基于整数索引的。如果 Wasm 模块的逻辑允许在运行时动态修改函数表条目(例如通过 table.set),或者由于某种越界写将 func_type 字段覆盖为期望的值,那么任何函数都可以通过间接调用被执行,函数的签名完全不匹配。宿主程序期望函数返回 void 且无参数,但被调用的函数可能实际返回一个指针,并将该指针传递给宿主的其他代码——这就是类型混淆。内存地址伪造与指针注入Wasm 的线性内存与宿主内存隔离,本应无法伪造宿主指针。但 wasm2c 生成的代码为了性能,常常将线性内存地址作为 C 指针直接传递给宿主提供的辅助函数(例如通过 WASM_IMPORT 导入的函数)。如果胶水代码未验证辅助函数返回值的类型,攻击者可编写一个导入函数,它实际上返回一个整数,而宿主却将其视为指针用于内存操作,进而实现从 Wasm 内存到宿主内存的指针注入。 沙箱逃逸攻击链 综合上述缺陷,一条完整的逃逸链可被构造: 利用边界检查绕过:在 Wasm 模块中利用 memory.copy 的零长度未定义行为或整数环绕,实现线性内存外的越界写,覆盖函数表条目或胶水代码的全局变量(例如 mem_size 或 func_table 数据)。伪造函数表类型签名:将函数表条目的 func_type 覆写为宿主期望的签名索引,使得后续的 call_indirect 通过类型检查,但实际调用的函数具有完全不同的签名(比如返回宿主指针)。指针注入与宿主内存读写:通过签名混淆调用一个“返回指针”的函数,该指针实际被攻击者控制(因为在 Wasm 内存中构造)。宿主将此指针用于后续的 I/O 操作,导致任意宿主内存读写。代码执行:利用任意写修改宿主函数指针或返回地址,劫持控制流,执行 shellcode。 利用 wasm2c 实现宿主内存读取 以下演示一个概念性的恶意 Wasm 模块(用 C 编写,再由 wasm2c 转换为 C,并嵌入宿主)。我们故意构造一个签名混淆的场景。恶意 Wasm 模块源码 (module (type (func (result i32))) ;; type 0:返回 i32 (type (func (param i32) (result i32))) ;; type 1:带参数 (import "env" "host_func" (func $host_func (param i32) (result i32))) (table 2 funcref) (elem (i32.const 0) $confused_func) (elem (i32.const 1) $legitimate_func) (func $confused_func (result i32) ;; 签名是 type 0 (i32.const 0x12345678) ;; 返回一个伪造的宿主指针 ) (func $legitimate_func (param i32) (result i32) ;; 签名是 type 1 local.get 0 i32.const 1 i32.add ) (func (export "trigger") (result i32) ;; 通过某种手段覆写 table[1] 的类型标签,使其变成 type 0 ;; 这里简化:假设已通过越界写将 table[1] 的 func_type 改成 0 ;; 然后间接调用 table[1](实际为 $confused_func),但期望签名是 type 1 (call_indirect (type 1) (i32.const 0) (i32.const 1)) ) ) 在真实的 wasm2c 场景中,call_indirect 会进行类型检查。如果我们事先利用越界写把 func_table[1].func_type 从 1 改为 0,那么类型检查认为签名是 type 0(无参数返回 i32),但实际上函数 $confused_func 是 type 0,匹配!但是调用点期望的是 type 1(需要一个参数),由于检查被绕过,攻击者就使得调用点使用了一个本来不匹配的函数。胶水代码可能依然将参数压入栈,但 $confused_func 会忽略参数并返回攻击者控制的整数。此整数可能被宿主用作指针,导致信息泄露。宿主程序(C)漏洞模拟 // host.c — 演示 wasm2c 胶水代码类型混淆导致的指针泄露 #include"sandbox.h"// wasm2c 生成的头文件 // 宿主导入函数,期待一个指向线性内存的合法偏移,返回其内容 int32_tenv_host_func(int32_t ptr_offset){ // 此处本应验证 ptr_offset 是否在线性内存内,但假设它信任了调用者 if (ptr_offset >= 0 && ptr_offset < wasm_rt_get_memory_size()) { return ((int32_t*)wasm_rt_get_memory())[ptr_offset / 4]; } else { // 正常情况下会陷阱,但类型混淆可能让 ptr_offset 为任意值 return ((int32_t*)wasm_rt_get_memory())[ptr_offset / 4]; // 越界读 } } intmain(){ wasm_rt_init(); // 加载 wasm 模块... // 触发 attack int leaked = sandbox_trigger(); // 调用了间接函数,通过类型混淆让 host_func 越界读 printf("Leaked: 0x%x\n", leaked); return0; } 越界写覆写 func_type 的辅助代码通过 memory.copy 零长度环绕可伪造一个巨大的 n 值,实现越界写: // 在 Wasm 模块内用 C 表达 voidoverwrite_func_table(){ // 假设内存地址直接映射 func_table,我们通过调试确定偏移 uint32_t* func_table_ptr = (uint32_t*)(0x1000); // 示例地址 func_table_ptr[1] = 0; // 将 table[1].func_type 改为 0 } 说明:以上代码仅为示意,真实攻击需精确计算 wasm2c 生成的数据结构偏移,并利用零长度拷贝等缺陷实现越界写。 检测与防御 检测方法静态审核胶水代码:重点关注 memcpy、memmove 的调用,确保源地址和目标地址已经过严格的非 NULL 和边界检查,且长度为零时不应调用(改用空操作)。启用编译器消毒器:使用 -fsanitize=undefined,address 编译包含 wasm2c 输出的宿主程序,捕获未定义行为和越界。函数表完整性检查:在每次间接调用后,重新校验函数表条目的签名索引是否与调用前一致(类似 CFI)。Wasm 模块行为分析:检测模块中是否存在对 memory.copy 的非标准参数(如零长度但指针异常),以及大量使用 table.set 的异常。防御加固强化 wasm2c 代码生成模板:在生成的边界检查中,对零长度拷贝跳过 memcpy,避免触发未定义行为。类型签名哈希替代整数索引:使用密码学哈希(如 truncated SHA-256)替换简单的整数索引进行类型匹配,使伪造类型签名的难度升高。宿主导入函数参数白名单:自动生成的胶水代码应自动为每个导入函数的参数和返回值添加边界检查和类型消毒,防止指针滥用。编译后模糊测试:对 wasm2c 生成的 C 代码进行差分模糊测试,对比不同编译器优化级别下的行为,发现由于未定义行为导致的差异。 结语 wasm2c 将 WebAssembly 的安全岛屿移入了 C 的大海,生成的胶水代码成为唯一的海堤。但 C 语言的未定义行为、整数溢出与孱弱的类型系统,在海堤上留下了细微的裂缝——攻击者只需精准的越界写与类型混淆,便可破堤而入,让宿主进程沦陷。WebAssembly 沙箱逃逸的教训在于:安全边界的翻译必须保留源语言的所有不变式,任何遗漏都将成为攻击者的跳板。在 Wasm 纳入更多宿主环境的今天,对胶水代码的自动化安全审计与形式化验证,已不是可选,而是必需。 Post navigation Previous PostPrevious WebAuthn 信任链坍缩Next PostNext eBPF 验证器盲点