ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

WebAssembly沙箱逃逸攻防:边界、漏洞与安全加固实践

WebAssembly沙箱逃逸攻防:边界、漏洞与安全加固实践

1. 先搞清楚一件事:WebAssembly沙箱到底在拦谁

提到WebAssembly沙箱,大多数人第一反应是“安全、隔离、可控”,甚至有些人直接把它当成“轻量级容器”来用。但干了几年安全攻防之后,我对这类说法的态度越来越谨慎:沙箱从来不是一个点,而是一条边界;边界防得住常规攻击,不代表它不会失效。WebAssembly沙箱从设计目标上讲,是让不可信代码在受限环境里运行,同时不泄露宿主机进程的内存、文件系统和其他敏感资源。这个目标本身没有问题,问题在于,很多人把它当成了一道“防弹玻璃”,而它实际是一层“单向透视玻璃”——从里面看外面是黑的,但从外面看里面也是透明的,关键在于有没有人知道玻璃在哪、怎么砸。

客户端场景下,浏览器是宿主,WebAssembly模块跑在浏览器引擎的隔离环境里;服务器端场景下,WASI运行时是宿主,模块跑在服务进程内。两者的共同点非常明显:线性内存、宿主函数、模块实例,这三者构成了一条完整的权限边界。逃逸的本质,就是突破这条边界,拿到宿主上下文里的执行权或数据访问权。

这篇文章我会从客户端和服务器端两个视角,把WebAssembly沙箱逃逸的攻防面拆开:逃逸是怎么发生的,攻击者会从哪些路径下手,防守方最终应该把力气花在哪里。内容会偏实战,但不会给出可直接利用的完整武器化样本,重点是把原理讲透,你才能在自己系统里举一反三。

2. 沙箱模型拆解:它到底拦得住什么,拦不住什么

2.1 模块、线性内存与宿主函数,三者构成边界

要理解逃逸,先得理解WebAssembly的执行模型。一个编译好的WASM模块,运行时会获得一块独立的线性内存,这是一段连续的地址空间,默认位宽是32位,最大寻址4GB。模块内部的一切访问,包括指针、数组、栈,都是在这段线性内存里完成的。它不能直接发起syscall,不能直接访问宿主进程的堆,一切外部能力必须通过显式的宿主函数导入来获得——这里我用“宿主函数”这个说法,对应到代码里就是import段里声明的那一串外部接口,浏览器场景里常见的canvas绘制、网络请求封装,服务器场景里的文件读取、环境变量查询,都属于这一类。

这套设计能拦住的威胁,我在实战中总结过三类最典型的:

  • 模块本身包含恶意逻辑,但被限制在自身线性内存和已授权宿主函数范围内,无法越权。
  • 从C/C++或Rust编译进来的模块存在内存破坏漏洞,运行时边界检查能把这类错误隔离在模块自己内存里,不会直接打穿宿主进程。
  • 模块尝试访问未授权的资源(比如文件、目录、网络端口),WASI的capability模型会拒绝它。

但“拦得住”是有前提的。边界是否真的牢靠,取决于三件事:宿主函数的实现有没有缺陷,运行时引擎本身有没有漏洞,以及授权粒度是不是合理。这三个地方,恰恰就是逃逸发生的全部位置。

2.2 一个容易混淆的概念:能力限制不等于代码安全

我见过不少后端同学,把WASM当成“自带安全属性的代码格式”,觉得模块只要跑在沙箱里,里面代码怎么写都无所谓。这是认识上一个很大的误区。

WASM的安全边界保护的是宿主,不是模块自己。模块内部如果写了一个无限循环、一个巨大的内存分配,或者一个算法复杂度爆炸的函数,它确实跑不出去,但它可以耗尽宿主分配的CPU和内存资源。服务器端这种问题尤其致命——一个多租户环境里,一个模块把CPU吃满,其他租户跟着遭殃。这个虽然不算严格意义上的“逃逸”,但后果比逃逸更常见、更磨人,所以必须纳入威胁模型一起考虑。

3. 客户端侧:浏览器里的逃逸面与典型路线

3.1 逃逸的第一层:线性内存越界打到宿主进程

浏览器场景下的逃逸,绝大多数集中在运行时引擎这一层。用户通过网页加载一个WASM模块,引擎负责把字节码编译成本地机器码、管理线性内存、提供宿主函数桥接。攻击者要做的第一件事,是想办法在模块内部用某种方式拿到一个“超出线性内存范围”的读写原语,再用这个原语去碰宿主的堆内存。

理论上,引擎应该拦截所有越界访问。但实际工程不是数学证明,拦截逻辑本身可能出错。举一个我很早期跟踪过的漏洞类型:引擎在处理“宿主函数参数校验”时,对指针参数和长度参数分别做了检查,但两个检查用的不是同一份边界快照。模块只要在线性内存增长之后,再调用宿主函数,就有可能让引擎误以为某个宿主函数收到的指针仍然有效,实际上那块内存已经被线性内存增长操作重新分配过了。这类问题的本质不是WASM语言层面的边界失效,而是宿主函数实现里的TOCTOU(Time of Check to Time of Use)竞态。浏览器端由于JS与WASM对象之间共享GC堆,演化出的利用链通常还要叠加一个类型混淆或UAF,先拿到任意对象读写,再进一步替换宿主侧的函数指针。

这里我给一个非常精简的概念性示例,用来理解“宿主函数参数校验缺失”导致的问题形态:

// 宿主导入给WASM的处理函数(存在越界读风险) function hostProcess(bufPtr, bufLen) { // 只检查了指针是否为0,没有校验bufLen是否超过线性内存大小 const wasmMemory = new Uint8Array(memory.buffer, bufPtr, bufLen); processBytes(wasmMemory); }

真正的修复是要用运行时提供的导出接口拿到线性内存当前实际长度,再判断bufPtr + bufLen是否落在合法范围内,并且这两个值的获取必须是在同一个同步操作里完成,中间不能插入任何可能修改内存边界的机会。

3.2 第二层:JIT编译器漏洞,把字节码变成武器

浏览器引擎的WASM实现,基本都走了编译优化路线,运行时把WASM字节码转成中间表示,再生成优化过的本地代码。这个优化链路本身复杂度极高,而复杂度就是漏洞的温床。

字节码层面的“合法代码”可能触发编译器里的中间表示处理缺陷,产生一段与本意不符的machine code,比如错误的边界计算被深度剔除、某个分支条件的类型假定被错误传播、整数溢出绕过符号扩展检查。攻击者精心构造一个WASM模块,利用编译器的错误,让越界访问逃过运行时检查。一旦拿到原生代码执行权限,线性内存这个概念就形同虚设了,之后的动作就不再受任何约束。

这层逃逸的可怕之处在于,它不需要宿主侧暴露任何危险的函数接口,纯靠模块内容本身就能触发。防守方靠“审查模块来源”或者“限制宿主函数权限”都挡不住,只能靠引擎本身的修复和内存安全加固。

3.3 浏览器场景的利用链:从逃逸到提权的完整叙事

浏览器端的沙箱逃逸,很少是“一步到位的”,多数是一条链:

模块先触发引擎漏洞得到宿主进程内的读写能力,完成沙箱逃逸;随后利用宿主进程自身的漏洞或API去访问浏览器内部敏感数据,可能包括跨站数据、本地存储、插件接口;最后再尝试提权到操作系统层。

站在防守角度,这条链给我们的启发是:即使某一层防住了,也不代表整个系统安全了。每一条边界都需要独立加固。比如引擎本身打补丁了,但宿主函数暴露得过多,攻击者可以直接跳过低级逃逸,用高级API达成目的,这就属于“设计层面的逃逸”,代码再安全也救不回来。

4. 服务器端:WASI与函数计算场景的攻防空间

4.1 服务器端为什么需要WASM,又为什么更容易出事

服务器端用WASM,核心诉求是安全地运行不可信代码,同时获得比操作系统级容器更低的启动开销和更强的移植性。典型的业务形态包括边缘计算、多租户函数平台、插件市场、在线IDE的代码执行沙箱。模块通过WASI(WebAssembly System Interface)访问系统能力。WASI采用了一个很好的设计:capability-based安全模型。模块要访问某个文件,宿主必须先通过preopen显式授予目录能力;要监听端口,必须先授予socket能力;查询环境变量、读取时钟等,也都各有各的权能。

听起来很完美,但实际接入的时候,很多团队是在“伪装成WASI的形式下放权”。最常见的情况是给模块暴露一个“万能宿主函数”,比如一个invokeCommand,想着反正内部做过滤,结果过滤规则一个没写全,等于把沙箱门直接拆了。

服务器端逃逸路线里,我把优先级从高到低排了一下:

  1. 宿主函数过度授权或实现有缺陷
  2. 运行时引擎自身漏洞
  3. WASI实现与操作系统交互时的路径解析问题
  4. 模块资源耗尽导致宿主进程被拖垮

4.2 放权出问题:路径规范化与符号链接的坑

服务器端最经典的逃逸手法,是路径穿越式的越权访问。假设宿主给模块开放了/srv/data目录的preopen能力,模块代码里调用open("../../etc/passwd"),正常WASI实现会把路径规范化后,发现它超出了已授权的目录范围,拒绝访问。但实际工程里,路径规范化逻辑不是每个运行时都做得完整。比如Windows风格路径分隔符、Unicode规范化、递归符号链接,都可能成为绕过点。

我印象很深的一个案例,就是某个运行时在解析符号链接时,只对用户传入的路径做了规范化,没有对路径中每一个中间目录做符号链接解析。攻击者可以在已被授权的/srv/data目录里预先塞入一个指向/etc的符号链接,然后通过一系列拼接调用,让WASI以为目标文件仍然在授权根目录下。这种问题的根因,是运行时把“路径看起来像在授权范围”当成了“实际解析结果在授权范围”,二者在文件系统层面并不等价。

防守端要做的,不仅是要靠运行时自身的规范化,还要在宿主侧做二次校验:拿到文件描述符之后,再通过realpath或等价接口核对规范化后的绝对路径是否仍然落在授权目录下。这里我建议写成强制校验,而不是可选的日志项。

4.3 引擎漏洞在服务器端的放大效应

服务器端逃逸最危险的一点是放大效应。浏览器端,就算WASM逃逸了,攻击者还要面对渲染进程沙箱、系统权限、浏览器安全模型等多层防护。服务器端很多平台是真的把WASM运行时直接跑在大进程里,模块逃逸之后拿到的就是宿主进程的真实权限,宿主进程可能是root,可能是服务账号,但无论如何,逃逸距离系统权限只有一步之遥。

这类漏洞的关键触发面,往往在异常路径和边缘参数上。比如堆栈溢出处理时的信号处理逻辑,内存增长超过阈值时的分配失败路径,多模块实例共享内存时的别名管理。我在审计过程中养成了一个小习惯:拿到一个运行时,先不急着测业务API,而是把所有“理论上无法到达”的分支代码都拉出来过一遍。因为攻击者最爱找的,就是开发者和审计者都觉得“不可能被触发”的代码。

5. 防守方视角:把沙箱做成洋葱,而不是单层玻璃

5.1 宿主函数设计:宁可少暴露,不要多暴露

几个防御原则里,最重要的是宿主函数的最小化。每暴露一个宿主函数,就等于给攻击者递了一件武器。一个WASM图像处理模块,如果只需要输入输出缓冲区,那就只给两个函数:一个拿输入缓冲区的地址和长度,一个传输出结果。别顺手把日志、环境变量、网络拨号接口都给了。

我建议每个宿主函数上线前都过一个清单:

  • 这个函数被调用后,会不会读取或修改宿主进程的内存?如果是,是否完成了线性内存边界的严格校验?
  • 这个函数的结果会不会影响宿主进程的控制流或对象生命周期?
  • 这个函数是否可以审计?一个纯数学计算模块,却频繁调用文件写接口,本身就该触发告警。

还有一个容易被忽略的原则:宿主函数不要带有“通用语义”。比如给一个runJsonPath做配置解析,结果它内部支持正则表达式、支持执行Java Script风格的表达式,那这就是一个隐藏的任意代码执行口子。宿主函数越专用、语义越窄,攻击者可以利用的想象空间就越小。

5.2 运行时加固:版本同步、内存保护、功能裁剪

运行时引擎本身的安全更新,必须建立自动化机制,靠“想起来再升一次”是不可接受的。安全公告出现后,如果不能在规定时间里完成升级测试,那沙箱的风险敞口就是长期存在的。

内存保护方面,可以作为监控项关注运行时是否启用了如下的保护策略:

  • 线性内存外圈是否带有guard page,让越界访问更容易触发异常而不是悄悄读取到邻近数据
  • 是否启用了堆加固,比如W^X(写异或执行)策略,确保JIT生成的代码区域在写和执行之间互斥
  • 内存增长后是否对旧引用做了失效检查

功能裁剪方面,不用的WASM特性建议在实例化阶段就关闭。SIMD、多线程、共享内存这些特性,每个都是一个攻击面。你不需要它们,就不要开启它们。我在实际项目中见过几次,平台为了某个边缘业务开了多线程内存共享,结果整个隔离模型都要重新审计——因为共享内存天然打破了“模块彼此隔离”的假设。

5.3 运行级隔离:别把WASM当成唯一堡垒

防守的核心心态是:WASM沙箱只是一层,不是最后一道防线。服务器端部署,应该做到“沙箱套沙箱”:一个WASM实例跑在一个受限用户下,一组实例放在独立的容器里,容器再配合文件系统只读层和网络策略。这样即使WASM层逃逸,攻击者还要再穿透一层操作系统级隔离,难度骤增,同时也给了安全团队响应的窗口。

另外,建议在宿主里加一层调用审计。WASM模块调用宿主函数的频次、参数特征,都是很好的异常检测数据源。一个平时只做矩阵乘法的模块,突然开始频繁调用文件枚举接口,这种行为模式本身就值得立即阻断。不需要等引擎漏洞被利用完成,行为层就能先报警。

5.4 上线前的测试:模糊测试与负面用例

对我来说,WASM模块和宿主函数接入流程的最后一道工序,永远是模糊测试。我会做两类:

一类是针对宿主函数入参的模糊测试,不按接口文档生成数据,而是故意把长度字段写成超大值、把指针写成零、把对象类型混着传,目标是看宿主函数会不会崩溃、会不会越界。模糊测试跑不满24小时,我都不太敢说这个接口是安全的。

另一类是直接拿现成的WASM语料库跑运行时,找出会让引擎崩溃的模块。这类测试不需要写业务逻辑,只需要不停的喂入模块、实例化、执行。一旦发现崩溃,先隔离样本,再判断是引擎bug还是接口问题。

6. 常见问题与排查实录

6.1 几个高频问题的即时诊断表

现象可能原因快速排查方向
模块实例化报“out of bounds memory access”线性内存增长后,模块内保存的旧指针仍然在用检查内存增长逻辑,启动后旧指针一律重新获取
宿主函数触发崩溃,但日志没有WASM trap宿主函数内部对线性内存访问越界确认宿主函数指针参数是否做过边界同步校验
WASM模块访问不到已授权的文件preopen目录与模块内路径未匹配,或路径带符号链接检查真实路径解析结果与授权根目录的关系
模块运行极慢,宿主CPU飙升模块内部复杂度过高,不被沙箱拦截加CPU时间配额,评估模块算法复杂度
某次升级后大量模块异常新运行时对可疑字节码更严格,或删除了旧特性回滚并diff引擎版本,确认业务对废弃特性有依赖

6.2 一次模拟项目的复盘:看似无害的缓冲区接口

我印象比较深的一次排查,是一个内部Demo项目踩的坑。项目结构很简单:一个在线图像处理服务,用户上传图片后,后端编译一个WASM模块去处理像素数据。宿主暴露了一个resize函数,参数包括输入缓冲区指针、输入长度、输出缓冲区指针、输出长度。

问题出在输出长度上。实现的时候,函数只校验了输入缓冲区在合法范围内,输出缓冲区则假设“调用方给的总是对的”。攻击者构造了一个声明输出长度远大于实际线性内存的请求,宿主函数内直接按声明长度向输出缓冲区写入。长度校验缺了,写入越界,直接破坏相邻的宿主堆对象,最后转化成一次严重的内存破坏。

修法也不复杂:在宿主函数内部,从WASM实例重新取一次线性内存当前大小,再对输出长度做完整校验。同时把输出处理改成两步——先问模块需要多大空间,再分配、再写入。整个过程里,模块建议者给出的任何数字都不作为可信依据。

这次复盘给我的结论是,无论宿主的调用方看起来多“可信”,都必须假设它已经失陷。防御设计的锚点应该是“最坏情况”,而不是“文档描述的情况”。

7. 我的几条实操心得

核心的思路在实践中会有一些偏差,这部分的体会最直接:安全是边际成本,必须盯紧“投入在哪个环节回报最高”。我认为投入优先级是:host function最小化大于运行时升级大于运行级隔离大于主动检测,任何一个环节缺失,都会让攻击面成倍增长。

还要一条劝告:不要为了性能引入WASM却忽略安全问题。如果你是把WASM当“更快、更小的函数容器”来用,那它是好工具;但如果你把它当“容器”来用,期待它提供类似虚拟机或进程的隔离强度,那你必须自己补上缺失的隔离层。技术本身不分对错,边界在哪、强度多大,完全是使用者的责任。

再分享一个散落在代码之外的小习惯:把所有涉及WASM的代码,无论看起来多简单,都放进CR(代码评审)的强制审查清单里。我见过太多漏洞,不是因为开发者不懂安全,而是因为这些代码被归类为“公共库”或“无业务逻辑”而跳过了安全评审。WASM不是普通业务代码,它是安全边界,边界上的任何一行修改都值得敬畏。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进