WASM 沙箱安全加固实战:Wasmtime 最小权限配置

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 兜底。完成这三层之后,即使插件被完全攻陷,攻击者拿到的也只是一个没有文件、没有网络、限时执行的空壳。