WebAssembly 的供应链安全困境:Wasm 模块信任模型与恶意代码检测
沙盒锁的是房门,钥匙在你手里——信任链四层账本,每一层都要算清风险预算
先说个画面。
某个独立游戏上线那天,玩家点开你发在 itch 的链接。进度条走完,游戏正常开局——画面、操作、存档,一切照旧。三十秒后,他的笔记本风扇像拖拉机一样响起来,CPU 拉满,电池肉眼可见地掉。他骂了一句“这游戏优化真烂”,把页面关了。
游戏本身没问题。有问题的是他浏览器里正在跑的那份 .wasm——那已经不是你构建出来的那一份了。
我不打算把这个开场写成恐吓文。说句实话,这类事在游戏圈真正公开爆出来的案例并不多,原因很朴素:大部分中小团队根本查不出来。你连自己上一次发布的那串字节到底是什么样都没留底,出了事拿什么比对?但这不代表它离你远。构建机被污染、CDN 中间层被替换、第三方 SDK 顺带夹带点东西、包体在“优化”过程中被重新生成——每一环,在别的软件行业里都真实发生过,而且不止一次。
这个专题走到第 28 篇,前面 27 篇我们一直在解决“怎么把游戏塞进浏览器、怎么让它跑得快、怎么把 50MB 压到 5MB”这些问题。这一篇我们把问题反过来问:你怎么证明,玩家正在运行的那串字节,是你在自己机器上编译出来的那一串?
这个问题听着像安全团队该操心的,其实不是。它是个工程管理问题,跟你团队几个人没关系。只要你的游戏是通过网络分发出去的,你的信任链就已经存在了——区别只在于它是被你有意设计过,还是稀里糊涂长出来的。
后面我把它拆成四层:谁产的、谁的字节、跑在哪、坏了怎么办。每一层我都给一条判据,一个失守后的最坏画面,外加一段能直接抄进工程的参数。中间会拿 Unity WebGL 的构建产物流水线、CoinHive 那场浏览器挖矿风波、以及用 Wasmtime 承载玩家 mod 的沙盒场景,做三段实打实的拆解。
先记一句话,这句是全文的骨架:沙盒管的是“它进来以后能干坏事吗”,不管“你为什么把它请进来”。
信任链的起点不是浏览器,是你自己的构建机。这层最反直觉的地方在于:它跟 Wasm 一点关系都没有,跟你代码写得好不好也没关系。
一次发布,从 commit 到玩家硬盘里那个文件,中间要经过多少只手?我随便数一下你们团队的情况:源代码仓库、CI 流水线、CI 用的镜像、镜像里的工具链版本、构建脚本本身、构建机上的缓存目录、打包脚本、上传脚本、CDN 的存储桶和它的访问密钥、发版页面上的那个上传按钮。这条链上任何一环被换掉,产出的字节就可能跟你预期的不一样,而源码 diff 会干干净净——这是最要命的部分。源码没变,构建稿没变,字节变了。
前端生态里这种事出过不止一次:某个被成千上万个项目依赖的工具包,维护者账号被盗,新版本里被塞进了别的东西,而所有下游项目的 package 文件看起来毫无异常。游戏这边只是把同样的剧本换了个舞台——你依赖的是引擎、是构建脚本、是那个三年没升级过的打包工具。
那这层的判据是什么?一句话:你能不能在一张纸上,把从源码到玩家浏览器所经过的每一个节点都写下来,并且对每一个节点说出“谁能改它”。写不出来,这层就是空的。
失守的最坏结果是什么画面?你给全部玩家同时下发了一个带后门的“官方版本”。注意,是官方版本——签名是对得上的,因为它们确实是你签的;杀毒软件给白名单也不算冤枉,因为这个文件确实出自你的域名。
落地的抓手有三个:确定性构建(同一个 commit、同一套锁死的工具链版本,产出的哈希必须稳定)、发布权限收敛(能触发一次正式构建的人越少越好,最好是一台机器而不是一个人的笔记本)、构建产物签名(私钥不在构建机上,签名步骤作为流水线最后一道闸门)。这三件事不便宜,尤其第二件会得罪人——但它们的性价比在第一层里是最高的。
顺手给个数量级的参考:密钥轮换周期 90 天起步,正式发布权限控制在 1 到 3 人,构建工具链版本用锁文件钉死,一次全量构建的可复现性验证跑月度。这些数字不神圣,但它们能让你在被问“这版是谁发的”时,不用翻聊天记录。
顺便说一句,这一层做扎实了,后面三层的工作量会掉一个量级。反过来,第一层是空的,后面做再多校验,也只是在给一栋没有地基的房子换门锁。
这一层是全文最容易翻车的地方,也是最多团队自认为“已经做了”的地方。
先讲机制。浏览器有个原生功能叫子资源完整性校验(Subresource Integrity,下文简称 SRI):你在 <script> 或 <link> 标签上写一个哈希,浏览器下载完这个子资源后自己算一遍,对不上就拒绝执行。听着很美好,但它有两个边界,恰恰卡住了游戏:
第一,它只认脚本和样式表这两类子资源。.wasm 不在它的保护范围里。而 Wasm 之所以要用 fetch 拿回来再实例化,是因为你需要的是一段二进制而不是一个可执行脚本——这个路径上,浏览器的原生 SRI 根本不会介入。
第二,游戏加载器基本都不是“一个 script 标签”这么简单。它是一段引导脚本,在运行时动态决定去拉哪些文件、按什么顺序拉、要不要并行。校验点一旦落在“加载器内部”,你就得自己实现校验,而绝大多数团队在这一步选择了相信。
我在第 4 篇聊 JS 与 Wasm 边界时说过,桥接层是两套世界唯一的接口;这里补一句更狠的:桥接层也是攻击面最集中的地方,因为它同时能碰 DOM 又能碰 Wasm 内存。
案例一:Unity WebGL 构建产物的 CDN 分发链。我们在第 5 篇拆过 Unity WebGL 的导出结构,一个典型的构建目录里,真正需要被信任的是这么几类文件:引导脚本(loader)、框架代码(framework,通常是大块 JS)、.wasm 二进制、以及那个经常被忽略的 .data 资源包。Unity 的加载顺序大致是:页面先拉起 loader,loader 再去取 framework,然后 framework 负责实例化 .wasm 并把 .data 流式喂进去。
问题就出在这:你完全可以在页面里的 <script> 标签上给 loader 加上 SRI,做得漂漂亮亮,然后眼睁睁看着 .wasm 和 .data 裸奔过去——因为它们是 framework 在运行时动态 fetch 的。
更麻烦的是哈希会变这件事。framework 每次构建都带着新的构建信息,哈希必然不同,所以你的完整性值只能由构建流程自动写回 HTML。这就形成了一个经典的两难:要么每次构建后自动重算并回写,中间隔着一次“如果你的回写脚本挂了会怎样”;要么手动维护,然后你懂的,第三次热修之后就没人记得更新了。
还有一层我们第 10 篇聊包体优化时踩过的坑:压缩和优化会改写字节。你用 wasm-opt 跑一遍优化,哪怕只是把优化等级调高一级,Binaryen 重新生成的字节码哈希就全变了。很多 CDN 还会在边缘自动做一次 Brotli 重压缩——于是服务端存的那份和玩家手里那份,压根不是同一串字节。你的“哈希台账”如果不跟构建参数、压缩策略绑定,第二天就会满屏对不上。
我自己就翻过车:早期我们给 CDN 上的资源做了一套哈希比对,跑了两周,突然全量报警。查了半天发现是运维顺手把 CDN 的自动压缩开到了“最优”。字节没变坏,是我们自己的校验定义得太天真。
所以这层真正的判据只有一条:校验必须发生在“交给实例化的那一刻之前”,而且校验对象必须是“最终会被实例化的那个字节序列”。在下载后校验、在解压后校验、在实例化前校验——三个时机听起来差不多,实际上只有最后一个能拦住东西。
失守的最坏结果:你身上挂着“我们加了 SRI”的免死金牌,实际保护范围刚好绕开了整个游戏本体。这就像给防盗门装了最好的锁,然后每天从窗户爬进爬出。
到了这层,才是大家最熟悉的那套话:Wasm 跑在沙盒里,访问不了文件系统,碰不到网络,只能调用宿主明确暴露给它的函数。这话没错,而且这是 Wasm 最值钱的设计之一。
但麻烦在于,这句话经常被误读成“所以 Wasm 很安全”。这两句话的差距,就是这一整篇文章要讲的东西。
打个生活化的比方:酒店房间的门锁再好,也拦不住坏人——因为坏人是你自己刷卡带进大堂的。
先讲能力模型的机制。宿主给 Wasm 模块的每一份能力,都是通过“导入”这个动作显式授予的。模块自己能干的事,只限于它被允许调用的那些宿主函数,以及它能碰到的那些线性内存。这套东西的正确用法是默认全关、按需开一条缝:文件系统默认不给,要给就只给一个特定目录;网络默认不给,很多 WASI 版本里压根没有对应的能力;时钟和随机数也各有各的开关。
这里得提一句运行时差异——我们在第 16 篇横评里聊过 Wasmtime、Wasmer、WasmEdge 的选择,它们在能力控制上的成熟度和表达方式并不一样。有的运行时靠“预打开目录 + 存储配额 + 指令配额”这套组合拳,能给到相当细的执行预算控制;有的运行时提供更接近策略文件的权限描述,读写网络文件分门别类,但默认同样是拒绝。选型时如果只比启动时间和峰值性能,很容易忽略这一层,而这一层恰恰是后面所有安全策略的地基。
案例二:CoinHive 与那场浏览器挖矿风波。这是理解“沙盒管不了恶意”最好的教材,因为它的主角是一段完全合法的 Wasm 模块。
2017 年 9 月,Coinhive 上线,提供一段在浏览器里挖门罗币的脚本,同时有 asm.js 版本和 WebAssembly 版本——后者的算力明显更高,这也是 Wasm 首次以“性能武器”的身份出现在灰色地带。它的使用方式很朴素:网站主在自己页面里嵌一段代码,访客的 CPU 就替他干活。
然后事情就脱轨了。2018 年 1 月,安全厂商发现视频平台的广告位被注入了挖矿脚本,用户在不知情的情况下贡献算力;同年 9 月,一家统计服务商的第三方脚本被入侵,成千上万个站点跟着中招。Coinhive 本尊在 2019 年 3 月关停,但这段历史留下的教训一直在:那段挖矿模块没有越权。它没有读你的文件,没有连你的数据库,没有碰任何不该碰的东西——它只是在算数。
你的沙盒能对它做什么?什么也做不了。它做的每件事都在允许范围内。你能做的只有两件事:一是在加载之前认出它不该来,二是把它的执行预算掐到地板上——给它一个极低的指令配额,让它跑几秒钟就被强制中断。前者是第二层的活,后者是这一层的活。
这就是为什么我一直说,供应链安全是两个正交的轴:来源可信度和行为约束。沙盒只覆盖第二个轴,而且只能限制“能造成多大破坏”,限制不了“它想干什么”。
案例三:用 Wasmtime 承载玩家 mod 的沙盒。假设你做一个支持玩家写扩展的游戏,玩家上传 Wasm 模块,你的服务器或客户端用 Wasmtime 跑它们。这套架构的能力白名单怎么配?
我的默认配置是这样的:文件系统全关,除非 mod 声明了“需要读写自己的存档目录”,那就只预打开那一个目录,路径写死,连上级目录都碰不到;网络全关,压根不暴露任何联网相关的导入;时钟只给一个单调递增的计时器,不给真实时间,防止 mod 拿系统时间做文章;随机数给,因为很多玩法需要它;指令配额给一个上限,超了就中断,模块本身不会崩,只是这一次调用被判定失败。
越权尝试被拒的现场是什么样的?mod 想读一个不在白名单里的路径,宿主会拿回一个拒绝错误,玩法逻辑照常走,只是那次读取失败;mod 想联网,它在实例化阶段就会失败,因为对应的导入函数根本不存在——这个失败发生在模块启动之前,比你想象中干脆。玩家看到的表现是“这个 mod 加载失败”,而不是“游戏卡死了”。
这里有个真实的取舍要摆出来:为兼容旧 mod 而放宽权限,收益和代价必须算清楚。放宽文件读权限,老 mod 的兼容率可能从六成跳到九成,社区一片欢呼;代价是这个 mod 从此能读到你游戏目录里的任何文件,包括配置和缓存。放宽网络权限,联机 mod 能活了;代价是你的游戏变成了一个通用的网络跳板,有人拿它做你想不到的事。我的立场是:兼容性可以靠升级 mod 接口来换,权限放宽是收不回来的。权限这东西像信用卡额度,批下去容易,想降下来要打一圈电话。
这一层的判据:把宿主暴露给模块的每一个导入函数都列出来,问一句“如果这个函数被恶意调用一万次,最坏结果是什么”。答不上来的,就先关掉。导入函数的条数控制在 24 条以内,是个能落地的经验值——条数越多,你能逐个想清楚的概率越低。
前三层是防御,这一层是止损。它经常被忽略,因为大部分团队在写代码的时候默认“不会出事”。但你要接受一个现实:只要你的游戏在线上跑,就一定会有某一天,你需要让一个已经发出去的模块停止生效。
这层的判据只有一个数字:从你确认某个模块有问题,到全量玩家不再加载它,需要多久?
这个数字取决于三件事。第一是台账——你有没有记录每一次发布对应的哈希、构建编号、时间、触发人。没有台账,你连“出问题的是哪一版”都定位不了,后面全免谈。第二是缓存策略——静态资源用内容指纹做文件名,配一年的强缓存是没问题的,因为内容变了文件名就变了;但那份“哪个版本合法”的清单文件必须有很短的缓存,五分钟这个量级,它就是你的吊销开关。第三是重签流程——发现问题后,谁能拍板、谁来签、签完怎么推。这套流程如果不在纸上,出事那天就会在群里吵架。
失守的最坏画面不是“被攻破”,而是“你三天前就修好了,但一半玩家还在跑缓存里的老版本”;或者更糟——慌乱之中有人把校验放宽了,于是那个问题在很长一段时间里没人再看得见。
我们团队第一次做哈希台账的时候,第二天就赶上一次紧急热修。当时为了赶时间,操作的同学直接把新哈希手写进了白名单,没走签名流程。三个月后我们做审计,发现那份白名单里躺着一条谁也说不出出处的记录——它可能是那次热修,也可能是别的什么。那一刻我意识到,我们给自己开的那个后门,比任何外部攻击都更值得担心。
所以这一层我强烈建议补两个动作:一是吊销演练,每季度真跑一次“假装某个版本有问题,看多久能全量停掉”,不演练的流程等于不存在;二是台账与构建编号强绑定,任何一条哈希记录,必须能反查到构建编号和触发人,反查不到的记录一律视为无效。
下面这些值不是标准答案,是我在几个项目里调出来的区间,你们按自己的包体大小和团队规模往里套。别照抄,照着改。
| 配置项 | 建议取值 | 首屏代价 / 影响 | 说明 |
|---|---|---|---|
| 完整性校验开关 | 启动全量校验(核心模块)/ 懒加载校验(可选内容)/ 绝不不校验 | 全量校验首屏 +80~200ms(20MB 模块,移动端 Safari 更慢);懒加载校验把代价摊到加载点 | 小于 3MB 的模块,全量校验的代价可以忽略,别省 |
| 单文件体积上限 | 压缩后 8MB,超过则切分 | 超过阈值后校验耗时和失败重试成本非线性上升 | 体积越大,一次校验失败后的重试代价越高 |
| 导入函数条数上限 | ≤ 24 条 | 无首屏代价,但会限制一部分功能设计 | 每条导入都是宿主递给模块的一把钥匙,越少越能逐个审 |
| WASI 文件读写 | 默认关闭;必要时只预打开一个沙盒目录 | 开启后 mod 能力提升,审计面同步扩大 | 路径写死,不允许向上跳级 |
| WASI 网络 | 默认关闭 | 关闭后联机类 mod 无法运行 | 一旦开启,你的游戏就是一台公共代理 |
| WASI 时钟 | 默认只给单调计时器,不给真实时间 | 无感 | 真实时间可被用于探测环境、做时间侧信道 |
| 随机数能力 | 默认开启 | 无感 | 大量玩法依赖,关掉得不偿失 |
| 执行配额(指令预算) | 单次调用设上限,超限中断 | 复杂计算场景需调高,否则误杀 | 这是对挖矿类模块最有效的一道闸 |
| 审计扫描超时 | 3 秒 / 模块,超时视为可疑 | 加载阶段多一次扫描,建议放后台 | 超时不等于有毒,但要进人工复核队列 |
| 可疑模块隔离策略 | 三档:禁止联网 / 只读运行 / 直接拒绝加载 | 降级会损失部分功能,但游戏还能开 | 按可疑等级自动降档,别一刀切 |
| CDN 缓存 TTL | 带内容指纹的静态资源 1 年;清单文件 5 分钟 | 清单 TTL 越短,吊销越快,回源压力越大 | 清单文件就是你的吊销开关 |
| 重签流程时限 | 从拍板到全量生效 ≤ 30 分钟 | 需要值班和授权链 | 超过这个数,说明流程有单点 |
| 签名密钥轮换 | 90 天 | 每次轮换要重签一轮历史台账 | 轮换不是形式,是限制密钥泄漏的爆炸半径 |
表格里我最想强调两行:执行配额和清单文件 TTL。前者是对“合法但恶意”的模块唯一有效的限制,后者决定了你出事之后有多久能止血。这两项几乎不花成本,但经常被整个跳过。
写到这里必须承认一件事:不是每个团队都有资格选。你用什么引擎、构建链掌握在谁手里,直接决定了你能做哪一半。
路径 A:自研引擎或自建构建流水线。你能改编译参数,能在流水线里卡一道闸。做法是:把编译器和优化工具的版本钉死,构建过程做成确定性的(同一个输入永远得到同一个输出),构建结束自动签名并写入台账,发布前最后一步自动校验自己刚签出来的东西。
收益很实在:你能证明字节是什么,出了问题能一路反查到那次构建,甚至能重建出完全一样的产物做对比。代价也很实在:构建时间会涨,wasm-opt 全量跑一轮,中型项目多几分钟到十几分钟很正常;工具链版本被锁死,升级一次优化器就要重签一轮;发布流程变重,紧急热修不再是一个人的事。
适用谁?三个人以上、有专职或半专职管构建的人,或者对公平性敏感的竞技类项目。小团队硬上这条路,大概率会被流程拖死。
路径 B:Unity / Godot 这类黑盒导出产物。你改不动编译参数,也管不了引擎内部怎么组装文件。能做的只有外围三件事:建哈希台账(构建完对 .wasm、.data、框架脚本各算一份,入库)、在加载前做一次校验(针对最终被实例化的字节)、以及把运行时沙盒收紧。
收益是落地快,一周内能跑通,不用碰引擎。代价是首屏会多一次校验延迟,模块大的话几百毫秒起;还有误杀风险——引擎小版本升级、CDN 压缩策略变化、甚至浏览器缓存行为差异,都可能让哈希对不上,而你的玩家会看到“加载失败”。
更根本的限制是:这条路只能防“在途被换”,防不了“构建机已经被污染”。因为如果恶意代码是在构建阶段进去的,你算出来的哈希本身就是恶意的哈希,校验会一路绿灯。
适用谁?独立小团队、发布频率低、没有反作弊压力。先把这条路做扎实,别想着两口都吃。
我的建议是别骑墙。先承认自己现在在哪条路上,然后把那条路做到位。等你的构建频率高到一天五次、或者你开始做竞技玩法了,再考虑迁到 A。中间状态最危险——你以为自己有流程,实际上两边都没兜住。
坑一:“Wasm 有沙盒,所以供应链不用太操心。”
这是最普遍也最贵的误解。沙盒约束的是运行时越权,它压根不管你是怎么把这个模块请进来的。只要你的 .wasm 来自构建流水线之外的任何一个地方——CDN 中间层、第三方 SDK、外包交付、插件市场、玩家上传——这个坑就成立。我的立场很明确:沙盒是最后一道闸,不是第一道。把它当成安全策略的全部,等于给家里装了最好的防盗门,然后把钥匙插在门上。
坑二:“SRI 加上就万事大吉。”
SRI 只认脚本和样式表,而游戏本体几乎都是运行时动态取回来的。触发条件很简单:只要你的加载路径里有 fetch 或者 XMLHttpRequest 参与,原生 SRI 就保护不到那里。修复方法不复杂——把校验点挪到“交给实例化的前一刻”,自己算、自己比。但很多团队卡在“不知道该挪”,于是做了一份看起来很完整、实际绕开了全部关键文件的安全清单。
坑三:“压缩和优化是纯收益。”
第 10 篇我们聊包体优化,把 wasm-opt、Brotli、代码裁剪挨个夸了一遍。这篇得补上代价:字节码重写会让签名和哈希全部失效。优化器升个版本、参数调一档、CDN 换个压缩算法,你昨天的台账今天就对不上了。于是你被逼到一个岔路口:要么锁死工具链版本,把重压缩纳入签名流程(代价是版本管理变复杂,升级一次要重签一轮);要么放弃逐字节校验,改成抽样或者只校验关键模块(代价是保护面缩水)。
我的立场:在签名这件事上,把优化参数当作版本号的一部分锁死,比追求每一版都压到最小更值钱。包体大 200KB,玩家感知不到;校验失效,你可能永远不知道。
坑四(顺带提一句):“过了静态分析工具就安全了。”
我们在第 20 篇聊过 Wasm 静态分析与审计工具链的能力边界,第 17 篇又聊过调试信息。这两件事是连着的:Wasm 二进制的结构性不透明,让静态分析只能看到导入导出、控制流轮廓和少量字符串,看不出“意图”。真正让审计变得可行的,往往是被保留下来的调试信息和源码映射——它们本来是给开发者排错的,结果成了审计的救命稻草。所以我的建议是:发布版保留一份带调试信息的内部产物专门给审计用,外部发布的那份再剥干净。别把两者混在一起。
Q1:我的游戏就一个 .wasm,也没自建 CDN,这事跟我有关系吗?
有,但优先级可以往后排。你至少要做两件事:给构建产物留一份哈希台账,以及给加载路径加一次实例化前校验。这两件事加起来不到一天工作量,但它决定了你出事之后是“能查”还是“只能重装”。
Q2:SRI 到底能不能用在 .wasm 上?
浏览器原生的 SRI 不支持直接给 Wasm 用,它只覆盖脚本和样式表。但你可以自己实现等效机制:取回字节后算摘要、跟台账比对、对上了才交给实例化。关键是校验点必须卡在实例化之前,晚一步就没意义。
Q3:每次构建哈希都会变,台账怎么维护才不疯?
把它做成流水线的自动产物,别靠人记。每次构建结束自动算、自动入库、自动关联构建编号和触发人。人工维护的台账活不过三次热修——这不是态度问题,是流程设计问题。
Q4:我用 Unity WebGL 导出,目录里到底哪些文件要校验?
引导脚本、框架代码、.wasm、.data,一个都别漏。前两个能用原生 SRI 覆盖,后两个必须自己在校验点做。第 5 篇拆过这个目录结构,可以对照着把你的构建目录过一遍,你会发现文件数比你以为的多。
Q5:首屏加一次校验,会掉多少性能?
小模块几十毫秒,20MB 级别在桌面端一两百毫秒,移动端 Safari 可能翻倍。听起来能接受,但要放在正确的时机——如果你的游戏本来就要下载几十兆资源,校验完全可以跟下载并行,别做成串行的一道坎。
Q6:Wasm 插件沙盒里,玩家 mod 要读自己的存档怎么办?
预打开一个专属目录,路径在宿主侧写死,模块只能在这个目录里操作,连上一级都碰不到。这不是理论,主流运行时都支持这种“只开一扇门”的配置。别图省事把整个游戏目录开出去,那等于把仓库钥匙给了每个 mod 作者。
Q7:真出事了,最快多久能让所有玩家停止加载坏模块?
取决于你的清单文件缓存时间。定在五分钟量级,理论上十分钟内能覆盖绝大多数在线玩家;如果清单本身也被长缓存了,那这个数字可能是一天。这就是为什么我一直强调:缓存策略不是性能参数,它是你的止血速度。
Q8:静态分析工具能查出挖矿模块吗?
能查出一部分特征,比如异常密集的计算循环、可疑的导入组合,但查不出全部,也基本判断不了意图。更现实的组合拳是:来源校验挡住不该来的东西,执行配额限制来了之后能造成多大破坏,静态分析作为人工复核的辅助。三层里没有任何一层能单独成立。
最后留个问题给你:你上一次发布的那份 .wasm,现在还能算出它的哈希吗?如果算不出来,那你其实并不知道过去这段时间里,玩家运行的到底是什么。