CVE-2026-6307:漏掉一次签名比较,Wasm 返回类型错乱
一句话结论:V8 的 TurboFan 编译器在做公共子表达式消除、合并「帧状态」时,负责判等的函数逐项比较了基类的八个字段,唯独漏掉派生类独有的第九项——Wasm 签名指针。于是两条「返回类型不同的 JS 到 Wasm 调用点」被判定为同一份帧状态、归并成一份;当被内联的 JS 函数随后发生一次惰性反优化,反优化器按那份存活的、可能属于另一条函数的签名去解释返回寄存器里的位模式——对象地址被读成整数、整数被当成对象指针。这是 CWE-843 类型混淆,是 V8 堆沙箱内利用链的常备一环。本文完整保留成因、补丁审计与检测,不复述可直接照抄的触发模块与逐步复现。
排版说明:正文按「现象 / 成因 / 归并后果 / 类型标注 / 物化两解 / 前提与危害 / 补丁 / 检测缓解 / 延伸」组织;引号统一用直角引号,字段名、类型记号(externref、i64、kRef、kI64)与源文件名按原样书写,不含任何转义符。
一、现象:一行属性读取里的两条调用点
一个只做属性读取的极短函数,只要它访问的属性在两条原型链上各有一个 getter、而这两个 getter 分别指向一个 Wasm 导出函数,编译器内联后就会在同一个函数里留下两条 JS 到 Wasm 的调用点。两个导出函数的参数签名可以完全相同,返回类型却不同:一个返回引用类型、一个返回 64 位整数。V8 在合并编译器中间状态时,把这两条调用点各自携带的帧状态判成了同一份——两份里只有一份的签名活了下来。
二、成因:逐字段判等漏掉了派生类多出来的那一项
真正的身份藏在派生类里。基类持有与 JS 帧状态相关的八项信息(类型、提升帧大小、Wasm 函数索引、参数个数、最大参数个数、局部变量个数、共享信息、字节码数组),签名是第九项,也是唯一能区分「返回引用」与「返回整数」这两个函数的字段。两个 Wasm 函数在前八项上完全重合,差异全部压在这一个指针上。
判等函数只做了几件事,其中一件直接转调那个「逐项比较基类八字段」的比较——它压根没有比较派生类独有的第九项。于是两份帧状态在编译器眼里完全一样:类型同为同一个延续帧标记、函数信息按八项判定相等、反优化标识指向同一偏移、状态合并策略同为忽略。哈希函数走的是同一套比较,连哈希值都一致。
三、归并后果:第二份签名连同返回类型一起被丢掉
公共子表达式消除与全局值编号见到第二份帧状态时不会犹豫:直接复用第一份节点,第二份签名连同它携带的返回类型一起被丢掉。保留哪一份,由节点表里谁先被登记决定——先出现的存活、后来的被归并。这一点很关键:它意味着同一套机制能往两个相反的方向走,只取决于预热顺序。
四、错误的签名如何一路变成返回类型标注
签名最初是对的:内联 Wasm 调用时,编译器把真实签名写进派生类对象、并把帧状态的字节码偏移固定指向惰性反优化。错位发生在归并之后。指令选择阶段按帧状态类型分派,构造描述符时把「函数信息里的签名」传下去——而被归并掉的那份,其函数信息指向的是存活的那一份,取出的自然是另一条函数的签名。描述符把它折算成一个返回类型标注,规则很短、只看签名的返回个数与返回类型,结果只可能落在「引用」与「64 位整数」两值之一,而这一刻它是错的。这个错误标注被写进翻译表、一路传下去,反优化时按同一个操作码读回来,取到的还是那个错误标注。编译器内部各环节彼此一致,一致的依据却是一个错误的签名。
五、位模式进入返回槽之后的两种解释
真正动手的是反优化器。两条分支共用同一个物理返回寄存器:当作 64 位整数解释时,它把寄存器里的位当作未加标记的整数;当作引用解释时,它把它当作带标记指针。位模式一个字都没变,变的只是对它的解释方式。JS 到 Wasm 的延续帧会把自己算出来的值直接塞进返回槽,这个值要等到真正需要物化时才变成堆对象、而种类在构造时已经钉死。
于是两种解释的差别在物化阶段定型:一个对象引用被按整数物化时,那个带标记指针的整数位模式被原封不动包成大整数——堆地址就这样漏出去(所谓 addrof);反过来,一个整数位模式被按引用物化时,它直接成为垃圾回收器眼中的对象指针(所谓 fakeobj)。这就是沙箱内利用链的两块基石,且单点触发即可完成对象引用与整数位模式的互转。必须强调:整条链上没有一次越界访问、也没有一处校验缺失,只有一次「本该判不等却判成了相等」的等价判定。
六、触发前提与危害
触发前提:一个 JS 函数内联了两条返回类型不同的 Wasm 调用点;且惰性反优化发生在 Wasm 调用返回之后——只有在调用返回之后才会走到「为延续帧填充返回槽」的分支,若在此之前反优化、延续帧根本不会建立。危害定性:远程攻击者用一个构造的网页即可在 V8 堆沙箱内实现任意代码执行所需的原语,它本身是类型混淆、不直接等于逃逸,而是逃逸链的常备一环。影响范围自一个较早的大版本起、直到某个修复小版本之前的所有平台构建,且下游基于同一引擎的浏览器同样受影响。特别要注意:自带该引擎的桌面运行时与服务端运行时(如各类内嵌 V8 的环境)必须按宿主实际内嵌的引擎版本单独判定——「浏览器已升级」不等于宿主已修复。
七、补丁的三处改动
修复落在三个文件、核心三处,各有分工。其一,新增一条编译期断言、把「派生类比基类多一个字段」这一事实钉成尺寸约束,此后任何人再给派生类加字段都会在编译期被拦下——它防的正是「加字段却忘了同步判等」这类反复出现的疏漏。其二,在判等函数里针对这一类帧状态补上签名比较,让两个不同签名的帧状态不再判等、也就无从被归并。其三,删除基类的拷贝构造与拷贝赋值,堵的是另一条路:即使比较写对了,经基类引用做拷贝时派生字段仍可能被切掉。补丁注释把因果链直接写进了代码——否则公共子表达式消除会归并它们、反优化器会用错的签名去物化延续帧。这是一处根本修复:它动的只是物化阶段的类型分派依据,内联判定与反优化触发时机都没被动。修复版上同一条路径照样被走到、惰性反优化仍在同一位置发生,唯一变化是返回槽按正确的返回签名分派物化,不再产生类型错乱。
八、检测与缓解
处置顺序是升级优先、能力降级其次:升级到修复小版本即可,实测不再命中,命中与修复的分界落在两个相邻小版本之间。对检测方有一条重要启示:判定不能靠「有没有走到反优化」这类路径特征来做——修复前后都会走到同一个位置,能用作判据的只有返回值本身的类型。可迁移的检测方法是:在测试集里构造「同一函数内联两个返回类型不同的调用点、在调用返回之后触发一次惰性反优化、再检查返回值的语言层类型」,若返回值类型发生了错乱、或本应是对象的值变成了一个超大整数,即命中。
九、延伸思考:可模板化的审计问题
这个漏洞的根因是一个非常通用的反模式:为「带继承的数据结构」手写逐字段判等或哈希时,只覆盖了基类字段、漏掉了派生类新增字段——而派生类那个被漏掉的字段,往往恰恰是唯一承载语义差异的字段。可迁移的审计问题是:凡是手写「相等判断、哈希、拷贝」这三类会随数据结构演进而必须同步的地方,都要问一句「这个类型有没有派生层、新增字段有没有被这三处同时覆盖」。补丁里的编译期尺寸断言,本质就是把「必须同步」从口头约定变成机器可检查的约束——这是防这一类疏漏最省力的工程手段,值得推广到任何「加字段就得同步若干处」的代码约定上。另一条通用教训关乎即时编译的安全语义:当「编译器内部各环节彼此一致」建立在一份被归并的元数据上时,一致本身不再可信——类型混淆往往不来自某处漏了检查,而来自一处「本该不等却判成相等」的归并,把错误的类型标注传给了下游唯一真正执行解释的那一步。