资讯详情

资讯详情

V8 JIT类型混淆利用实战:DEFCON CTF 2022 Zeratool深度解析

这道题在 DEFCON CTF 2022 Quals 里算是一道标志性的浏览器漏洞利用题Zeratool 这个标题也很有意思听起来像某个内部工具代号实际考察的是 Chromium V8 引擎里 JIT 编译器相关的类型混淆漏洞。我把整个分析、调试和利用的过程完整记录在这里给打算入门或者已经深入浏览器利用方向的朋友一份可复现的参考。1. 题目环境与整体分析思路1.1 拿到题目后先做什么Zeratool 这道题和很多 Chromium 漏洞题类似给定的是一个修改过的 V8 源码仓库通常还有一个精心构造的 patch 文件以及一份题目描述。我拿到手里的是一份基于某次 V8 版本迭代的源码外加一个 diff这个 diff 就是漏洞本体。第一步不是急着看 diff而是先把环境跑起来。我习惯先编译一个带符号的 d8 调试版本因为后续调试 JIT 相关的崩溃时符号和内部对象信息能省下大量时间。d8 是 V8 自带的 JavaScript 引擎 shell可以直接执行 JS 文件也可以加各种内部参数比如--allow-natives-syntax暴露 V8 内部测试函数、--trace-turbo打印 TurboFan JIT 的编译图、--trace-opt跟踪优化过程。这些参数对理解漏洞是必需的。编译的时候有几个细节需要注意。第一用./tools/dev/gm.py x64.debug这种构建方式比直接make方便构建系统会自动处理大部分依赖。第二如果机器配置一般加一个-j4的编译参数不要一次开太多并行任务不然内存直接爆掉。第三题目给的源码版本有对应的 IME 或者 V8 revision不要去网上乱找第三方构建产物自己编译最靠谱。编译完成后我通常先跑一个简单的 JS 脚本验证 d8 能正常启动再把题目给的 exploit 模板或者 patch 相关文件导入工作目录。接下来才去看 diff。1.2 为什么选 JIT 方向这道题的核心方向是 JIT 类型混淆这几乎是浏览器利用领域里最有代表性的一类漏洞。V8 为了提升 JavaScript 执行性能引入了 TurboFan 这个优化编译器。TurboFan 通过收集运行时反馈Feedback Vector来猜测变量的类型然后基于这个猜测生成高度优化的机器码。问题在于如果某类操作在优化过程中对类型的处理存在逻辑漏洞猜测和现实就会脱节产生类型混淆。这类漏洞能做很多事把普通对象当作数组对象操作把整数数组当作浮点数组读写甚至能让 JS 引擎以为一个double就是一个对象指针。一旦建立起跨类型访问能力就相当于拿到了一个基础的读写原语接下来就是利用 V8 堆布局伪造对象最终获得任意代码执行。从 CTF 出题角度来说JIT 漏洞的考察点很集中一是对 V8 对象模型和优化流程的熟悉度二是对 TurboFan 运行时内建Runtime和 JIT 生成代码的理解。Zeratool 把这两点结合得比较深所以我觉得很有分享价值。1.3 工具链准备除了 d8我还准备了三个必须的工具缺一个都会难受gdb配合 V8 的脚本扩展可以直接查看 JS 对象结构比如p obj-map()-instance_type()查看对象类型。TurbolizerV8 自带的 TurboFan 图可视化工具用 Chromium 打开tools/turbolizer里的 HTML 页面可以把 JIT 编译中间图可视化方便观察类型推断在哪里出错。一份 V8 对象布局速查表V8 不同版本里Map、FixedArray、JSArray、PropertyArray的字段偏移会变没有速查表容易绕晕。工具准备好才开始正式的静态分析。2. JIT 类型混淆漏洞的核心原理2.1 从优化编译器的视角看 JavaScript 类型JavaScript 没有静态类型变量可以随时变成任何类型。V8 在运行一段热代码时会记录各种操作遇到过的对象“形状”Shape这个形状在 V8 内部叫Map。可以理解为一张动态的类型卡片记录了对象有哪些属性、属性顺序、元素类型、是否可扩展等信息。TurboFan 优化一段代码时会在图层面插入各种检查Check。最常见的是CheckMaps就是“如果对象的 Map 不是我预期的那个就退出去运行解释器”。如果代码路径里只有CheckMaps类型混淆很难发生因为每次变化都能被挡住。真正的风险点是“某个操作没有插入检查”或者“检查的范围太宽松把不应该兼容的类型也放行了”。比如某个内置方法的FeedbackNexus在调用过程中只记录了前几种 Map后续出现了新 Map代码仍然按旧的类型假设执行这就可能发生类型混淆。2.2 对象模型与 ElementsKindV8 里的数组有个重要属性叫ElementsKind描述容器里元素是怎么存储的。常见的有PACKED_SMI_ELEMENTS元素全是小整数32 位内部存储。PACKED_DOUBLE_ELEMENTS元素是浮点数存成 64 位 double。PACKED_ELEMENTS元素是普通对象指针。只有把ElementsKind理解成一个“类型状态机”才能真正读懂 JIT 漏洞。因为 JIT 生成的代码会根据ElementsKind决定如何读取元素内存。比如认为数组是DOUBLE_ELEMENTS就会把存储区域按 8 字节浮点数来读如果这个数组实际上已经被替换成了ELEMENTS的普通对象数组那么读取时就会把对象指针的低位当作浮点数高位去拼接造成数据混乱或者反过来让 JIT 误以为某个 double 的数值是一个对象的地址。我在分析 Zeratool 时发现漏洞的根源就藏在某个数组相关内建方法里它对ElementsKind的处理没有遵循“慢模式转换”的标准流程导致优化后产生了错误的读取语义。2.3 用生活类比解释类型混淆说起类型混淆我经常用一个类比你把钥匙寄存在前台前台记录上写着“寄存的是硬币”等你去取时前台把钥匙掏出来按硬币找给你你拿 5 毛硬币的开孔尺寸去拧锁自然全乱套了。V8 就是那个前台Map和ElementsKind就是它的“寄存记录”。更麻烦的是V8 前台不止一个窗口解释器、TurboFan、内建代码一个窗口记录正确另一个窗口记录出错最终拿出来的“钥匙”和“硬币”混在一起用就会把对象 A 的内存区域当成对象 B 来读写。3. Zeratool 漏洞根因分析3.1 静态阅读 Patch 文件拿到 diff 之后我习惯用git diff配合git log看修改的上下文。Zeratool 的 patch 不算大核心改动集中在一个与数组方法相关的 BUILTIN也就是 C 写的内建函数文件里。补丁的核心是把某个原本直接返回的ElementsKind改成经过规范化检查后的结果。具体来说原来的代码在判断数组是否为“快模式”时只做了很浅的检查没有考虑HOLEY_ELEMENTS或者带有Length访问器的情况。补丁新增了一个条件判断分支强制在某种情况下使用通用的慢路径。只看 diff 还不够因为很多逻辑在补丁之外。我选择回到 V8 源码把相关 BUILTIN 的完整实现全部啃了一遍理解修复前和修复后的行为差异。这一步很关键因为要写 exploit必须精确知道哪个 JS 操作序列能让错误路径被执行。3.2 触发路径的推理基于漏洞位置我推断触发点大概是这样一段逻辑先让某个数组以某种ElementsKind被 JIT 优化然后在一个内部方法里强制改变数组的ElementsKind但又没有让 JIT 重新做CheckMaps造成读出和写入的不一致。我自己构造了一个最小复现样本大致是这样function trigger(a, b) { let arr [1.1, 2.2, 3.3]; // 让 TurboFan 对 arr 的 DoubleElements 版本进行优化 for (let i 0; i 100000; i) { arr[0] a; } // 在某个特定调用中触发元素类型迁移 return arr[b]; } let obj {evil: 123}; let evilArr [obj]; trigger(evilArr, 0);当然这只是触发逻辑的骨架实际题目里的调用要绕一点需要先满足 JIT 的内联缓存Inline Cache记录让 TurboFan 已经“坚信”了错误的类型再从另一个入口制造冲突。3.3 类型推断断裂点通过给 d8 加上--trace-turbo参数把优化前后的图导出我用 Turbolizer 观察到了关键的推断点。在 TurboFan 生成的图中某个LoadField或者CheckMaps节点没有出现在数组中也就是说 JIT 认为类型不会变省略了一次运行时检查。正是这次省略让后续的LoadTypedElement读取使用了错误的元素类型。这里归纳出漏洞的三个必要条件存在一个可以改变对象类型/元素类型的 JS 操作且该操作在 TurboFan 看来是“纯类型不变”。同一函数里的后续访问没有重新验证类型。通过合理的调用次数让 TurboFan 把该函数标记为 hot code 并完成优化。4. 利用链构建从类型混淆到任意代码执行4.1 建立 addrof 和 fakeobj 原语拿到类型混淆读后第一步是建立两个基础原语这是几乎所以 V8 利用都绕不开的操作addrof(obj)给定一个 JS 对象获得它在内存中的指针地址。fakeobj(addr)给定一个内存地址构造一个指向该地址的假 JS 对象。在 Zeratool 里我的做法是让混淆后的数组同时具备“按浮点数读”和“按对象指针读”的能力。具体来说用一个小数组保存目标对象的引用让 JIT 按 double 方式读取它读到的高 32 位和低 32 位就拼出了地址。反过来如果想伪造对象就把一个 double 写进混合区域再让引擎当成对象指针来解析。有了这两个原语V8 的堆对我们来说就是半透明的了可以扫描对象、修改变量、构造假的对象结构。4.2 利用 ArrayBuffer 构造任意读写pwn 的最终目标通常是控制 PC。V8 利用里最常用的中转对象是ArrayBuffer尤其是它的backing_store指针字段。只要改掉这个指针让它指向任意地址JS 层对这个ArrayBuffer的访问就变成了对目标地址的读写。步骤大概是准备一个ArrayBuffer对象大小先设成 0x100。通过fakeobj构造一个指向该ArrayBuffer内部字段的假对象定位到backing_store指针的偏移。使用addrof计算出目标地址写入backing_store。之后用DataView对这个 buffer 进行读写就能读任意地址了。我在调试时遇到过backing_store写入失败的情况原因是对齐和指针压缩Pointer Compression问题。V8 在新版本里默认开启了指针压缩对象里存的是 32 位偏移不是完整 64 位地址。写 exploit 时不能直接把 64 位地址填进去要先做一次“压缩编码”。这个细节让我多花了一个晚上后面在常见问题里详细说。4.3 从任意读写到 getshell有了任意地址读写接下来需要找可以控制 RIP 的代码执行方式。Zeratool 的限制在于利用环境是一个独立 d8没有现代浏览器那种沙箱和进程隔离所以不需要绕过复杂的 sandbox目标是直接执行 shellcode。我用的路径是 WASM。创建一个 WebAssembly 实例V8 会为它分配一段可读可写可执行RWX的内存页。我通过任意读写找到 WASM 实例的函数表把入口地址改成 shellcode 地址然后调用 WASM 导出函数触发执行。这也是 CTF 里最常见、最稳定的控制流劫持思路。完整利用链可以概括为类型混淆漏洞泄漏对象地址建立addrof/fakeobj。伪造 ArrayBuffer改写backing_store建立任意地址读写。扫描堆内存定位 WASM 的 RWX 页。将 shellcode 写入该页并通过修改 WASM 函数入口跳转过去。5. 实操记录与调试心得5.1 关键调试参数整个分析过程里我依赖的参数组合是./d8 --allow-natives-syntax --trace-opt --trace-turbo --trace-deopt --print-opt-code --code-comments exp.js如果运行过程中出现Check failed或者段错误我会立刻用 gdb 跑一遍gdb --args ./d8 --allow-natives-syntax exp.jsV8 源码内置了gdbinit脚本加载后可以直接打印对象内部结构。比如p obj p *obj p obj-map() p obj-elements()这些命令能极大加速对崩溃原因的判断。5.2 复现过程中的两个大坑我在写 exploit 过程中遇到的第一个坑是ArrayBuffer的地址泄漏不稳定。原因是对象在堆上可能被 GC 移动导致保存的地址失效。解决办法是在完成漏洞利用之前关闭自动 GC或者尽量分配足够多的ArrayBuffer然后选择性的、最后再引用它让对象保持存活。更保险的做法是直接调用 V8 的内部函数%ArrayBufferDetach之前不触发任何 GC。第二个坑是系统随机化和 SHADOW 页的问题。d8 有自身的地址空间布局但不是强随机所以通过扫描 WASM 内存区域找到 RWX 页不算难。难的是在开启 ASLR 的情况下泄漏的地址中夹杂的符号位扩展导致写地址时少写了几位崩溃得莫名其妙。建议在写入backing_store时打印一次十六进制值人工确认高位和低位。5.3 JIT 优化后的代码断不了点怎么解决调试 JIT 生成代码和调试 C 代码不太一样。TurboFan 生成的机器码没有绑进 gdb 的符号里直接break是断不到的。我常用的办法是在 C 源码里用%SystemBreak或者通过TriggerBreak插入指令或者在生成的代码中查找特征字节%OptimizeFunctionOnNextCall(fn);然后再在fn入口处对应的地址下断点。另一个技巧是给 V8 打一个运行时的日志开关./d8 --print-opt-code --code-comments exp.js打印出的汇编代码里包含节点名和源码位置我可以先看汇编再反推断点地址。这些都是比较传统但有效的 JIT 调试办法。5.4 常见问题速查表下面是我在本次实操中整理出的一张速查表基本覆盖了调试过程中遇到的典型问题现象可能原因解决方式优化后运行直接Check failedTurboFan 省略了应有的 Map 检查用--trace-deopt确认是哪个 check查看图中是否缺失CheckMaps泄漏的地址全是 0xfff指针压缩未转换将地址按 V8 的 cage base 规则压缩后再写入伪造对象后触发段错误伪造的 Map 字段不对用p *obj对照正常对象逐个字段调整backing_store写进去无效ArrayBuffer可能被 GC 或 Detach确保在利用前引用住 buffer不调用任何 Detach 方法执行 WASM 函数时崩溃入口地址未对齐或 shellcode 跨页使用__builtin_align_up对齐到 4/8 字节shellcode 前补 NOP5.5 从崩溃到 root cause 的定位思路如果调试时遇到一个十分诡异的崩溃我的经验是先不猜直接记录崩溃时的上下文。V8 带符号时gdb 会给出当前 C 调用栈。JIT 代码崩溃时可以先打印$pc附近的反汇编对比--print-opt-code输出的机器码确定是哪一个节点出了问题。拿到节点 ID再回到 Turbolizer 看它的输入边就能准确找到是哪个 Load 或 Store 的类型边界错了。整个过程很像做法医鉴定保持冷静、一步步缩小范围比反复瞎试更有效率。6. 实战经验总结与后续扩展6.1 关于二进制利用的长期技能沉淀Zeratool 这道题做完给我留下最深印象的不是某个具体技巧而是对“对象模型”本身的理解加深了。V8 里所有对象本质都是一块内存加上一个指针所谓类型就是这套“内存-指针”契约的一整套解释规则。类型混淆攻击的核心就是打破契约的一方让另一方的期望落空。我建议做类似题目的朋友一定要花时间把 V8 的Map、ElementsKind、Feedback Vector这三块内容彻底搞透。每打一类 JIT 漏洞都可以沉淀成自己的知识框架。等你做完十几道同类题目看到CheckMaps缺失、ElementsKind转换、LoadTypedElement节点几乎不用思考就知道该在哪里做类型混淆、该在哪里打断点。6.2 从 CTF 到真实 CVE 的迁移思路CTF 题模拟的漏洞有很多是和真实 CVE 对应的。比如历史上 Chrome 的某次远程代码执行漏洞就是由于数组方法没有正确更新ElementsKind导致优化器错误推断类型。Zeratool 的练手价值在于让你习惯真实源码补丁的分析流程。要往真实方向进阶可以试着把题目 patch 从补丁中提取出漏洞版本自己写 PoC再验证修复版本是否失效。这个过程非常像真实的漏洞研究流程分析 diff、定位 root cause、写 PoC、构造利用链。6.3 给初学者的路线建议如果你还没有做过类似的题我不建议直接硬啃 Zeratool。先按这个路线来理解 V8 对象结构写一个小脚本打印不同对象的Map和ElementsKind。学习将double数组篡改为对象数组的标准手法实现自己的addrof。尝试利用Uint32Array的越界读写构造任意地址读写。熟悉 TurboFan 的优化日志和 Turbolizer 图形。最后再挑战隐晦一点的 JIT 类型混淆题目。这条路走下来慢的话两三周快的话一个礼拜就会对浏览器利用有整体感知。之后再回过头做一些硬核 JIT 漏洞题你会发现很多原来觉得天书的内容其实是一套固定套路。6.4 一个屡试不爽的小技巧最后分享一个小技巧也是我这次实弹中反复使用的在调试 JIT 利用时尽量把漏洞触发代码和后续利用代码拆成两个文件。触发文件只负责把类型混淆变成原语利用文件里再写读写和执行逻辑。这样你每次微调利用代码时不需要重新优化和触发漏洞减少了不少不确定性。还有一个血泪经验在写 PoC 的时候多打印、多断言别嫌麻烦。例如在改写backing_store后立刻检查一下递归的读写访问是否正常。哪怕多花十分钟也能避免在终点线前翻车。Zeratool 是一道非常扎实的 JIT 类型混淆题各位如果有机会建议从头到尾自己手动做一遍不要只看 writeup。亲自从责备 patch 里找到那个小小的条件分支再亲手把它变成 shellcode 执行这个过程中的成就感才是这类题目最有魅力的地方。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →