V8 的 Sandbox 模式( 以下称为沙箱模式 )是 V8 为了保护进程内其他内存( 避免黑客基于 V8 的漏洞实现对进程内任意内存的读写 )而设计的一套机制,在 Chrome 103 版本启用,V8 10.8 版本对齐了 Chromium 的默认配置默认开启。
原理上,主要是通过提前开辟一块足够大的虚拟内存( 毕竟 64 位设备虚拟内存不要钱 ),即沙箱,并将 V8 可访问到的内存都分配到这块内存上来实现的。
实际上,随着指针压缩这一机制在 64 位设备上的默认启用,多数的 JS 对象原本就已经被限制分配在了预分配出来的 4G 地址空间上( V8 9.2 之后,同个进程多个 Isolate 默认共享同一个 4G 地址空间;启用沙箱情况下,指针压缩的地址空间分配在沙箱起始地址处 )。此外,V8 可访问到的内存大体上可以分为三种:即 ArrayBuffer 对象指向的内存( BackingStore ),WebAssembly 的 Memory 对象指向的内存,以及 V8 的宿主应用基于 V8 的 API 提供给 V8 访问的内存( 外部指针指向的内存,通常被分配在我们称之为 Native 堆的位置 )。
具体来说,对于 ArrayBuffer 和 WebAssembly 的 Memory 这两类情况,限制分配在沙箱内部。
外部指针的情况则要复杂一些。对于外部指针的情况,仍旧保持内存分配在沙箱外部,但额外引入了一层 ExternalPointer 的抽象来做保护。每个 Isolate 有自己独有的外部指针表( 实际上也有全局共享的外部指针表 ),V8 内部对外部指针的使用都改为基于外部指针表的间接引用的形式。外部指针表的表项同样会做 GC ,避免 JS 对象销毁后仍然可以访问到关联的外部指针;此外,在将外部指针存放到外部指针表中时,会将外部指针的部分高位( 考虑了和 TBI 以及 MTE 可能的冲突情况 )用来存放类型信息( V8 内部定义的类型,不能用来具体区分外部指针的 C++ 对象类型 ),实际访问时会基于这一信息做类型检查。
还有一个对上述两种情况的内存访问都通用的保护机制是,在启用沙箱情况下,V8 总是基于间接引用的方式来访问内存( 对于沙箱内内存的情况,使用基于沙箱起始地址的偏移 ),且间接引用的有效范围设计为 2 的幂 ( 即如果间接引用使用 n 个比特位表示,则间接引用的有效范围为 0 到 2 的 n 次方 ),保证了即使间接引用被恶意修改,也始终只能做到间接引用范围内的内存任意读写。 此外,在沙箱的左右边界上,V8 各设置了一个大小为 32G 的 guard region。这里的 guard region 和传统的 guard page 设计是一致的,通过在内存边界上设置不可读写的保护页来避免内存越界的情况。为什么是 32G?考虑 Float64Array( 元素大小为 8 字节 )的情况,在索引类型为 32 位整数情况下,最大占用内存就是 32G。32G 的 guard region,保证了如果基于 ArrayBuffer 来做内存越界攻击的话,也逃不出 guard region 的范围,不会影响到进程内的其他内存。
另一个值得一提的点是,沙箱的内存权限被限制为只可读写不可执行,而 JIT 所需要使用的可执行内存则会另行分配。这里是考虑到沙箱内内存可能会被攻击,在其上分配可执行内存可能出现因可执行内存被篡改导致的程序执行流程被篡改。
总的来说,V8 的沙箱模式是基于 V8 自身漏洞可能被利用导致内存被任意读写这一前提,使用沙箱将 V8 自身的内存访问同进程内其他内存隔离开来,来保护进程内其他内存的一套机制。 性能上,V8 的沙箱是进程内共享的,实际上不影响跨 Isolate 的一些内存共享行为;此外,额外引入的间接引用翻译和类型检查行为的实现具体做过权衡,性能影响是微乎其微的( 按 V8 的说法 )。
从 V8 使用的角度来说,实际上 WebAssembly Memory 内存分配位置的差异以及外部指针这一抽象的引入都是无感的,有明确影响的是 ArrayBuffer 的内存分配位置限制。以往我们可以通过在其他位置( 比如基于 malloc 分配在 Native 堆上或者基于 DirectByteBuffer 分配在 Java 堆上 )分配 ArrayBuffer 的实际使用内存来实现和 JS 的共享内存,而现在则要求我们必须在沙箱内部分配内存( 启用沙箱情况下,ArrayBuffer::Allocator::NewDefaultAllocator 返回的分配器实现即是基于沙箱内存的 )。
VS Code 的处理方式是直接替换了内存分配器内部实现使用的内存池( 得益于 Electron 和 Chromium 的既有设计 ),换成了基于 V8 沙箱创建的内存池,使得所有使用 malloc 类接口给 ArrayBuffer 分配内存的地方可以无感知迁移。
Android 端上的情况,直接替换进程的内存分配器不太现实( 影响面太大且可能有坑 ),且替换内存分配器的方案也无法覆盖直接使用 mmap 分配内存或者直接使用 Java 创建的 DirectByteBuffer 对应的内存的情况,手动做 ArrayBuffer 内存分配实现的替换是相对好的方式( 主要的毛病就是工作量太大 )。另外注意到 mmap 和 Java 创建的 DirectByteBuffer 这两种情况,由于难以将底层的内存分配替换成基于 V8 沙箱的实现,这里总是需要多出一次内存拷贝。
实际上,对于 Java 和 JS 共享内存的情况,我们可以通过基于 C++ 分配内存再构造 DirectByteBuffer 的方式( 而不是从 Java 创建 DirectByteBuffer 再拿到对应的内存地址 )来避免冗余的内存拷贝。区别在于这种方式将内存释放的职责由 ART 转移到了我们自己身上,但实际上本来共享内存的情况,我们天然就需要小心维护对象的生命周期,避免某一端预期外的内存自动释放导致另一端的行为异常。
一个比较有意思的异常现象是,在 V8 10.8 上,启用沙箱后,使用默认的 ArrayBuffer 内存分配器,在沙箱上分配一块长度为 0 的内存是被支持的,可以成功得到一个非空的指针,但释放这个指针则会崩溃。
这个表现和 V8 的当前实现强相关。具体来说,整个分配释放调用链路上,只有 DCHECK 来检查长度大于 0,所以在非 debug 版本上,不会提前 assert。分配时,由于整个沙箱的虚拟内存已经提前创建好,实际 V8 需要做的事情是找到空闲内存并创建好内部对应的内存管理结构( 刚好长度为 0 的情况也支持 ),并且 mprotect 给当前分配的内存页设置对应的权限,刚刚好,mprotect len 为 0 的情况返回的是 0( 成功 )( 从mprotect 系统调用的实现看,这个逻辑似乎一直如此 )orz,因此最终成功分配到了一个指向大小为 0 的非空指针。释放时,V8 内部通过指定地址( MAP_FIXED )重新 mmap 成不可访问内存的方式来释放物理页,而 mmap length 为 0 的情况返回的是 MAP_FAILED,最终触发了 V8 的 abort。
最后稍微提下 V8 沙箱模式的虚拟内存占用情况。
默认情况下,沙箱的大小为 1T,而 Android 端上,由于大部分设备用户空间的虚拟内存上限只有 512G( V8说的 ),沙箱的大小被设置为 128G,再加上前后两个 32G 的 guard region,实际在虚拟内存足够的情况,V8 的单个沙箱会占用 192G 的虚拟内存。这里为什么要说单个?实际在 Android 端上同个进程运行多个 V8 库的情况并不少见( 典型场景是 Chromium 加上独立的 V8,Chromium 中的 V8 以静态库形式存在,不存在运行时符号找错的问题 ),另外多个 V8 的情况在沙箱模式可能存在额外的性能损耗( 多个 V8 沙箱互相独立,无法做到共享内存 )。而虚拟内存不足的情况,V8 会舍弃 guard region,同时不断对沙箱大小砍半之后再尝试创建( 当然安全性有所降级 ),这里的下限是 8G,毕竟还要留 4G 给指针压缩用。
最后的最后,启用沙箱模式实际上会对 Android Chromium 上的内存统计存在影响。
此前的实现是,Chromium 通过给 V8 设置自定义的 PageAllocator 的方式接管了 V8 内部的内存分配,在 Android 上,通过 prctl 给对应的虚拟内存空间设置了名字 v8,我们可以基于这一特征统计 Chromium 中 V8 具体的内存占用。启用沙箱后,沙箱的内存分配不直接使用宿主设置的 PageAllocator,沙箱内的实际内存占用无法被统计。
V8 Sandbox 设计文档: docs.google.com/document/d/…
VS Code 适配 V8 Sandbox issue: github.com/microsoft/v…