引言
WASI 最反直觉的一点:WASM 模块默认什么文件都碰不到。没有「工作目录」「当前用户文件」,甚至没有 /——它看到的文件系统完全是宿主「显式打开」的那几扇窗。这套 能力(capability)模型 与 OS 的 chroot、容器的挂载卷有本质区别:它不是「把整个文件系统藏起来一部分」,而是「根本没给文件系统,只给能力凭证」。本文把 WASI 文件系统沙箱的机制拆开:preopened directories 是什么、fd 与 path_open 的能力链怎么走、路径解析如何杜绝 .. 与符号链接逃逸、权限标志怎么配、以及多租户场景的正确姿势。
前置:/wasm-wasi-runtime-cloud/(WASI 演进)、/wasm-security-sandbox/(沙箱总机制)、/wasm-wasmtime-runtime/(运行时接入)。
目录
- 1. 能力模型:默认无权限
- 2. preopened directories
- 3. fd 描述符与能力链
- 4. path_open 与权限标志
- 5. 基于能力的路径解析
- 6. 逃逸防护:绝对路径与符号链接
- 7. 虚拟根与多租户映射
- 8. 与 OS 沙箱的对比
- 9. 常见陷阱与排查
- 10. 速查表与一句话记忆
- 延伸阅读
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/ — 嵌入式/资源受限场景的沙箱取舍
- 安全专题 — 沙箱、权限与纵深防御
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。