fyr3p4w Blog
  • Introduction
  • Other Activities
    • Global Cybersecurity Camp (GCC) Taiwan 2025
  • CTFs
    • ASEAN Cyber Shield 2024
    • Wargames MY 2024
    • HTB Cyber Apocalypse 2024
    • FSIIECTF 2024
    • HTB CyberApocalypse 2025
    • TsukuCTF 2025 - Easy Kernel
  • Browser Exploitation Notes
Powered by GitBook
On this page
  • Challenge Files
  • Additional Knowledge
  • Goal
  • Objects in v8
  • Methods of Getting RCE

Browser Exploitation Notes

After finishing my FYP Part 1, I finally have some free time to learn browser v8 exploitaiton based on the UTCTF challenge, E-Corp Part 2

PreviousTsukuCTF 2025 - Easy Kernel

Last updated 1 month ago

Challenge Files

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 are given a server file which asks for the size and content of our exploit. It will then call d8 with our exploit.

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"]

Dockerfile of the challenge

We are also given a d8 file, which is the standalone binary of the JavaScript engine V8. V8 is the JavaScript engine of Google Chrome.

args.gn
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

The args.gn file is a list of arguments that will be used by d8. We can see that the V8 sandbox is disabled. So we don't need to escape the sandbox.

patch
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(

You're looking at a custom patch applied to the V8 engine for UTCTF 2025, which introduces a new built-in function called Array.prototype.confuse which is defined in builtins-array.c.

Additional Knowledge

Pointer Tagging

Pointer tagging is a mechanism used to efficiently store and differentiate between different types of data, such as pointers, immediate small integers (Smis), and doubles.

  • Doubles - actual 64 bit representation without any changes

  • Pointers - points to objects, the least significant bit is set to 1 (eg: 0x5678 is represented as 0x5679)

  • SMI - integer that is bitshifted left by 1 bit when in memory (eg: 3 will be stored as 0b110)

Pointer Compression

Typically, pointers are 8 bytes and takes up alot of memory. In pointer compression, the actual pointer values can be represented in 4 bytes. These 4 bytes will store the offset of the object. To calculate the actual address, we can do base_addr + offsetto get the actual address. Example :

Base address = 0x00007fff_00000000

Offset = 0xdeadbeef

Actual address = base + offset = 0x7fff_deadbeef

Helpful Functions

helpful_functions.js
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);
}

Any address leaks that you get is in double precision floating point since their 64 bit representation is not modified. We would need these js functions to parse them

helpful_functions_2.js
%DebugPrint();
%SystemBreak();

These DebugPrint() native JS functions that can give you extra information

Debugging

debug.py
from pwn import *

exe = "./d8"
elf = context.binary = ELF(exe, checksec=True)
context.clear(arch="amd64")
context.log_level = "info"

gdbscript = """
c
"""

p = gdb.debug([exe, '--allow-natives-syntax', '/home/kali/CTF/UTCTF2025/pwn/d8/poc.js'], gdbscript=gdbscript)
# p = process([exe, '--allow-natives-syntax', '/home/kali/CTF/UTCTF2025/pwn/d8/poc.js'])

p.interactive()

Goal

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.

Objects in v8

let a = ["hi", "bye"];

Lets create object a and view it in memory

We can see our JSArray at 0x227c00042b10

In memory, it stores pointers as 32 bit values due to pointer compression. The first value 0x14b8ed corresponds to a MAP object at 0x227c0014b8ec. Next, 0x725 is our properties. Then, 0x15354d is our elements pointer. Finally, 0x00000004is the length of our object bitshifted to the left once (original value would be 2).

In V8, every JavaScript object, including arrays, is linked to a Map object. The Map in V8 contains metadata and layout information about the structure of the object. It's essentially the blueprint of how the object is structured and how it should behave.

(remote) gef➤  x/4gx 0x227c0015354d-1
0x227c0015354c: 0x000000040000065d      0x001534ad0015349d

Elements refer to the individual items or values that are stored within the array

Methods of Getting RCE

WASM to RCE

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

We can create a sample wasm file like such and then compile it using wat2wasm from the web assembly toolkit.

Then we can use xxd to extract the bytes

const wasmCode = new Uint8Array([...]); // minimal wasm module
const wasmModule = new WebAssembly.Module(wasmCode);
const wasmInstance = new WebAssembly.Instance(wasmModule);
const f = wasmInstance.exports.func;

With the bytes we have, we can put it in the Uint8Array

Now theres a pointer to trusted data

JIT to RCE

In V8 (the JavaScript engine), when a function is called many times, it becomes "hot" and the engine tries to speed it up by compiling it using a Just-In-Time (JIT) compiler like Maglev or Turbofan. This compiled code is stored in memory that can be executed (RWX: Read, Write, Execute). So, if you make a function that returns raw bytes (your shellcode) and force V8 to compile it by calling it many times, V8 will place that compiled code — including your shellcode — in executable memory. Then, with a vulnerability, you can hijack the control flow to jump to that memory and run the shellcode, leading to remote code execution (RCE).

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

This function will be placed in a RWX memory region which we will abuse for RCE.

Now, there is a new RWX memory allocated. However, this method did not work for this challenge as the CPU has a feature called which prevents writing to that new memory region.

pkey
d8
object a
JSArray
Map object
Pointer to elements array