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 部署更快、更容易理解、而且真正有效。