WASI 文件系统与沙箱:preopened 目录、能力模型与路径逃逸防护

系统覆盖 WASI(WebAssembly System Interface)文件系统的安全模型:能力(capability)授权的设计哲学(默认无权限、显式授予)、preopened directories 如何把宿主目录暴露给 WASM 模块(wasmtime --dir / WasiCtx preopen)、fd 文件描述符模型与 path_open 的能力链、权限标志(read/write/append/create/truncate/directory)、基于能力的路径解析(禁止绝对路径/.. 逃逸、符号链接防护)、虚拟根文件系统的映射、与操作系统沙箱(chroot/容器)的对比、多租户隔离下的文件沙箱实践,以及路径逃逸与权限配置的常见陷阱和排查方法,帮助读者把 WASI 沙箱从「听说过」变成「能正确配置」。

引言

WASI 最反直觉的一点:WASM 模块默认什么文件都碰不到。没有「工作目录」「当前用户文件」,甚至没有 /——它看到的文件系统完全是宿主「显式打开」的那几扇窗。这套 能力(capability)模型 与 OS 的 chroot、容器的挂载卷有本质区别:它不是「把整个文件系统藏起来一部分」,而是「根本没给文件系统,只给能力凭证」。本文把 WASI 文件系统沙箱的机制拆开:preopened directories 是什么、fd 与 path_open 的能力链怎么走、路径解析如何杜绝 .. 与符号链接逃逸、权限标志怎么配、以及多租户场景的正确姿势。

前置:/wasm-wasi-runtime-cloud/(WASI 演进)、/wasm-security-sandbox/(沙箱总机制)、/wasm-wasmtime-runtime/(运行时接入)。

目录

1. 能力模型:默认无权限

WASI 的哲学与 OS 完全不同:

OS 权限:进程继承父进程的权限,可访问「整个命名空间」,靠系统调用授权
WASI 能力:模块「生而一无所有」,每个能力(打开某个目录/绑定某个端口)都必须被显式授予
WASI 模块启动时:
✗ 没有工作目录
✗ 没有环境变量(除非显式传)
✗ 没有可打开的路径(除非 preopen)
✗ 没有可绑定的端口(除非显式授权)
✓ 只有「stdin/stdout/stderr」这类被明确提供的 fd

为什么这样设计:把「权限」做成「凭证」而不是「身份」——模块不再「属于某个用户」,而是「恰好持有某几把钥匙」。最小权限原则成为默认,而不是需要事后收敛。

2. preopened directories

宿主用 preopen(预打开) 把目录暴露给模块:

# wasmtime 命令行的 --dir
wasmtime run --dir ./data app.wasm
# 模块里看到的路径 = 映射名(见第 7 节虚拟根)
// 宿主代码(Rust,WasiCtx 预打开)
let ctx = WasiCtx::builder()
    .preopened_dir(host_path("/srv/data"), "/data")?   // 宿主 /srv/data → WASM 视角 /data
    .build()?;
preopen 的语义:
□ 暴露「宿主的一个目录」给 WASM 模块
□ 模块持有一个「目录 fd」(capability)
□ 后续所有路径访问都从该 fd 出发(见第 3、5 节)

工程要点:preopen 是唯一的入口——模块想读任何文件,宿主都必须先显式 preopen 对应目录。没有「默认继承的整个磁盘」。

3. fd 描述符与能力链

WASI 的文件访问围绕 fd(文件描述符) 展开,但这里的 fd 是「能力凭证」而非 OS 句柄:

能力链:
preopen 目录 fd(宿主授予)
  → path_open(在该目录下打开相对路径)
    → 得到子文件/子目录 fd(继承部分权限)
      → 继续 path_open 深入…

关键:每次 path_open 都从「一个已有的 fd」出发
  → 模块能到达的范围 = 它能从持有 fd 出发抵达的范围
WASI Preview2 的 filesystem 接口:
filesystem::open_at(dir_fd, path, ...) → 返回新 fd
  → 以「目录 fd + 相对路径」为唯一寻址方式
  → 无「绝对路径」概念(见第 5 节)

工程要点:fd 是能力的最小单位——模块「持有多少 fd,就拥有多少范围」。合理设计是「按需开 fd」:能开文件就开文件,不要长期持有整个目录 fd(缩小被攻击面)。

4. path_open 与权限标志

每次 path_open 都要显式声明所需权限(flags),宿主/运行时据此校验:

路径标志:
□ readonly / write / append / create / truncate
□ directory(作为目录打开,用于继续向下走)
□ nofollow / follow(符号链接处理,见第 6 节)

fd 权限标志(open_at 时声明):
□ read / write / append / create / truncate / directory
□ 声明了但宿主不允许 → 运行时拒绝
;; 伪指令示意(WASI Preview2 的 open_at)
;; 以目录 fd 打开 "config.toml",只读
call $open_at
  (param $dir fd) (param "config.toml")
  (param OPEN_READ)          ;; flags:只要读
  (result (result fd errno))

工程要点:权限要「最小化声明」——只读任务只声明 read,不声明 write/create。这样即使代码 bug 想写文件,能力层面就通不过。这是「纵深防御」在 WASI 的体现。

5. 基于能力的路径解析

WASI 的路径解析是基于能力的,与 OS 的「绝对路径解析」根本不同:

OS 解析:/srv/data/x → 从根开始,逐段查找(受全局命名空间约束)
WASI 解析:从「持有 fd 的目录」出发,逐段相对查找
  → 没有「根」、没有「绝对路径」、没有「父目录」的全局概念
例:模块持目录 fd(对应宿主 /srv/data)
  可访问:fd 下的 a.txt、b/sub/(逐段相对)
  不可访问:/etc/passwd(无此 fd 也无绝对路径)
            /srv/data/../other(.. 超出 fd 边界 → 拒绝)

工程要点:因为路径总是相对某个 fd,.. 超出 fd 边界即被拒,模块「天然看不到 fd 之外的世界」。能力边界 = 沙箱边界,比 chroot 的「路径前缀匹配」更严密。

6. 逃逸防护:绝对路径与符号链接

攻击者可能试图「从 fd 逃出去」,WASI 有对应防护:

逃逸向量 1:绝对路径 → WASI 无「根路径」,无此能力 → 拒绝
逃逸向量 2:路径里有 .. 超过 fd 边界 → 运行时拒绝(能力解析)
逃逸向量 3:符号链接指向外部 → 需显式 follow,且链接目标仍在能力范围
   → nofollow(默认)拒绝跟随链接,或 follow 但目标超范围也拒绝
实现细节(wasmtime 等):
□ 内部用「capability 感知的路径查找」,实时校验每一段
□ 符号链接跟随前解析目标,检查是否仍在授权范围内
□ 等价于「每次 open 都是一次权限审计」

工程要点:默认 nofollow 是最稳策略——除非明确需要链接跟随,否则拒绝。即便开启 follow,运行时也会把「链接目标是否在能力范围内」作为打开条件,逃逸不是「跟随链接」就能做到的。

7. 虚拟根与多租户映射

preopen 时可以把宿主路径映射成模块视角的「虚拟根」,实现多租户隔离:

宿主磁盘:/srv/apps/tenant_a/  /srv/apps/tenant_b/  /srv/shared/

给租户 A 的模块:preopened_dir(/srv/apps/tenant_a, "/") + preopened_dir(/srv/shared, "/shared")
给租户 B 的模块:preopened_dir(/srv/apps/tenant_b, "/") + preopened_dir(/srv/shared, "/shared")

→ 模块看到「自己的 / 」其实是宿主里各自的目录
→ 共享目录各自挂到不同虚拟路径
→ 同一份代码、不同能力 = 不同沙箱
虚拟根的收益:
□ 多租户「同一镜像不同数据」:配置(挂载点)决定隔离
□ 路径不暴露宿主真实布局:内部路径不可预测
□ 迁移宿主目录:只改宿主映射,模块代码不变

工程要点:把「模块视角路径」与「宿主路径」解耦——虚拟根映射让多租户的隔离与迁移都收敛到「宿主配置」层,是 WASI 文件沙箱在生产用得最多的形态。

8. 与 OS 沙箱的对比

维度WASI 能力沙箱chroot / 容器
默认权限无(生而无权)继承父进程
边界依据能力凭证(fd)路径/命名空间
逃逸难度无根、无绝对路径、链接受限依赖内核隔离(提权漏洞)
多租户程序内隔离,单进程可多沙箱通常进程/容器级隔离
适用不可信模块、FaaS 单元进程/系统级隔离
互补关系:
WASI 沙箱管「程序内」的能力授权
OS 沙箱管「进程/系统」的隔离边界
→ 生产往往两层都用(WASM 沙箱 + 容器/进程隔离)

工程要点:WASI 沙箱是「第一层细粒度授权」,不是「替代操作系统」——纵深防御:容器负责系统隔离,WASI 负责程序内最小权限,两者叠加而非二选一。

9. 常见陷阱与排查

陷阱 1:以为模块「能看到整个文件系统」
  → 现象:open /etc/passwd 失败
  → 解决:理解能力模型,用 preopen 显式暴露

陷阱 2:preopen 但没给够权限标志
  → 现象:read/write 报 EPERM(权限不足)
  → 解决:检查 open_at 的 flags 与宿主授权是否匹配

陷阱 3:符号链接跟随导致意外失败
  → 现象:打开「看起来在目录内」的文件被拒
  → 解决:确认链接目标;必要时 follow + 确认目标在授权范围

陷阱 4:把「目录 fd」当「整盘访问」
  → 现象:目录 fd 能触达的东西超出预期
  → 解决:按需最小化 preopen,不要 preopen 到 `/` 或用户主目录
# 排查:先看模块到底拿到了哪些能力
wasmtime run --dir ./data --print-wasi app.wasm
# 报错时看 errno(WASI 错误码)定位是「不存在」还是「权限不足」

工程要点:遇到「文件访问失败」先问「能力给了没、给的范围对不对」,而不是「路径对不对」。WASI 的报错语义(EPERM vs ENOENT)能帮你快速区分「能力问题」与「路径问题」。

10. 速查表与一句话记忆

问题一句话答案
模块默认能访问什么几乎什么都没有,靠显式授权
怎么暴露目录preopen(wasmtime –dir / WasiCtx)
访问文件靠什么fd 能力链:从目录 fd 出发 path_open
权限怎么控制path_open 的最小 flags 声明
能不能用绝对路径不能——没有根,路径相对 fd
符号链接怎么防默认 nofollow,follow 也须目标在能力内
多租户怎么隔离虚拟根映射 + 每租户独立 preopen

一句话记忆:WASI 文件沙箱 = 能力凭证(fd)+ preopen 显式暴露 + path_open 最小权限 + 无根相对路径(杜绝逃逸)+ 虚拟根多租户——「模块生而无权,所有能力都是宿主给的钥匙」。

延伸阅读

  • /wasm-wasi-runtime-cloud/ — WASI 标准演进与运行时接入
  • /wasm-wasmtime-runtime/ — Wasmtime 的能力授权接入
  • /wasm-security-sandbox/ — 沙箱整体机制与运行时加固
  • /wasm-wasi-preview2-http/ — WASI 网络接口的能力模型
  • /wasm-embedded-iot/ — 嵌入式/资源受限场景的沙箱取舍
  • 安全专题 — 沙箱、权限与纵深防御

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「wasm」更多文章

  1. WASM 构建与打包工具链:wasm-bindgen、wasm-pack 与前端集成
  2. WASM 媒体处理:音视频编解码、转码与滤镜
  3. WASM 密码学:WebCrypto、WASM 密码库与安全计算