在 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 就死在这道门上。

四道墙之外还有一层出口协议 :模块必须导出 allocrun 两个函数,输入是宿主写进共享内存的 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",但 WithCloseOnContextDoneWithMemoryLimitPages 从未被调用------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 页内存)→ 导出段(把内存、allocrun 三个名字暴露给宿主)→ 代码段(两个函数的指令)→ 数据段(预置一段输出 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 链路上的实际调用频次本次未观测,如实分层。
相关推荐
摇滚侠1 小时前
《Spring Boot 3:高级与架构设计》第 1 章 BeanDefinitionRegistry 阅读笔记 3
spring boot·笔记·后端
烂蜻蜓2 小时前
Flask入门教程(三十一):实用函数与类API——全局工具函数速查
后端·python·flask
计算机毕设定制辅导-无忧学长2 小时前
《基于Spring Boot传承之光非遗陶瓷烧造产品交易平台的设计与实现》
java·vue.js·spring boot·后端·毕业设计
Zane19942 小时前
内存都要回收,为什么JVM偏要把堆分成新生代和老年代
java·后端
Zane19942 小时前
257 is 257 为什么是 True?小整数缓存背后还藏着一个更容易被忽略的机制
后端·python
Terra.K2 小时前
后端开发阶段性总结
java·开发语言·后端
计算机学姐2 小时前
基于SpringBoot的旅游系统的设计与实现
java·vue.js·spring boot·后端·spring·java-ee·旅游
卷心菜的学习路2 小时前
Spring Boot 多数据源落地:AbstractRoutingDataSource + 注解切面(附源码)
java·spring boot·后端
薄雾晚晴2 小时前
大模型Skill暴论:所有Skill终将消亡?最新Skill方法论与模型增强开发实战
前端·后端·架构