感觉浏览器是最复杂的用户态程序之一,而 JS 引擎又是浏览器中最复杂的组件之一。
故在比赛中受挫后下定决心研究一下 V8,也许这会成为我以后的研究方向😋。
浏览器一直是安全届的焦点之一,包括 Google 在内的大厂也为相关漏洞开出了高额的赏金。我 22 年尝试给 Hackergame 出一道「有趣」又「与众不同」的二进制题目时关注到 V8,23 年一次旅途中刷到了天府杯、V8CTF 等赛事给相关漏洞开出数十万美元的奖金,感受到一些震撼并决定深入学习一下浏览器安全。
前置知识
现代浏览器采用多进程架构,通过 IPC(进程间通信)协作,不同的进程负责管理不同的内容。开源的 Chromium 架构是现在大部分浏览器的基础(Chrome 在其基础上加了自动更新、媒体编解码器、PDF 阅读器等额外组件),下面示意图中展现了 Chromium 的架构:
选择 JavaScript 引擎作为浏览器安全研究的切入点,因为它是所有主流浏览器中的核心技术,提供解释运行任意 JS 代码的能力暴露出了广阔的攻击面。JavaScript 带来丰富网页特效、功能的同时也给浏览器运行效率带来了很大开销,各大厂商都在引入诸如 JIT 等技术来加速 JS 代码的执行。而众所周知效率和安全往往是相违背的,有许多严重的浏览器漏洞都源自于 JS 引擎中的缺陷。
开源的 V8 引擎是 Chromium 的 JavaScript 引擎,同时也是 Node.js 的核心,下面列举了一些主流浏览器的 JS Engine:
其中 Chromium 内核的市场占有率一骑绝尘,原因如下(Refer to CefDetector):
执行流程与 JIT 优化
最初的 JS 引擎主要是解释执行器,但由于纯解释执行效率低下,现代 JS 引擎已经变成了相当复杂的程序,基本执行流程可以大致分为 AST 分析、引擎执行两个步骤:
- JS 源码通过 parser(分析器)转化为 AST(抽象语法树),再经过 interpreter(解释器)解析为 bytecode(字节码)
- 为了提高运行效率,optimizing compiler(优化编辑器)负责生成 optimized code(优化后的机器码)
可以把重点放在 AST 之后,其中优化的矛盾点在于:JS 代码可以在 字节码 或者优化后的 机器码 状态下执行,而生成字节码速度很 快,生成机器码就要 慢 一些。
上述优化思路具体到 V8 引擎中也是一致的,不过命名方式有所区别:
有趣的是 V8 Engine 也有汽车引擎的意思,V8 发动机是内燃机汽车历史上浓墨重彩的一笔。而 V8 中 interpreter 过程称为 Ignition(点火),Optimized Compiler 称为 TurboFan(涡扇):
- 解析:V8 首先读取 JavaScript 代码,并将其解析成一个抽象语法树(AST)。这个阶段包括词法分析(将输入的字符流转换成标记或令牌)和语法分析(根据语言的语法规则构建 AST)。
- 字节码生成:接下来,V8 的 Ignition 解释器会将 AST 转换成 Bytecode(字节码)。字节码是一种低级的、与机器码相比更接近源代码的中间表示形式,它被设计来快速执行而非直接在硬件上运行。
- 执行:V8 使用内置的 Ignition 虚拟机来执行这些字节码。虚拟机通过解释执行字节码来初步运行 JavaScript 代码。
- 优化:在执行的同时,V8 会监视代码的运行性能,特定的代码块如果被频繁执行(热点代码),则会被另一个编译器 TurboFan 提取出来并进行优化。TurboFan 会将这些字节码编译成高度优化的机器码。这个过程称为即时编译(JIT)。
- 优化后的执行:编译成机器码的代码将直接由 CPU 执行,这大大提高了执行速度。如果后续发现优化基于的假设不再有效(如类型变化等),V8 可以废除这些优化(deoptimization)并回退到字节码执行,再次观察并优化。
从 Chrome M117 版本开始,V8 引入了一个新的优化编译器 Maglev,位于 Ignition 和 TurboFan 之间。Maglev 能够比 Ignition 更快地生成优化代码,虽然优化程度不如 TurboFan。这使得 V8 可以在函数变热之前就开始优化,同时给 TurboFan 更多时间生成高度优化的代码。Maglev 大大提高了 JavaScript 的执行速度。
v8 会记录下某条语法树的执行次数,当 v8 发现某条语法树执行次数超过一定阀值后,就会将这段语法树直接转换为机器码。
后续再调用这条 js 语句时,v8 会直接调用这条语法树对应的机器码,而不用再转换为 ByteCode 字节码,这样就大大加快了执行速度。
对象结构
V8 中的 JS 对象结构基本符合下面描述:
- map:定义了如何访问对象
- prototype:对象的原型(如果有)
- elements:对象元素的地址
- length:长度
- properties:属性,存有map和length
其中,elements 也是个对象(指向数组对象具体内容的指针),即 v8 先申请了一块内存存储元素内容,然后申请了一块内存存储这个数组的对象结构,对象中的 elements 指向了存储元素内容的内存地址。
在没有开启指针压缩的情况下,对象的内存布局如下(可以结合下面的例题):
- 即对于
FixedDoubleArray
类型的对象,内存布局如下:
V8 Pwn Cheatsheet
接下来把关注点放到题目及其利用上:
Installation
Chrome 中 JavaScript 的解释器被称为 V8,下载的 V8 源码经过编译后得到可执行文件 d8,而 d8 往往又分为 debug
和 release
版本。
本地编译用于调试的可执行文件 d8:
通常作为攻击者,希望编译时附带调试信息却又没有额外的检查,但是默认生成的 debug 配置文件 out.gn/x64.debug/args.gn
中会包含 v8_enable_slow_dchecks = true
,可以将其改为 false
以免影响攻击。
Patch
题目一般会给出有漏洞版本的 commit-id
,因此编译之前需要把源码版本先 patch 到目标版本:
Debug
在 ./v8/tools/gdbinit
中提供了便于调试 V8 的 gdb 脚本,主要提供了 job
指令来根据地址查看对象。
调试时需要打开 allow-natives-syntax
选项:
Some Tips for GDB
telescope [addr] [length]
job [addr]
V8 在内存中只有数字和对象两种数据结构的表示,为了区分,内存地址最低位是 1 则表示该地址上的数据结构是对象。
即指针标记机制,用来区分指针、双精度数、SMI(immediate small integer)。
- Double: Shown as the 64-bit binary representation without any changes
- Smi: Represented as value << 32, i.e.
0xdeadbeef
is represented as 0xdeadbeef00000000
- Pointers: Represented as addr∣1, i.e.
0x2233ad9c2ed8
is represented as 0x2233ad9c2ed9
即 Double 类型在 v8 的内存中能保持原始数据,故利用过程中的任意地址读 / 写通常倾向于通过浮点数实现。
JavaScript
%DebugPrint(obj);
%SystemBreak();
- 触发调试器中断,允许开发者使用调试工具(如 Chrome 开发者工具、Node.js 的调试器、或者 GDB)来检查当前的调用堆栈、变量、内存状态等
V8 PWN 的基本思路
基于上面对 JS 对象结构的分析可以意识到在 V8 中类型混淆是很容易通过 OOB 之类的漏洞触发的,因为引擎对对象类型的判断完全取决于对象结构中 Map 域的标识,修改 map 造成类型混淆后又可以构造任意地址读写:
graph TD;
漏洞-->越界读写;
越界读写-->泄漏map;
越界读写-->篡改map;
泄漏map-->类型混淆;
篡改map-->类型混淆;
类型混淆-->任意地址读写;
任意地址读写-->写入shellcode;
有如下常见漏洞点:
- JS code exectution:
- Type Confusions
- UaFs
- OOB Accesses
- Wasm:
- Incorrect parsing
- Signature mismatch
- JIT Compilation:
- GC & Memory Management:
- Heap corruption
- Incorrect memory handling
- DOM Interaction:
- Execution stages & optimization pipeline:
- Structural optimization errors
- Sandbox violations/SBX
例题 0x00:starCTF2019-OOB
这道题也算是 V8 题目中比较经典的例题了, 题目附件: starctf2019-pwn-OOB
这里有一点需要注意的是,我们现在编译的 debug 版本调用 obj.oob()
时会触发异常退出,因此只能在 release 版本下进行利用,debug 版本下调试帮助理解 JavaScript 对象结构。
漏洞分析
题目的漏洞点体现在 oob.diff
文件中,通过参数数量的不同分别提供了越界读和越界写的功能:
即无论是读还是写,oob 方法都索引到了 elements[length]
的位置,造成了数组越界漏洞。
在具体利用时,还是遵循着常规 pwn 题目的基本思路:
漏洞
-> 类型混淆
-> 任意地址读写
-> 泄露相关地址
-> shellcode || hook_hijacking
开始之前 - 一些辅助函数
先来看几个类型转换的辅助函数:
接下来是利用 oob() 实现类型混淆的思路:
- 首先需要明白:JavaScript 中对于对象(对象结构的复习)的解析依赖于
map
:map 指向 <Map(PACKED_ELEMENTS)>
时 elements 中元素就会按照 obj 来解析,其他类型同理;
- 而
oob()
不带参数(args.at<Object>(0)
永远是 self),就可以输出 elements[length]
,oob(data)
就可以在 elements[length]
写入 data;
- array 的 elements 也是对象,在内存结构中,往往体现为:elements 紧挨着 array,即:
elements[length]
的位置上就是 array 的 map
;
- 因此可以考虑先读出 map,再在另一种 array 的 map 处写入,即实现了类型混淆。
利用漏洞 - 获得地址与伪造对象的能力
这样一来,我们就可以开始考虑构造任意地址写了,思路如下:
- 首先,在 JavaScript 中浮点数在内存中是直接存储的,因此伪造
float_array
是比较合适的;
- 目标是通过在
evil_float_array
这个对象的 elements
的基础上使用 get_obj()
函数构建假的float_array
;
- 如此一来,当访问到
fake_array[0]
的时候,实际上会根据其 map 设定的访问规则,最终访问到 target_addr+10
也是 evil_float_array[2]
的位置上。
因此就可以构造出如下 poc:
可以验证在输出 fake_obj
时显示为 <JSArray[128]>
类型,进一步就可以在 fake_obj
的基础上获得任意地址读写的能力:
受限的任意读写
但是上面使用 FloatArray 进行写入的时候,在目标地址高位是 0x7f 等情况下,会出现低 18 位被置零的现象,可以通过 ArrayBuffer 的利用来解决(这也是绕过没有沙盒的指针压缩的常见思路,因为 ArrayBuffer 的储存空间使用 PartitionAlloc 分配,位于 v8 堆之外的单独内存区域中):
更强的任意读写 - ArrayBuffer
DataView(ArrayBuffer)
对象中的有如下指针关系:
- ArrayBuffer 对象用来表示通用的、固定长度的原始二进制数据缓冲区;
- 但是 ArrayBuffer 不能直接操作,需要通过 DataView 对象来提供读写多种数据类型的底层接口,因此不需要考虑字节序等问题;
- 利用时可以考虑
DataView -> buffer -> backing_store -> 存储内容
;
- 即
backing_store
指针指向了 ArrayBuffer 真正的内存地址;
改进如下:
最终利用 - 通过 WasmInstance 写 shellcode
现在获得了任意地址读写,最直接的思路就是:
- 构造任意地址读写原语
- 构造 WASM 实例
- 读 rwx 空间地址
- 写 shellcode
- 调用 WASM 函数执行 shellcode
上述思路是最直接的 v8 利用思路,但是也存在指针压缩、v8 沙箱等情况,这时候可以考虑:
- 通过 JSFunction 的 JIT 优化机制,使用立即数写 shellcode
- 利用 WasmInstance 的全局变量
imported_mutable_globals
- 篡改 MemoryChunk 使 JIT function 的 W^X 失效
但是实施起来还需要结合调试定位 RWX 内存的具体地址,是通过定位 wasm_instance + 偏移
获得的。
此外 wasm_code
的内容其实无所谓,只要去 WasmFiddle 上用含有 main 函数的 C 语言生成一段字节码就可以了,这只是为了申请 rwx 空间并保留对其的函数引用,和 wasm 代码功能无关:
最后就是用任意写的能力把 shellcode 到 rwx 内存中,下面给出 shellcode 的生成方式:
完整利用
最后整体利用代码如下:
至此题目已经完成了,通过这道入门题目了解了 V8 的调试方式、对象结构与基本利用思路。
例题 0x01:X-NUCA 2020 - babyV8
这里看第二道例题,虽然同样是直接的 OOB 类型漏洞,但是这题开启了指针压缩,可以举一反三尝试一下刚刚学到的利用思路:
漏洞分析
分析题目 patch 的函数可以发现:
BuildAppendJSArray
函数先获取了原数组的 length 和 elements;
- 判断原数组 elements(即 FixedArray)的容量,若不足则调整容量;
- 将参数用 ForEach 循环依次存入新的 elements 中;
- 原先会将
var_length
,即 index 递增,经过 patch 过后会直接增加 3;
- 结束循环后设置
array.length
并返回。
其中「增加容量」和「递增 index 并最终设置 array.length
」二者的操作是通过不同的方法计算出来的,而题目中 patch 了后者的计算逻辑,使得最终的 array.length
可以超过调整出来 elements 的容量,就直接导致了 OOB。
POC
来到具体利用,还需要明确如何触发漏洞:
BuildAppendJSArray
函数的参数应该是多大的数组才能导致 OOB?
- 首先跟踪到
PossiblyGrowElementsCapacity
函数,找到其中调用的 CalculateNewElementsCapacity
,定位到返回长度的计算方式为:1.5×arg+Padding
- 经过测试,Double 类型 Array 的 Padding 值为 0x10
其次就是怎么调用漏洞函数,同样是找交叉引用发现 ArrayPrototypePush 中存在相关调用,故可以写出如下 POC:
运行后可以发现长度为 37 的数组的元素容量却只有 35:
若继续向数组里 push 浮点数,则会触发 OOM crash,因此最多只能获得 16 bytes 的 oob,故这里可以结合调试构造更强的 POC:
即在原 OOB array 的后面再构造一个 Double Array,注意在声明时采用了 new Array
的写法,是因为这样可以保证其 elements 在数组对象的内存之后,于是形成了如下内存布局:
在这种构造下,就可以在获得 double 类型 Array 的 map 与 prototype 的同时篡改新数组的 size 从而获得更大的 OOB,因此上面那段 poc 运行后会输出 evil.length = 2048
。
受限的任意地址读写
在上面构造了超大范围 oob 的情况下,就可以用很多种方法、很轻松地实现任意地址读写,注意由于 elem 指针实际指在一个 FixedArray 对象上,因此在篡改 elem 指针的时候要注意把地址减 8 才能成功读写目标位置的数据:
之所以说是受限的,是因为开启指针压缩后,只能改到实际地址的后 32 位,前 32 位存在 $r13
寄存器内。
接下来就需要考虑怎么完成最终利用,即执行 shellcode:
常规方法 - ArrayBuffer + WasmInstance
正如之前所说的,在没开启沙箱的情况下 ArrayBuffer 位于 v8 堆之外的单独内存区域,因此在调用时也保留了完整的 64 位地址,因此也可以继续使用上一道例题的思路完成利用:
利用 JIT 优化后的函数获得 RWX 内存
这是一个很有趣的技巧,虽然在最新版本控制新分配堆空间的 flag 位置已经不在 V8 堆上了,但也值得学习一下:
- 定位
write_protect_code_memory_
变量位置(在题目版本源码下):
首先定位到设置该变量的代码位置,这个布尔值在 /v8/new_v8/v8/src/heap/heap.h:2037
中被声明为 false,在 /v8/new_v8/v8/src/heap/heap.cc:5377
中被使用,故可以把断点打在 heap.cc:5377
上:
在当前的命名空间下,就可以直接用 p &write_protect_code_memory_
指令找到该标志位的地址,计算出它在 V8 堆上的相对偏移,我本地得到的偏移是 0x9a08
。
接下来仅需构造:
此时就已经获得了 RWX 权限的 JIT 内存,再用任意读写去获得 jit_func
地址就能根据偏移算出 JIT 内存的位置,最终实现利用。
不过这里经过 JIT 优化的过程中也触发了 gc,弄乱了原先的内存布局,因此需要重新构造任意读写。
这篇博客作为 V8 漏洞利用的入门至此就结束了,后面会结合 GC、JIT(turbofan / maglev)、wasm、SBX 等不同知识点进行更深入的学习。
References
- Exploiting CVE-2021-21225 and disabling W^X . tiszka
- v8 pwn入门篇利用合集 . XiaozaYa
- Chrome v8 pwn . sky123
- V8 沙箱绕过 . Jayl1n
- Chaos me JavaScript V8 . TokameinE