WASM 沙箱安全的核心目标,是让不可信的第三方代码在宿主进程内被安全执行——即使插件本身留有漏洞或被植入恶意逻辑,也无法读取宿主文件、发起任意出站连接或长时间占用 CPU。本文以 Wasmtime 为例,从能力预开放(preopen)、资源计量(fuel / epoch)与宿主进程隔离三层给出可直接复制的 WASM 沙箱安全加固配置。
适用场景
- SaaS 平台允许用户上传自定义插件(规则引擎、数据处理脚本、图片滤镜)。
- 边缘网关或 CDN 节点运行第三方 Wasm 过滤器(如 Proxy-Wasm 扩展)。
- 多租户系统中用 Wasm 做租户逻辑隔离,替代 fork 子进程或轻量容器。
- 把不受信任的解析库、计算库编译成 Wasm,在服务端复用而不扩大宿主的攻击面。
前置条件
- Linux 内核 4.14 以上,glibc 2.28 以上(或使用 musl 静态构建)。
- Wasmtime 20 及以上版本,用
wasmtime --version确认。 - Rust 工具链,用于编译
wasm32-wasip1目标;若已有.wasm插件包可跳过。 - 一个专用非特权系统账号,例如
wasmrunner。 - 可选:
wasm-tools用于产物校验与导入表审计。
原理说明
Wasm 模块运行在宿主引擎实现的线性内存与虚拟指令流中,自身无法直接发起系统调用。它能做的每一件「危险的事」——读写文件、联网、取系统时间——都必须由宿主通过 WASI 显式注入。因此加固的总原则是默认拒绝、按需授权,具体落在三处边界上:
- 能力隔离:WASI Preview1 标准接口不包含 socket,未显式预开放的路径对 guest 完全不可见。
- 资源隔离:fuel 按指令条数计费,预算耗尽立即 trap;epoch interruption 由宿主的后台线程周期性递增纪元,长驻死循环可在几十毫秒内被打断。
- 内存隔离:线性内存上限由宿主侧的 StoreLimits 约束,guest 越界写入只会命中自己的线性内存,不会污染宿主堆。
需要特别注意的是,沙箱边界由宿主定义,而不是由字节码自身保证。任何宿主自行导入的自定义 env.* 函数,都是沙箱墙上开的一扇门,必须单独审计。
操作步骤
步骤一:以专用低权限账号运行
sudo useradd -r -s /usr/sbin/nologin -d /var/lib/wasmrunner wasmrunner
sudo install -d -o wasmrunner -g wasmrunner -m 0750 /srv/plugin-data
sudo install -d -o root -g root -m 0755 /opt/wasmhost
步骤二:用只读绑定挂载收敛静态资源目录
预开放目录对 guest 默认是可读写的。若插件只需要读取静态资源,先在宿主侧把目录挂成只读,再开放给 guest,避免插件改写规则库或配置:
sudo install -d -o root -g root -m 0555 /srv/plugin-run/assets
sudo mount --bind -o ro,nodev,nosuid,noexec /srv/plugin-assets /srv/plugin-run/assets
echo '/srv/plugin-assets /srv/plugin-run/assets none bind,ro,nodev,nosuid,noexec 0 0' | sudo tee -a /etc/fstab
步骤三:CLI 方式的最小权限启动
wasmtime run \
--dir /srv/plugin-data::/data \
--env PLUGIN_MODE=strict \
--disable-cache \
/srv/plugins/plugin.wasm run-job
两点必须记住:一是 --dir 只开放了 /srv/plugin-data,guest 眼中的 /data 之外不存在任何文件;二是 Wasmtime 自身的参数必须写在 .wasm 文件之前,写在之后的参数会被透传给 guest 程序。
步骤四:宿主侧强制资源上限
CLI 无法表达全部配额,生产环境应在宿主代码里显式设置 fuel、epoch 与内存上限。以下为 Rust 嵌入示例(Wasmtime 20+ API):
use wasmtime::*;
use wasmtime_wasi::WasiCtxBuilder;
fn build_engine() -> anyhow::Result<Engine> {
let mut cfg = Config::new();
cfg.consume_fuel(true); // 开启指令计量
cfg.epoch_interruption(true); // 开启纪元中断
cfg.wasm_backtrace_details(WasmBacktraceDetails::Disable);
Engine::new(&cfg)
}
fn main() -> anyhow::Result<()> {
let engine = build_engine()?;
// 后台线程每 50ms 递增一次 epoch,guest 死循环将在 1 个 epoch 内被 trap
let ticker = engine.clone();
std::thread::spawn(move || loop {
std::thread::sleep(std::time::Duration::from_millis(50));
ticker.increment_epoch();
});
let module = Module::from_file(&engine, "/srv/plugins/plugin.wasm")?;
// 内存与实例上限,防止 guest 撑爆宿主内存
let limits = StoreLimitsBuilder::new()
.memory_size(64 << 20) // 单实例线性内存上限 64MiB
.instances(4)
.tables(4)
.build();
let wasi = WasiCtxBuilder::new()
.inherit_stdio()
.preopened_dir("/srv/plugin-run/assets", "/assets")?
.build_p1();
let mut store = Store::new(&engine, wasi);
store.limiter(|s| &mut s.limits);
store.set_fuel(50_000_000)?; // 约 5000 万条指令预算
store.set_epoch_deadline(1); // 下一个 epoch 到期即 trap
let instance = Instance::new(&mut store, &module, &[])?;
let run = instance.get_typed_func::<(), ()>(&mut store, "run")?;
run.call(&mut store)?;
Ok(())
}
关键参数说明:memory_size(64 << 20) 限制单个实例的线性内存上限;set_fuel(50_000_000) 为本次调用设定指令预算,超支会返回 all fuel consumed 的 trap;set_epoch_deadline(1) 与 50ms 的 ticker 组合,等效于约 50ms 级别的执行超时。生产环境下每个请求进入前都应重新调用一次 set_epoch_deadline(1),把预算按请求切分。
步骤五:审计插件导入表
wasm-tools validate /srv/plugins/plugin.wasm
wasm-tools print /srv/plugins/plugin.wasm | head -40
校验重点是导入段(import section)。若插件导入了自定义的 env.* 宿主函数,必须逐个确认该函数内部有参数校验与长度限制,否则攻击者可以绕过 WASI 的能力模型,直接通过这层自定义 ABI 访问宿主资源。
配置验证
- 文件越权验证:编译一个尝试读取
/etc/passwd的测试模块,预期输出failed to find a pre-opened file descriptor。 - 资源耗尽验证:构造死循环模块,运行后应在 100ms 内以 epoch deadline 相关 trap 退出,宿主 CPU 不持续占用。
- 内存上限验证:模块内
memory.grow超过 64MiB 时应返回 -1 并触发 trap。 - 进程账号验证:运行中的进程应以
wasmrunner身份存在,而非 root。
ps -o user,pid,cmd -C wasmtime
sudo -u wasmrunner cat /etc/shadow # 预期 Permission denied,确认宿主账号本身无特权
常见问题
Q1:WASI Preview1 真的完全不能联网吗?
标准 Preview1 接口不含 socket 相关调用,但宿主若自行导入了 sock_* 或 wasi:sockets 能力,guest 就重新获得了网络访问。加固时应同时审计宿主 ABI 与显式授权项,把出站访问收敛到固定的代理函数,并在代理内部做目标域名白名单与响应体大小限制,而不是把网络能力直接暴露给插件。
Q2:开启 fuel 之后插件性能下降明显,如何取舍?
fuel 计量会给每条指令增加一次计数操作,典型开销在 20% 到 50% 之间。折中方案是只用 epoch interruption 做超时控制,它的开销接近零,把 CPU 公平性交给宿主调度器,同时用 cgroup 限制整个 host 进程的 CPU 配额。需要说明的是,--disable-cache 只影响编译产物缓存策略,与运行时性能无关,不要为了解决性能问题去打开编译缓存。
Q3:插件需要长期驻留(如 HTTP 过滤器),epoch 会不会误杀正常请求?
不会。epoch deadline 是「每个调用周期」的预算而不是进程级时限。宿主应在每次请求进入前重设 set_epoch_deadline(1),单个请求只要在 50ms 内完成就不会被中断;若业务本身需要更长耗时,把 deadline 调大或把 ticker 周期放宽即可,两者是可独立调整的参数。
总结
WASM 沙箱的安全边界由宿主定义,落地时按三层逐项检查:能力层只预开放必需目录、静态资源尽量用只读绑定挂载;资源层同时配置 fuel 与 epoch 并设定内存与实例上限;进程层用专用低权限账号运行,再叠加 systemd 沙箱与 seccomp 兜底。完成这三层之后,即使插件被完全攻陷,攻击者拿到的也只是一个没有文件、没有网络、限时执行的空壳。