C 库内部的一次空指针解引用就可能导致整个应用程序崩溃。如果该库负责解析用户输入、解压图像或处理网络协议,那么一个畸形的数据包就足以引发崩溃。容器确实能解决这个问题,但仅为一个依赖项就启动 Docker,无异于为盆栽雇佣保镖。你需要的是没有额外开销的隔离。

WebAssembly 加上 WASI 是最实用的答案。将库编译为 WASM,在沙箱化运行时中执行,即使来宾发生段错误,宿主进程也能安然无恙。内存访问会经过边界检查。文件系统访问基于权能机制。而且这个沙箱是一次库调用,而非系统调用。

为 C 库做沙箱隔离究竟意味着什么

C 语言没有内存安全性。依赖项中的缓冲区溢出可能破坏你的栈、劫持执行流程或导致宿主崩溃。ASAN 等传统缓解措施能在 runtime 时发现缺陷,但会带来开销,且无法将故障与进程其余部分隔离。seccomp 和 Landlock 可以限制系统调用,但它们仅适用于 Linux,某些操作需要 root 权限,且无法阻止允许范围内的系统调用内部的内存破坏。

WebAssembly 采取了不同的方法。它编译为具有显式内存边界、结构化控制流、且越界访问不存在未定义行为的虚拟指令集。Wasmtime 或 WAMR 等 WASM 运行时在执行前会验证字节码。如果来宾触碰了不属于自己的内存,运行时会触发陷阱。宿主继续运行。

这不是模拟。现代 WASM 运行时通过 Cranelift 或 LLVM 编译为本地机器码。对于计算密集型代码,性能损失通常在 1.1 倍到 2 倍之间。对于 I/O 密集型库,差距往往淹没在噪声中。

WASI 如何将文件系统访问转化为权能

WASI 即 WebAssembly 系统接口。它为沙箱化模块提供类 POSIX 的 API,但每个资源都是一项权能。不存在全局文件系统。如果你想让来宾读取 ./data/input.txt,必须显式预打开该目录并传入文件描述符。

以下是一个读取文件并返回校验和的最小化 C 库:

// libchecksum.c
#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>

uint32_t checksum_file(const char *path) {
    FILE *f = fopen(path, "rb");
    if (!f) return 0;

    uint32_t sum = 0;
    int c;
    while ((c = fgetc(f)) != EOF) {
        sum += (uint32_t)c;
    }
    fclose(f);
    return sum;
}

使用 WASI SDK 将其编译为 WebAssembly:

# Install wasi-sdk from https://github.com/WebAssembly/wasi-sdk
/opt/wasi-sdk/bin/clang \
  --target=wasm32-wasi \
  -Wl,--export=checksum_file \
  -o libchecksum.wasm libchecksum.c

生成的 .wasm 文件是一个沙箱化模块。除非运行时显式授予访问权限,否则它无法打开任何文件。它无法在线性内存区域之外分配内存。由于 WASI 没有定义 execve,它也无法执行任意 shell 命令。

从 Rust 运行沙箱化库

Wasmtime 是一个面向生产环境的 WASM 运行时,使用 Rust 编写,并带有 C 和 Python 绑定。以下是一个宿主程序,它加载编译后的 C 库,授予其对 ./data/ 的只读访问权限,并调用 checksum_file

use wasmtime::{Engine, Linker, Module, Store};
use wasmtime_wasi::{WasiCtx, WasiCtxBuilder, WasiView};

fn main() -> anyhow::Result<()> {
    let engine = Engine::default();
    let module = Module::from_file(&engine, "libchecksum.wasm")?;

    // Build a WASI context that preopens ./data as /data inside the guest
    let mut builder = WasiCtxBuilder::new();
    builder.preopen_dir("./data", "/data", wasmtime_wasi::DirPerms::READ_ONLY, wasmtime_wasi::FilePerms::READ_ONLY)?;
    let wasi = builder.build();

    let mut linker = Linker::new(&engine);
    wasmtime_wasi::add_to_linker_sync(&mut linker)?;

    let mut store = Store::new(&engine, wasi);
    let instance = linker.instantiate(&mut store, &module)?;

    // Call the exported function with a guest-allocated string
    let checksum_fn = instance.get_typed_func::<(i32, i32), i32>(&mut store, "checksum_file")?;

    // Write the path string into guest memory
    let memory = instance.get_memory(&mut store, "memory").unwrap();
    let path = b"/data/input.txt\0";
    let ptr = 0;
    memory.write(&mut store, ptr, path)?;

    let result = checksum_fn.call(&mut store, (ptr as i32, path.len() as i32))?;
    println!("Checksum: {}", result);

    Ok(())
}

来宾 C 代码在其独立的 32 位线性内存中运行。memory.write 调用将路径字符串复制到该沙箱化地址空间。如果 checksum_file 试图读取超出自身内存末尾的位置,Wasmtime 会触发陷阱。如果它试图打开 /etc/passwd,WASI 会返回 ENOENT,因为该路径从未被预打开。

内存模型真正保护你的地方

WASM 模块使用单一的连续线性内存,通常从 64KB 开始,按需增长。每次内存访问都会由运行时进行边界检查。C 库中的缓冲区溢出无法破坏宿主的栈或堆。无法跳转到任意代码。只能触碰自己已分配区域内的内存。

这弱于完整的进程隔离。来宾与宿主共享同一个操作系统进程。CPU 级别的侧信道攻击或推测执行漏洞理论上可能跨越边界泄漏数据。但对于”这个 C 库有缺陷并可能崩溃”的威胁模型而言,WASM 隔离是真实且实用的。

故障时陷阱的行为是关键卖点。如果来宾解引用空指针或发生缓冲区溢出,Wasmtime 会捕获错误并返回异常。宿主进程不会发生段错误。你的 Web 服务器不会重启。记录错误并继续即可。

会拖慢你的权衡

WASM 并非透明。使用线程、信号、setjmp/longjmp 或原始 mmap 的 C 代码不经改写无法编译到 WASI。WASI SDK 通过可选标志支持 pthreads,但仍是实验性的,且需要宿主运行时启用。

FFI 边界是手动的。你不能简单地把 .wasm 文件链接到 C 或 Rust 项目中,像普通库一样调用其函数。你需要将参数整理到线性内存中,按索引调用函数,再读取结果。wit-bindgen 等工具可以从接口定义生成粘合代码,但你仍需承担复杂性代价。

性能表现各异。整数密集型代码编译为 WASM 后速度接近原生。频繁进行宿主调用、大量分配或依赖 SIMD 的代码则会出现更明显的减速。Wasmtime 支持 SIMD 提案,但并非所有目标平台都统一支持。

最后,WASI 仍在演进。WASI Preview 1 稳定且广泛支持。Preview 2 引入了更简洁的组件模型接口,但尚未普及。如果今天就要交付,请继续使用 Preview 1。

值得了解的替代方案

如果 WebAssembly 感觉过于复杂,还有更轻量的选择,只是权衡不同。

seccomp-bpf 允许你为 Linux 进程的系统调用设置白名单。速度快且由内核强制执行。但仅适用于 Linux,无法阻止允许的系统调用内部的内存破坏,而且编写正确的 seccomp 过滤器 notoriously 容易出错。

Landlock 是 Linux 较新的功能,可在无需特权的情况下限制文件系统访问。它比 seccomp 更简单,能很好地处理”这个库能否读取我的 SSH 密钥”的问题。但仍然是 Linux 专用,且不对内存做沙箱隔离。

独立进程 通过 fork 或工作池提供真正的操作系统隔离。子进程中的段错误只会杀死子进程。缺点是序列化开销和进程管理复杂度。对于高吞吐场景,每次调用一个进程是不可扩展的。

WebAssembly 处于中间地带。比 seccomp 更强的隔离,比 Landlock 更可移植,比独立进程更轻量。正确选择取决于你的瓶颈是系统调用延迟、内存安全还是部署复杂度。

实用的起点

你不必重写整个应用程序。挑选一个处理不可信输入的 C 依赖项,将其编译为 WASM,并用一个轻量的宿主垫片封装起来。

# 1. Install wasi-sdk (or use the prebuilt release)
curl -LO https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-24/wasi-sdk-24.0-x86_64-linux.tar.gz
tar xzf wasi-sdk-24.0-x86_64-linux.tar.gz

# 2. Compile your C library
./wasi-sdk-24.0/bin/clang --target=wasm32-wasi \
  -O2 -Wl,--export-all -o parser.wasm parser.c

# 3. Run it with wasmtime
wasmtime run --dir=./input::/input parser.wasm /input/untrusted.dat

--dir=./input::/input 语法将宿主的 ./input 目录挂载到来宾内部的 /input。来宾看不到你文件系统上的其他任何内容。如果 parser.c 包含缓冲区溢出,你会收到一条陷阱消息,而非核心转储。

对于宿主集成,请使用 Rust、C 或 Python 中的 Wasmtime 嵌入 API。从一个导出函数开始。当手写整理不再够用的时候,再引入 wit-bindgen。在将 WASM 用于热路径之前,先分析其开销。

FAQ

我能用同样的方式给 C++ 库做沙箱隔离吗?

可以。WASI SDK 包含 clang++,支持大部分 C++17 特性。异常和 RTTI 可用,但线程局部存储有一些限制。使用相同的标志和 --target=wasm32-wasi 编译即可。

需要网络的库怎么办?

WASI Preview 1 不包含套接字。某些运行时提供非标准扩展,或者你可以通过导入函数经由宿主代理网络请求。Preview 2 增加了套接字支持,但各运行时的支持仍在逐步推出。

编译后的 WASM 二进制文件有多大?

通常是相同 C 代码的剥离后原生二进制文件的 2 到 5 倍。Wasmtime 可以在首次运行时编译并缓存本地代码,因此启动开销是每个二进制版本一次性付出的代价。

这会取代容器吗?

不会。WASM 是进程级沙箱。容器是操作系统级隔离。用 WASM 隔离进程内的不可信代码。用容器将进程与系统其余部分隔离。两者可以叠加,服务于不同的威胁模型。

来宾能否通过 WASI 漏洞逃逸?

攻击面在于 WASM 运行时和 WASI 实现本身,而非来宾代码。Wasmtime 使用 Rust 编写,消除了运行时中的许多内存安全缺陷。WebAssembly 规范也经过形式化验证。没有沙箱是完美的,但 WASM 运行时的安全记录优于大多数原生库。

C 语言中的段错误应该是一个受控的故障,而非生产事故。当你需要完整的操作系统隔离时,容器是正确的答案。当你只需阻止单个库接管进程时,WebAssembly 部署更快、更容易理解、而且真正有效。