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