在 Go 里跑不信任的代码:WASM 沙箱的四道墙(第107篇)

第二季第 7 篇。拆解对象:DeepFlux 钩子系统里的第三种执行器------wasmrunner,一个用 wazero(纯 Go 的 WebAssembly 运行时)实现的沙箱,负责执行租户上传的钩子代码。这篇回答一个问题:别人家的代码要在你的服务器进程里跑,怎么保证它读不到你的文件、连不了你的网络、也拖不死你的服务?不要求懂 WASM,概念当场讲。

先交代真实状态。今天我做了两件事:一是把沙箱相关的 11 个单元测试原样跑了一遍(3.2 秒全绿,其中包含"让一个死循环模块跑进来、验证它 600 毫秒被拖出去"的实测);二是在 /tmp 从零手工编码了一个 137 字节的 WASM 模块------不借助任何编译器,一个字节一个字节拼出来------放进同样的沙箱配置里执行,三个场景全部通过:

csharp 复制代码
[build] 手工编码模块:137 字节
[run] 输入 58 字节 JSON → 沙箱执行 → 输出: {"decision":"allow","reason":"handwritten module"}
[guard] "evil.sh" 缺少 WASM 魔数 \0asm → 加载拒绝(宿主不会把它当代码执行)
[guard] grow 100000 页后 memory.size = 1 (仍为原始 1 页 → 沙箱拒绝分配,宿主无恙)

第三行值得停留:模块申请把内存扩到 6.4 GiB,沙箱拒绝了,申请前后内存都是 1 页。这个"拒绝"不是模块自觉,是沙箱强制的------这正是本篇的主题。

一、先弄懂三个词

WASM(WebAssembly) :一种二进制指令格式。你可以把它理解成"世界通用的机器码"------不是给某种 CPU 的,而是给一个规范化的虚拟 CPU 的。任何语言(C/Erlang/Rust/Go/AssemblyScript...)都能编译成 WASM 字节;任何实现了 WASM 运行时的环境都能执行它。浏览器用它跑高性能代码,服务器用它跑不可信的代码------本篇就是后者。

沙箱(sandbox) :一个"什么都拿不到"的执行环境。代码在沙箱里照常运算、照常读写自己的内存,但文件系统、网络、环境变量、其他进程------默认统统看不见。沙箱这个词很形象:小孩在沙池里随便刨,刨翻天也出不了池子。

wazero :一个纯 Go 实现的 WASM 运行时。选它的决定性理由写在 commit 里:"纯 Go 无 CGO"------不需要在服务器上装任何 C 依赖,go build 出来的单个二进制自带沙箱能力。对一个强调"离线交付、单节点部署"的产品(第 101 篇),这一点比性能更关键。

二、为什么需要它:三种钩子执行器,一种比一种危险

第 06 篇讲过钩子(Hook)系统:在 Agent 工作流的关键位置(调工具前、回复前......)插入检查点,检查点代码可以放行、拒绝、改写或转人工审批。问题是:检查点自己是什么代码?谁写的? DeepFlux 给了三种答案,对应三个执行器(runner),2026-05-16 同一天入库:

执行器 跑的是什么 谁写的 危险程度
builtin 平台预置的 Go 函数(PII 脱敏、SQL 守卫、注入检测等) 平台开发者 低------代码在发布前经人工审查
http 把载荷发给一个外部 HTTP 地址,等它回判决 租户配置的 URL 中------代码在别人家跑,风险是数据外泄和 SSRF
wasm 租户上传的 .wasm 模块,在本进程内执行 租户(完全不可信) 最高------别人的代码在你的进程里跑

wasm 的定位因此很明确:前两种要么是自己的代码、要么把代码推到外面去,只有它把不可信的代码拉进来。拉进来的代码,必须关在笼子里。

顺带一个真实的姊妹事故:http 执行器上线时(注释原话)"此前完全没有防护"------工具系统早在 2026-05-29 就补了 SSRF 防护(拦截对内网地址的请求),钩子这条姊妹路径被漏掉了 ,审查发现后两者才收进同一个共享守卫(pkg/netguard)。"同一类漏洞在两个子系统里修一次漏一次"是安全工程的老毛病,解法是把防线做成共享件。

三、四道墙:沙箱怎么围起来

wasmrunner 的安全设计完整记录在它自己目录下的 SECURITY.md(2026-07-09 的 P4.2 审查报告),原则一句话:deny-all------默认拒绝一切,只白名单放开钩子逻辑必需的能力(对标知名 JS 沙箱 goclaw 的思路)。落到实现是四道墙:

墙一:没有门,就没有路(能力缺省)。 WASM 模块要接触外部世界,必须通过一组标准接口(WASI,约 46 个函数:开文件、读写、联网、取环境变量......)。wazero 会注册它们,但 DeepFlux 不配置任何资源:没有预开放目录 → 开文件直接返回坏文件描述符错误;没有网络监听配置 → 建连接同样失败;环境变量和命令行参数不注入 → 拿到空列表;标准输入永远 EOF、输出直接丢弃。审查报告里有一张 11 行的能力矩阵,每一行都是 ❌,唯一的 ✅ 是"读写自己的线性内存"(上限 16 MiB)。

墙二:内存天花板。 WASM 内存以 64 KiB/页为单位申请。wazero 默认上限 65536 页------4 GiB ,一个死循环分配内存的模块足以耗尽宿主机内存。配置收紧到 256 页 = 16 MiB(对"读载荷、判 JSON"这种判定式钩子绰绰有余)。我 demo 里那个 grow 100000 页的模块就是这么被摁住的。

墙三:时间天花板。 单次执行限时,钳制在 500ms, 30s。这里藏着一个极容易踩的坑,也是 07-01 那次修复的核心(下一节细说):wazero 默认根本不理会 Go 的超时取消信号 ------你设了 30 秒超时,模块里的死循环照样跑到天荒地老。必须显式开启 WithCloseOnContextDone(true),运行时才会在解释器/JIT 里插入周期性检查点,超时一到强行打断。

墙四:进门验魔术。 加载模块前先看文件头 4 个字节是不是 \0asm(WASM 的魔数,相当于 PNG 文件的固定开头)。不是?连沙箱门都不进。我 demo 里那个伪装成脚本的 evil.sh 就死在这道门上。

四道墙之外还有一层出口协议 :模块必须导出 alloc 和 run 两个函数,输入是宿主写进共享内存的 JSON(阶段名+载荷),输出是内存里一段"4 字节长度前缀+JSON"的判决书(allow/deny/modify/require_approval 四选一)。判决值如果不在四种之列,一律按 allow 处理------但别紧张,执行出错时的生死另有开关:租户可为每个钩子配置 fail-open(沙箱故障就放行)或 fail-closed(故障就拒绝),11 个测试里有一半在验证这两条岔路。

四、两次修复:安全配置"形同虚设"事故

这部分是全篇最值得细读的真实故事,两次 commit 把"文档写了"和"配置生效"之间的鸿沟暴露得干干净净。

第一次(2026-07-01,7a8c654f):审查发现两道墙根本没砌。 commit 标题就叫"wasm hook runner 超时形同虚设 + 无内存上限"。实情:代码注释和文档里白纸黑字写着"超时钳制 500ms, 30s",但 WithCloseOnContextDone 和 WithMemoryLimitPages 从未被调用------wazero 默认不检查取消信号、默认内存上限 4 GiB。也就是说:文档承诺的墙只在文档里,租户上传一个死循环模块就能把宿主拖死。审查报告将其定为高优先级 DoS 风险,修复本身只有两行配置。

但这个 commit 真正的诚实之处在结尾 ,原文照抄:"未覆盖:现有 wasmrunner 测试全部是边界场景(空 binding/文件缺失/非法字节),从未真正执行过一个合法 wasm 模块 ,因此没有构造'死循环模块被超时打断''内存超限被拒绝'这类端到端回归测试------需要真实 .wasm 二进制 fixture,测试基础设施目前不具备,留作后续独立任务。"------修了墙,但没有测试证明墙砌上了;它把这个欠账白纸黑字记了下来。

第二次(2026-07-09,af336c8c):欠账兑现。 没有引入任何外部工具链(CI 不保证装 TinyGo/wabt 这类 WASM 编译器,"依赖外部二进制会让测试变脆"),而是手工编码了四个测试模块:合法 allow 模块、死循环模块、内存爆炸模块、尝试打开 /etc/passwd 的越权模块------纯字节拼接,加上配套的 LEB128 编码工具共 347 行 Go。五个沙箱边界测试从此常驻 CI:死循环必须在 600ms 级被打断(而不是外层兜底的 3 秒------证明是墙三而非兜底起作用)、6.4 GiB 申请被拒、path_open 被拒......今天我跑的 11 个测试里,有 5 个就是它们。

这个故事的两条教训都很实在:其一,安全配置和普通配置一样会"文档写了但没接上",审查必须对配置与文档逐条对账 ;其二,修安全漏洞时没有回归测试,等于宣布"下次重构可能悄悄拆墙而无人知晓"------欠账当场记下、立刻偿还,比"以后补"可靠得多。

五、demo 实录:137 字节的模块,和一个阴险的编码坑

最后交代我自己的手工模块 demo,因为它恰好演示了"模块只是一段字节"这个本质,以及这段字节里埋着多少细节。

一个最小的判定式 WASM 模块由六个 section(段)拼成:类型段(声明两个函数的签名)→ 函数段(声明有两个函数)→ 内存段(申请 1 页内存)→ 导出段(把内存、alloc、run 三个名字暴露给宿主)→ 代码段(两个函数的指令)→ 数据段(预置一段输出 JSON)。我的版本里 run 的全部逻辑是一条指令 :把预置数据的地址 8192 压栈,结束------因为输出判决书早在数据段里写好了,run 只负责指路。137 字节里没有一个字节是多余的,每个字节都有含义。

而 demo 过程中我亲手踩了一个足够阴险的坑,值得替读者踩一遍:写"地址 8192"进指令流时,数字用一种变长编码(LEB128)表示,且有符号版 的编码里,最后一个字节的最高有效位是符号位 。8192 若按直觉编成 80 40,尾字节的符号位恰好是 1------解码结果是 -8192 ;换个编法 80 80 00,解码结果是 0 。两种错误都不报错:一种让数据段写到负偏移直接崩溃(还算幸运),另一种让数据写到偏移 0、模块静默返回空输出(我遇到的就是这种,排查了三轮)。第三种正确编法是 80 C0 00。137 字节里的一个编码位错了,程序不崩、不报错、只是安静地给你空结果------底层格式没有"善意的报错",这是手写字节教会人的事。

demo 的第三个场景则是超出仓库测试的一点增量:仓库的内存测试靠"模块正常返回、进程没崩"间接 验证上限(注释明说直接断言不值得),我的版本让模块 grow 之后立刻调用 memory.size 报告页数------返回 1(而非 100001),直接证明申请被拒。同一个结论,断言从"没死"升级成"数字为证"。

小结

  1. 不可信代码的答案不是"别跑",而是"关着跑"。 租户自定义钩子是真实需求,WASM+wazero 用纯 Go 给了本进程执行的可能;四道墙(能力缺省、内存上限、时间上限、魔数验证)全部是配置缺省实现,没有一行"黑名单"逻辑------deny-all 的可维护性远好于逐个封堵。
  2. 最危险的不是没有墙,是"以为有墙"。 07-01 的事故里,文档、注释、评审全都认为超时和内存上限存在,实际两行配置从未写入。安全属性必须可测试------07-09 手写字节构造模块补上五个边界测试,才把"墙存在"变成"墙存在且 CI 天天验证"。
  3. 同一类漏洞要收进同一道防线。 SSRF 防护在工具系统修了、钩子系统漏了,最终共享同一个 netguard 包。安全组件做成共享件,"修一次漏一次"才能根治。
  4. 机器守的部分今天全绿:仓库 11 测(含死循环 600ms 打断、6.4GiB 拒绝、path_open 拒绝)+ demo 三场景(137 字节模块往返、魔数拦截、memory.size=1 实证内存墙)。wasmrunner 在生产 hook 链路上的实际调用频次本次未观测,如实分层。
相关推荐
汉堡大王95273 分钟前
一张图三句需求,我用 Trae Work 做了一块能看日出日落和月相的天文机械表
前端·后端·github
mudtools7 分钟前
在.NET现有系统中快速集成飞书任务分配能力
后端·c#·.net
知守观8 分钟前
ThreadLocal + 异步线程导致用户数据串号:一次跨请求数据泄漏的完整复盘
后端
前端冒菜师10 分钟前
我为什么做了 Iris,又为什么停下了它
后端·ai编程
子一!!11 分钟前
集成Spring家族的Spring论坛实战==一阶段
java·后端·spring
Ticnix12 分钟前
你的 Agent 聊到第 20 轮就"失忆"?你管理的是历史,高手管理的是上下文
后端·python·agent
不合格的程序员12 分钟前
Agent Memory架构设计与实现
后端·ai编程
用户938169125536019 分钟前
分页查询 Out of sort memory 问题
后端
xyLJ25 分钟前
为什么 Spring Boot 自动配置了 Redis,还要自己写 RedisTemplate?
后端
xixiaoyunya1 小时前
Spring Boot 3 统一响应与全局异常处理实战
java·spring boot·后端