
CVE-2018-4280: iOS 11.2.6의 launchd에서 발견된 Mach port 교체 취약점으로 샌드박스 탈출, 권한 상승, 코드 서명 우회가 가능합니다.
Blanket은 iOS 11.2.6을 대상으로 하는 샌드박스 탈출 도구이며, 주요 취약점은 iOS 11.4.1에서만 패치되었습니다. 이 도구는 launchd의 Mach 포트 교체 취약점(CVE-2018-4280)과 다른 서비스들의 여러 가지 사소한 취약점을 악용하여, 샌드박스가 적용되지 않고 root로 실행되며 task_for_pid-allow 자격을 가진 ReportCrash 프로세스 내에서 코드를 실행합니다. 이를 통해 amfid와 같은 보안에 중요한 프로세스를 포함하여 폰에서 실행 중인 모든 프로세스에 대한 전면적인 제어가 가능해집니다.
이 익스플로잇은 여러 단계로 구성됩니다. 이 README에서는 주요 취약점과 샌드박스 탈출의 각 단계를 단계별로 설명합니다.
iOS의 크래시 리포팅을 조사하던 중, 저는 launchd에서 Mach 포트 교체 취약점을 발견했습니다. 특정한 방식으로 크래시가 발생하면, 프로세스는 커널이 launchd에 Mach 메시지를 보내도록 만들 수 있으며, 이로 인해 launchd는 자신의 IPC 네임스페이스에 있는 Mach 포트에 대한 send right를 과도하게 해제(over-deallocate)하게 됩니다. 이를 통해 공격자는 시스템의 나머지 부분에 대해 조회할 수 있는 모든 launchd 서비스를 가장할 수 있으며, 권한 상승으로 이어질 수 있는 다양한 경로가 열립니다.
이 취약점은 macOS에도 존재하지만, iOS에서 이 취약점을 트리거하는 것은 더 어렵습니다. launchd에는 Mach 예외 메시지가 커널로부터 온 것인지 확인하는 검사가 있기 때문입니다.
launchd는 자체 메인 포트에서 여러 가지 Mach 메시지 핸들러를 다중화하며, 여기에는 예외 메시지용 MIG 핸들러도 포함됩니다. 프로세스가 자신의 bootstrap 포트로 mach_exception_raise 또는 mach_exception_raise_state_identity 메시지를 보내면, launchd는 그 메시지를 호스트 수준 예외로 수신하여 처리합니다.
불행히도 launchd의 이러한 메시지 처리는 버그가 있습니다. 예외 유형이 EXC_CRASH이면 launchd는 메시지에 포함된 스레드 및 태스크 포트를 해제한 다음 서비스 루틴에서 KERN_FAILURE를 반환하므로, MIG 시스템이 스레드와 태스크 포트를 다시 해제하게 됩니다. (여기서 가정하는 것은 서비스 루틴이 성공을 반환하면 Mach 메시지의 모든 리소스에 대한 소유권을 가져간 것이고, 오류를 반환하면 어떤 리소스도 소유한 것이 없다는 점입니다.)
다음은 mach_exception_raise 메시지에 대한 launchd의 서비스 루틴 코드로, IDA/Hex-Rays를 사용해 디컴파일하고 가독성을 위해 약간 편집한 것입니다.```C
kern_return_t __fastcall
catch_mach_exception_raise( // (a) The service routine is
mach_port_t exception_port, // called with values directly
mach_port_t thread, // from the Mach message
mach_port_t task, // sent by the client. The
exception_type_t exception, // thread and task ports could
mach_exception_data_t code, // be arbitrary send rights.
mach_msg_type_number_t codeCnt)
{
__int64 __stack_guard; // ST28_8@1
kern_return_t kr; // w0@1 MAPDST
kern_return_t result; // w0@4
__int64 codes_left; // x25@6
mach_exception_data_type_t code_value; // t1@7
int pid; // [xsp+34h] [xbp-44Ch]@1
char codes_str[1024]; // [xsp+38h] [xbp-448h]@7
__stack_guard = *__stack_chk_guard_ptr;
pid = -1;
kr = pid_for_task(task, &pid);
if ( kr )
{
_os_assumes_log(kr);
_os_avoid_tail_call();
}
if ( current_audit_token.val[5] ) // (b) If the message was sent by
{ // a process with a nonzero PID
result = KERN_FAILURE; // (any non-kernel process),
} // the message is rejected.
else
{
if ( codeCnt )
{
codes_left = codeCnt;
do
{
code_value = *code;
++code;
__snprintf_chk(codes_str, 0x400uLL, 0, 0x400uLL, "0x%llx", code_value);
--codes_left;
}
while ( codes_left );
}
launchd_log_2(
0LL,
3LL,
"Host-level exception raised: pid = %d, thread = 0x%x, "
"exception type = 0x%x, codes = { %s }",
pid,
thread,
exception,
codes_str);
kr = deallocate_port(thread); // (c) The "thread" port sent in
if ( kr ) // the message is deallocated.
{
_os_assumes_log(kr);
_os_avoid_tail_call();
}
kr = deallocate_port(task); // (d) The "task" port sent in the
if ( kr ) // message is deallocated.
{
_os_assumes_log(kr);
_os_avoid_tail_call();
}
if ( exception == EXC_CRASH ) // (e) If the exception type is
result = KERN_FAILURE; // EXC_CRASH, then KERN_FAILURE
else // is returned. MIG will
result = 0; // deallocate the ports again.
}
*__stack_chk_guard_ptr;
return result;
}
이 코드가 수행하는 작업은 다음과 같습니다:
1. 이 함수는 `mach_exception_raise` 예외 메시지를 위한 Mach 서비스 루틴입니다. launchd가 `mach_exception_raise` Mach 예외 메시지를 처리할 때 Mach 시스템에 의해 직접 호출됩니다. 서비스 루틴에 전달되는 인자는 Mach 메시지에서 파싱되므로, 메시지 전송자가 제어할 수 있습니다.
2. (b)에서 launchd는 Mach 예외 메시지가 커널에 의해 전송되었는지 확인합니다. 전송자의 audit 토큰은 필드 5에 전송 프로세스의 PID를 포함하며, 커널의 경우에만 0이 됩니다. 메시지가 커널에 의해 전송된 것이 아니면 거부됩니다.
3. 메시지의 thread 및 task 포트는 (c)와 (d)에서 명시적으로 할당 해제됩니다.
4. (e)에서 launchd는 예외 유형이 `EXC_CRASH`인지 확인하고, 그렇다면 `KERN_FAILURE`를 반환합니다. 의도는 `EXC_CRASH` 메시지를 처리하지 않도록 하여, ReportCrash가 corpse 핸들러로 호출되게 하기 위한 것으로 보입니다. 그러나 이 시점에서 `KERN_FAILURE`를 반환하면 나중에 예외 메시지가 정리될 때 task 및 thread 포트가 다시 할당 해제됩니다. 즉, 이 두 포트가 이중으로 할당 해제됩니다.
이 취약점을 실제로 활용하려면 launchd가 제공하는 Mach 서비스에 대한 launchd의 send 권리를 해제하여, 해당 서비스를 시스템의 나머지 부분에 사칭할 수 있어야 합니다. 즉, 예외 메시지의 task 및 thread 포트가 launchd에서 해제하려는 Mach 서비스 포트에 대한 실제 send 권리여야 합니다. 그런 다음 악성 예외 메시지를 launchd에 보내 서비스 포트를 해제한 후, 동일한 포트 이름이 재사용되도록 시도하되, 이번에는 우리가 receive 권리를 보유한 Mach 포트에 할당되도록 해야 합니다. 그렇게 하면 클라이언트가 launchd에 서비스의 Mach 포트에 대한 send 권리를 요청할 때, launchd는 대신 우리 포트에 대한 send 권리를 넘겨주어 우리가 클라이언트에게 해당 서비스를 사칭할 수 있게 됩니다. 이후 시스템 권한을 획득하는 다양한 경로가 존재합니다.
### 취약점 트리거
취약점을 실제로 트리거하려면 메시지가 커널에 의해 전송되었는지 확인하는 검사를 우회해야 합니다. 예외 메시지를 launchd에 직접 보내면 그냥 폐기되기 때문입니다. 어떻게든 커널이 실제 thread 및 task 포트 대신 시스템 서비스에 대한 Mach send 권리를 포함한 "악성" 예외 메시지를 보내도록 해야 합니다.
알고 보니 Mach 트랩인 `task_set_special_port`를 사용하면 특정 상황에서 실제 task 포트 대신 사용될 사용자 지정 send 권리를 설정할 수 있습니다. 그러한 상황 중 하나는 커널이 task를 대신하여 예외 메시지를 생성할 때입니다. 커널은 예외 메시지에 실제 task send 권리를 넣는 대신 `task_set_special_port`가 제공한 send 권리를 사용합니다. 더 구체적으로, task가 `task_set_special_port`를 호출하여 `TASK_KERNEL_PORT` 특수 포트에 사용자 지정 값을 설정한 다음 task가 크래시하면, 커널이 생성한 예외 메시지의 "task" 필드에는 실제 task 포트가 아닌 사용자 지정 포트에 대한 send 권리가 포함됩니다. 동등한 API인 `thread_set_special_port`를 사용하면 생성된 예외 메시지의 "thread" 필드에 사용자 지정 포트를 설정할 수 있습니다.
이러한 동작 덕분에 커널이 task 및 thread 포트 대신 Mach 서비스 포트를 포함하는 "악성" 예외 메시지를 생성하도록 만드는 것은 전혀 어렵지 않습니다. 그러나 생성된 예외 메시지가 launchd에 전달되도록 해야 합니다.
다시 말하지만, 올바른 API를 알고 있다면 커널이 "악성" 예외 메시지를 launchd에 전달하도록 만드는 것도 어렵지 않습니다. `thread_set_exception_ports` 함수는 이 스레드의 예외 메시지가 전달될 포트로 임의의 Mach send 권리를 설정합니다. 따라서 부트스트랩 포트로 `thread_set_exception_ports`를 호출하기만 하면, 이후 발생하는 모든 예외로 인해 커널이 launchd에 예외 메시지를 보내게 됩니다.
퍼즐의 마지막 조각은 올바른 예외 유형을 얻는 것입니다. 이 취약점은 `EXC_CRASH` 예외에 대해서만 트리거됩니다. 약간의 시행착오를 통해 표준 `abort` 함수를 호출하면 `EXC_CRASH` 예외를 쉽게 생성할 수 있음을 알 수 있습니다.
요약하면, 기존에 잘 문서화된 API를 사용하여 커널이 우리를 대신해 악성 `EXC_CRASH` 예외 메시지를 생성하고 launchd에 전달하도록 만들어 취약점을 트리거하고 Mach 서비스 포트를 해제할 수 있습니다:
1. `thread_set_exception_ports`를 사용하여 이 스레드의 예외 핸들러로 launchd를 설정합니다.
2. `bootstrap_look_up`을 호출하여 launchd에서 사칭하려는 서비스의 서비스 포트를 가져옵니다.
3. `task_set_special_port`/`thread_set_special_port`를 호출하여 예외 메시지에서 실제 task 및 thread 포트 대신 해당 서비스 포트를 사용하도록 설정합니다.
4. `abort`를 호출합니다. 커널이 `EXC_CRASH` 예외 메시지를 launchd에 보내지만, 메시지의 task 및 thread 포트는 대상 서비스 포트가 됩니다.
5. launchd가 예외 메시지를 처리하고 서비스 포트를 해제합니다.
### 크래시 이후 코드 실행
위 전략에는 문제가 있습니다. `abort`를 호출하면 우리 프로세스가 종료됩니다. 취약점을 트리거한 후 코드를 실행하려면 다른 프로세스에서 크래시를 수행할 방법이 필요합니다.
(다른 예외 유형의 경우 프로세스가 실제로 예외에서 복구할 수 있습니다. 프로세스가 복구하는 방법은 thread 예외 핸들러를 launchd로 설정하고 task 예외 핸들러를 자기 자신으로 설정하는 것입니다. launchd가 예외를 처리하지 못하면 커널이 예외를 task 핸들러로 보내고, task 핸들러가 스레드 상태를 재설정하고 예외가 처리되었음을 커널에 알립니다. 그러나 프로세스는 자신의 `EXC_CRASH` 예외를 잡을 수 없으므로 두 개의 프로세스가 필요합니다.)
한 가지 전략은 먼저 iOS의 다른 프로세스에서 취약점을 악용하여 해당 프로세스가 커널 포트를 설정하고 크래시하도록 강제하는 것입니다. 그러나 개념 증명(proof-of-concept)을 위해서는 앱 확장(app extension)을 만드는 것이 더 쉽습니다.
iOS 8에서 도입된 앱 확장은 애플리케이션의 일부 기능을 패키징하여 애플리케이션 외부에서 사용할 수 있게 하는 방법을 제공합니다. 앱 확장의 코드는 별도의 샌드박스 프로세스에서 실행됩니다. 따라서 특수 포트를 설정하고, launchd를 `EXC_CRASH` 예외 핸들러로 등록한 다음 `abort`를 호출하는 프로세스를 매우 쉽게 실행할 수 있습니다.
앱이 자체 앱 확장을 프로그래밍 방식으로 실행하고 통신하도록 지원되는 방법은 없습니다. 그러나 Ian McDowell은 [훌륭한 문서][Multi-Process iOS App Using NSExtension]에서 비공개 `NSExtension` API를 사용하여 앱 확장 프로세스를 실행하고 통신하는 방법을 설명했습니다. 여기서는 거의 동일한 전략을 사용했습니다. 유일한 차이점은 Mach 포트를 앱 확장 프로세스에 전달해야 하며, 이를 위해 앱 확장이 연결할 더미 서비스를 launchd에 등록해야 한다는 것입니다.
[Multi-Process iOS App Using NSExtension]: https://ianmcdowell.net/blog/nsextension/
### launchd에서 포트 재사용 방지
설명한 대로 익스플로잇을 실행하면 가끔 해제된 포트를 다시 획득하지 못하는 문제가 발생합니다. 그 이유는 커널이 프로세스의 사용 가능한 IPC 항목을 프리리스트(freelist)로 관리하기 때문에, 방금 해제된 포트 이름은 IPC 테이블에 새 포트가 할당될 때 (다른 세대 번호로) 재사용되기 때문입니다. 따라서 launchd가 해당 IPC 항목 슬롯을 다른 포트에 먼저 재사용하지 않을 때만 우리가 원하는 포트 이름을 다시 할당받을 수 있습니다.
이를 우회하는 방법은 사용 가능한 IPC 항목 슬롯을 프리리스트의 깊은 곳에 묻어두는 것입니다. 그러면 launchd가 새 포트를 할당할 때 다른 슬롯들이 먼저 사용됩니다. 어떻게 할까요? 우리가 receive 권리를 보유한 포트를 사용하여 launchd에 더미 Mach 서비스를 여러 개 등록하면 됩니다. `abort`를 호출하면 예외 핸들러가 먼저 실행되고, 그 다음 Mach 포트를 포함한 프로세스 상태가 정리됩니다. launchd가 `EXC_CRASH` 예외를 수신하면 실수로 대상 서비스 포트를 해제하여 해당 포트 이름에 대응하는 IPC 항목 슬롯을 프리리스트의 맨 앞에 놓게 됩니다. 그런 다음 앱 확장의 나머지 Mach 포트가 파괴되면 launchd는 알림을 받고 더미 서비스 포트를 해제하여 대상 IPC 항목 슬롯을 방금 해제된 포트들의 슬롯 뒤에 묻어버립니다. 따라서 launchd가 우리가 등록한 더미 서비스 수보다 적은 수의 포트를 할당하는 한 대상 슬롯은 여전히 프리리스트에 남아 있으며, launchd가 원래 서비스와 동일한 포트 이름으로 슬롯을 재할당하도록 만들 수 있습니다.
이 전략의 한계는 launchd에 서비스를 등록하려면 `com.apple.security.application-groups` 자격(entitlement)이 필요하다는 것입니다. launchd에 Mach 포트를 숨기는 다른 방법도 있지만, application groups를 사용하는 것이 확실히 가장 쉬우며 이 개념 증명에 충분합니다.
### 해제된 서비스 사칭
크래셔 앱 확장을 실행하고 launchd에서 Mach send 권리를 해제한 후에는, 우리가 receive 권리를 보유한 send 권리로 해당 Mach 포트 이름을 재할당해야 합니다. 그러면 launchd가 그 포트 이름으로 보내는 모든 메시지를 우리가 수신하게 되고, launchd가 클라이언트와 그 포트 이름을 공유할 때마다 클라이언트는 우리 포트에 대한 send 권리를 받게 됩니다. 특히, launchd의 Mach 서비스에 대한 send 권리를 해제할 수 있다면, launchd에서 해당 서비스를 요청하는 모든 프로세스는 실제 서비스 포트 대신 우리 포트에 대한 send 권리를 받게 됩니다. 이를 통해 우리는 서비스를 사칭하거나 중간자 공격(man-in-the-middle)을 수행하여 클라이언트가 서비스에 보내는 모든 메시지를 검사할 수 있습니다.
해제된 포트 이름이 우리가 소유한 포트를 가리키도록 재사용하는 것도, 이미 application-groups 자격을 사용하기로 결정했으므로 매우 간단합니다. launchd에 더미 Mach 서비스를 등록하여 그중 하나가 원래 포트 이름을 재사용할 때까지 반복하면 됩니다. 이 작업은 일괄 처리로 수행해야 합니다. 즉, 많은 수의 더미 서비스를 함께 등록하고, 그중 하나가 해제된 포트 이름을 성공적으로 재사용했는지 확인한 다음, 등록을 해제합니다. 그 이유는 등록이 IPC 포트 프리리스트의 끝까지 거슬러 올라가 우리가 원하는 묻힌 포트 이름을 복구하도록 보장해야 하기 때문입니다.
`bootstrap_look_up`으로 원래 서비스를 조회하여 해제된 포트 이름을 성공적으로 재사용했는지 확인할 수 있습니다. 조회 결과가 우리가 등록한 서비스 포트 중 하나라면 성공한 것입니다.
원래 서비스와 동일한 포트 이름을 얻는 새 서비스를 등록하는 데 성공하면, launchd에서 원래 서비스를 조회하는 모든 클라이언트는 실제 서비스 포트가 아닌 우리 포트에 대한 send 권리를 받게 됩니다. 따라서 우리는 사실상 시스템의 나머지 부분(적어도 공격 이후에 서비스를 조회하는 프로세스들)에 대해 원래 서비스를 사칭하게 됩니다.
1단계: host-priv 포트 획득
---------------------------------------------------------------------------------------------------
임의의 시스템 서비스를 사칭할 수 있게 되면, 다음 단계는 host-priv 포트를 획득하는 것입니다. 이 단계는 간단하며 iOS 11.3의 변경 사항의 영향을 받지 않습니다. 이 공격의 전반적인 아이디어는 SafetyNet을 사칭하고 ReportCrash를 크래시시킨 다음, 예외 메시지에 포함된 종료되는 ReportCrash의 task 포트에서 host-priv 포트를 추출하는 것입니다.
### ReportCrash와 SafetyNet 소개
ReportCrash는 iOS에서 크래시 리포트를 생성하는 역할을 담당합니다. 이 단일 바이너리는 실제로 4개의 서로 다른 서비스를 제공합니다(각각 다른 프로세스에서 실행되며, 특정 시점에 모두 실행 중이 아닐 수도 있습니다):
1. `com.apple.ReportCrash`는 크래시하는 프로세스에 대한 크래시 리포트를 생성합니다. `EXC_CRASH`, `EXC_GUARD`, `EXC_RESOURCE` 예외에 대한 호스트 수준 예외 핸들러입니다.
2. `com.apple.ReportCrash.Jetsam`은 Jetsam 리포트를 처리합니다.
3. `com.apple.ReportCrash.SimulateCrash`는 시뮬레이션된 크래시에 대한 리포트를 생성합니다.
4. `com.apple.ReportCrash.SafetyNet`은 `com.apple.ReportCrash` 서비스에 등록된 예외 핸들러입니다.
우리에게 관심 있는 것은 `com.apple.ReportCrash`와 `com.apple.ReportCrash.SafetyNet`이며, 이하 간단히 ReportCrash 및 SafetyNet이라고 부르겠습니다. 둘 다 MIG 기반 서비스이며 사실상 동일한 코드를 실행합니다.
ReportCrash가 시작되면 launchd에서 SafetyNet 서비스를 조회하고 반환된 포트를 task 수준 예외 핸들러로 설정합니다. 의도는 ReportCrash 자체가 크래시할 경우 별도의 프로세스가 해당 크래시 리포트를 생성하도록 하려는 것으로 보입니다. 그러나 이 코드 경로는 기능하지 않는 것으로 보입니다. ReportCrash는 `mach_exception_raise` 메시지에 대해 SafetyNet을 등록하지만, ReportCrash와 SafetyNet 모두 `mach_exception_raise_state_identity` 메시지만 처리합니다. 그럼에도 불구하고 두 서비스 모두 여전히 존재하며 iOS 컨테이너 샌드박스 내에서 접근할 수 있습니다.
### ReportCrash 조작 프리미티브
다음 공격을 수행하려면 ReportCrash(또는 SafetyNet)를 원하는 방식으로 조작할 수 있어야 합니다. 구체적으로 다음 기능이 필요합니다: 필요할 때 ReportCrash 시작, ReportCrash 강제 종료, ReportCrash 크래시, 그리고 사용 중에 ReportCrash가 종료되지 않도록 하는 것. 여기서 각 목표를 어떻게 달성하는지 설명하겠습니다.
ReportCrash를 시작하려면 Mach 메시지를 보내기만 하면 됩니다. launchd가 요청 시 시작합니다. 그러나 독특한 설계로 인해 `mach_exception_raise_state_identity`를 제외한 모든 메시지 유형은 ReportCrash가 새 메시지에 응답하지 않고 결국 종료되게 만듭니다. 따라서 이후에도 계속 살아 있게 하려면 `mach_exception_raise_state_identity` 메시지를 보내야 합니다.
ReportCrash를 종료하려면 다른 유형의 Mach 메시지를 보내기만 하면 됩니다.
ReportCrash를 크래시시키는 방법은 여러 가지입니다. 아마도 가장 쉬운 방법은 thread 포트를 `MACH_PORT_NULL`로 설정한 `mach_exception_raise_state_identity` 메시지를 보내는 것입니다.
마지막으로, 사용 중에 ReportCrash가 종료되지 않도록 해야 합니다. ReportCrash가 처리하는 각 `mach_exception_raise_state_identity` 메시지는 원래 스레드가 크래시 리포트를 생성하는 동안 다음 메시지를 수신할 다른 스레드를 생성하게 만듭니다. ReportCrash는 크래시 리포트를 생성 중인 모든 미해결 스레드가 완료되면 종료됩니다. 따라서 크래시 리포트 생성 과정에서 해당 스레드 중 하나를 지연시킬 수 있다면 ReportCrash가 종료되지 않도록 할 수 있습니다.
내가 찾은 가장 쉬운 방법은 task 및 thread 필드에 사용자 지정 포트를 넣은 `mach_exception_raise_state_identity` 메시지를 보내는 것입니다. ReportCrash가 크래시 리포트를 생성하려고 하면 "task" 포트에 대해 `task_policy_get`을 호출하여, 우리가 보낸 포트로 Mach 메시지를 보내고 응답을 기다립니다. 그러나 "task" 포트는 일반적인 Mach 포트일 뿐이므로 Mach 메시지에 응답하지 않기만 하면 ReportCrash는 `task_policy_get`이 반환될 때까지 무기한 기다리게 됩니다.
### ReportCrash에서 host-priv 추출
익스플로잇의 첫 번째 단계에서 공격 계획은 비교적 간단합니다:
1. SafetyNet 서비스를 시작하고 공격이 진행되는 동안 계속 살아 있도록 강제합니다.
2. launchd 서비스 사칭 프리미티브를 사용하여 SafetyNet을 사칭합니다. 이를 통해 실제 SafetyNet 서비스로 전달될 메시지를 수신할 수 있는 새 포트를 확보합니다.
3. 기존에 실행 중인 ReportCrash 인스턴스를 종료시킵니다. 그래야 다음 단계에서 ReportCrash가 우리의 SafetyNet 포트를 조회하도록 할 수 있습니다.
4. ReportCrash를 시작합니다. ReportCrash는 launchd에서 SafetyNet을 조회하고, 그 결과로 얻은 포트(우리가 receive 권리를 소유한 가짜 SafetyNet 포트)를 `EXC_CRASH` 메시지의 대상으로 설정합니다.
5. ReportCrash에서 크래시를 트리거합니다. ReportCrash는 원래 예외 유형에 등록된 핸들러가 없다는 것을 확인한 후 프로세스 사망 단계에 진입합니다. 이 시점에 XNU는 ReportCrash가 `EXC_CRASH` 예외를 수신하도록 가짜 SafetyNet 포트를 등록했음을 확인하고, 예외 메시지를 생성하여 해당 포트로 보냅니다.
6. 그런 다음 가짜 SafetyNet 포트에서 `EXC_CRASH` 메시지를 수신합니다. 이 메시지는 `mach_exception_raise` 유형이므로 ReportCrash의 task 포트를 포함합니다.
7. 마지막으로 ReportCrash task 포트에 `task_get_special_port`를 사용하여 ReportCrash의 host 포트를 가져옵니다. ReportCrash는 샌드박스에 속하지 않고 root로 실행되므로, 이 포트가 host-priv 포트입니다.
샌드박스 탈출의 이 단계가 끝나면 사용 가능한 host-priv 포트를 확보하게 됩니다. 이것만으로도 이는 심각한 보안 문제임을 입증합니다.
2단계: 샌드박스 탈출
---------------------------------------------------------------------------------------------------
host-priv 포트를 확보했지만, 우리의 목표는 샌드박스를 완전히 탈출하고 `task_for_pid-allow` 자격을 가진 root로 코드를 실행하는 것입니다. 이를 달성하기 위한 첫 단계는 단순히 샌드박스를 탈출하는 것입니다.
기술적으로 말하면 샌드박스를 탈출하기 전에 host-priv 포트를 획득해야 할 이유는 없습니다. 이 두 단계는 독립적이며 어느 순서로든 수행할 수 있습니다. 그러나 이 단계 또는 이후 단계가 실패하면 시스템이 불안정해질 수 있으므로 나중에 수행하는 것이 좋습니다.
전반적인 공격은 동일한 launchd 취약점을 다시 사용하여 시스템 서비스를 사칭하는 것입니다. 그러나 이번 목표는 클라이언트가 Mach 메시지에서 task 포트를 보내는 서비스를 사칭하는 것입니다. iOS 11.2.6에서 실험해 보면 backboardd가 호스팅하는 `com.apple.CARenderServer`(이하 CARenderServer)를 사칭한 다음 `com.apple.DragUI.druid.source`와 통신하면, 샌드박스에 속하지 않은 druid 데몬이 가짜 서비스 포트로 Mach 메시지에 task 포트를 보낸다는 것을 쉽게 확인할 수 있습니다.
이 익스플로잇 단계는 iOS 11.3에서 작동하지 않습니다. druid가 더 이상 Mach 메시지에서 task 포트를 CARenderServer로 보내지 않기 때문입니다. 그럼에도 불구하고 이 취약점을 여전히 샌드박스 탈출에 사용할 수 있다고 확신합니다. 한 가지 방법은 다른 서비스의 입력을 신뢰하는 샌드박스 외부 서비스를 찾는 것입니다. 이러한 유형의 "취약점"은 시스템 서비스를 교체할 수 있는 능력이 없으면 악용할 수 없으며, 이는 Apple 내부와 외부 모두에서 낮은 우선순위의 공격 표면일 가능성이 높습니다.
### druid 크래시
ReportCrash와 마찬가지로, druid가 이미 실행 중인 경우 강제로 재시작시켜 launchd에서 우리의 가짜 CARenderServer 포트를 조회하도록 해야 합니다. 나는 이 목적을 위해 이미 수정이 예정되어 있던 libxpc의 버그를 사용하기로 결정했습니다.
libxpc를 살펴보던 중, 모든 XPC 서비스를 강제로 크래시시키는 데 사용할 수 있는 out-of-bounds 읽기(범위를 벗어난 읽기)를 발견했습니다:```C
void _xpc_dictionary_apply_wire_f
(
OS_xpc_dictionary *xdict,
OS_xpc_serializer *xserializer,
const void *context,
bool (*applier_fn)(const char *, OS_xpc_serializer *, const void *)
)
{
...
uint64_t count = (unsigned int)*serialized_dict_count;
if ( count )
{
uint64_t depth = xserializer->depth;
uint64_t index = 0;
do
{
const char *key = _xpc_serializer_read(xserializer, 0, 0, 0);
size_t keylen = strlen(key);
_xpc_serializer_advance(xserializer, keylen + 1);
if ( !applier_fn(key, xserializer, context) )
break;
xserializer->depth = depth;
++index;
}
while ( index < count );
}
...
}
문제는 공격자가 제어하는 데이터에 대해 검사되지 않은 strlen을 사용하면 직렬화된 사전 항목의 키가
데이터 버퍼의 끝을 넘어 확장될 수 있다는 점입니다. 이는 XPC
서비스가 사전을 역직렬화할 때 충돌하게 된다는 것을 의미하며, strlen이 범위를 벗어난
메모리를 역참조하거나 _xpc_serializer_advance가 공급된 데이터의 끝을 넘어 직렬 변환기를
진행시키려 할 때 발생합니다.
이 버그는 제가 발견했을 때 이미 iOS 11.3 Beta에서 수정되어 있었기 때문에 Apple에 보고하지 않았습니다. 익스플로잇은 제 xpc-crash 저장소에 독립적인 프로젝트로 공개되어 있습니다.
이 버그를 사용하여 druid를 충돌시키려면 druid 서비스에 잘못된 형식의 XPC 메시지를 보내기만 하면 됩니다. 즉, 사전의 키가 종결되지 않고 메시지의 마지막 바이트까지 확장되어야 합니다.
우리의 서비스 가장 프리미티브를 사용하여 iOS 11.2.6에서 druid의 태스크 포트를 얻는 것은 쉽습니다:
druid의 태스크 포트를 확보했다 하더라도, druid 프로세스 내부에서 코드를 실행하는 방법을 여전히 알아내야 합니다.
문제는 XNU가 플랫폼 바이너리의 태스크 포트를 비(非) 플랫폼 바이너리에 의한 수정으로부터
보호한다는 것입니다. 이 방어는 task_conversion_eval 함수에 구현되어 있으며, 이 함수는
convert_port_to_locked_task와 convert_port_to_task_with_exec_token에 의해 호출됩니다:```C
kern_return_t
task_conversion_eval(task_t caller, task_t victim)
{
/*
* Tasks are allowed to resolve their own task ports, and the kernel is
* allowed to resolve anyone's task port.
*/
if (caller == kernel_task) {
return KERN_SUCCESS;
}
if (caller == victim) {
return KERN_SUCCESS;
}
/*
* Only the kernel can can resolve the kernel's task port. We've established
* by this point that the caller is not kernel_task.
*/
if (victim == kernel_task) {
return KERN_INVALID_SECURITY;
}
#if CONFIG_EMBEDDED /* * On embedded platforms, only a platform binary can resolve the task port * of another platform binary. / if ((victim->t_flags & TF_PLATFORM) && !(caller->t_flags & TF_PLATFORM)) { #if SECURE_KERNEL return KERN_INVALID_SECURITY; #else if (cs_relax_platform_task_ports) { return KERN_SUCCESS; } else { return KERN_INVALID_SECURITY; } #endif / SECURE_KERNEL / } #endif / CONFIG_EMBEDDED */
return KERN_SUCCESS;
}
MIG 변환 루틴 중 이 함수들에 의존하는 `convert_port_to_task` 및 `convert_port_to_map`은 druid의 태스크에 대해 호출하면 실패합니다. 예를 들어 `mach_vm_write`는 druid의 메모리를 조작하도록 허용하지 않습니다.
하지만 XNU의 MIG 파일 `osfmk/mach/task.defs`를 살펴보던 중, 흥미로운 점을 발견했습니다:```C
/*
* Returns the set of threads belonging to the target task.
*/
routine task_threads(
target_task : task_inspect_t;
out act_list : thread_act_array_t);
The function task_threads, which enumerates the threads in a task, actually takes a
task_inspect_t rather than a task_t, which means MIG converts it using
convert_port_to_task_inspect rather than convert_port_to_task. A quick look at
convert_port_to_task_inspect reveals that this function does not perform the
task_conversion_eval check, meaning we can call it successfully on platform binaries. This is
interesting because the returned threads are not thread_inspect_t rights, but rather full
thread_act_t rights. Put another way, task_threads promotes a non-modifiable task right into
modifiable thread rights. And since there's no equivalent thread_conversion_eval, this means we
can use the Mach thread APIs to modify the threads in a task even if that task is a platform
binary.
In order to take advantage of this, I wrote a library called threadexec which builds a full-featured function call capability on top of the Mach threads API. The threadexec project in and of itself was a significant undertaking, but as it is only indirectly relevant to this exploit, I will forego a detailed explanation of its inner workings.
Once we have the host-priv port and unsandboxed code execution inside of druid, the next stage of the full sandbox escape is to install a new host-level exception handler. This process is straightforward given our current capabilities:
EXC_BAD_ACCESS by calling
host_get_exception_ports.EXC_BAD_ACCESS.host_set_exception_ports to register our
Mach port as the host-level exception handler for EXC_BAD_ACCESS.After this stage, any time a process accesses an invalid memory address (and also does not have a
registered exception handler), an EXC_BAD_ACCESS exception message will be sent to our new
exception handler port. This will give us the task port of any crashing process, and since
EXC_BAD_ACCESS is a recoverable exception, this time we can use the task port to execute code.
The next stage is to trigger an EXC_BAD_ACCESS exception in ReportCrash so that its task port
gets sent in an exception message to our new exception handler port:
EXC_BAD_ACCESS exception. Since ReportCrash has no exception handler registered
for EXC_BAD_ACCESS (remember SafetyNet is registered for EXC_CRASH), the exception will be
delivered to the host-level exception handler.KERN_SUCCESS to indicate to the kernel that the exception has
been handled and ReportCrash can be resumed.At this point, we have code execution inside an unsandboxed, root, task_for_pid-allow process.
The next two stages aren't strictly necessary but should be performed anyway.
Once we have code execution inside ReportCrash, we should reset the host-level exception handler
for EXC_BAD_ACCESS using druid:
host_set_exception_ports in druid to re-register the old host-level exception handler for
EXC_BAD_ACCESS.This will stop our exception handler port from receiving exception messages for other crashing processes.
The last step is to restore the damage we did to launchd when we freed service ports in its IPC namespace in order to impersonate them:
task_for_pid in ReportCrash to get launchd's task port.mach_port_insert_right in ReportCrash to push the real service port into launchd's
IPC space under the original name.After this step is done, the system should once again be fully functional. After successful exploitation, there should be no need to force reset the device, since the exploit repairs all the damages itself.
Blanket also packages a post-exploitation payload that bypasses amfid and spawns a bind shell. This section will describe how that is achieved.
Even after gaining code execution in ReportCrash, using that capability is not easy: we are limited to performing individual function calls from within the process, which makes it painful to perform complex tasks. Ideally, we'd like a way to run code natively with ReportCrash's privileges, either by injecting code into ReportCrash or by spawning a new process with the same (or higher) privileges.
Blanket chooses the process spawning route. We use task_for_pid and our platform binary status in
ReportCrash to get launchd's task port and create a new thread inside of launchd that we can
control. We then use that thread to call posix_spawn to launch our payload binary. The payload
binary can be signed with restricted entitlements, including task_for_pid-allow, to grant
additional capabilities.
In order for iOS to accept our newly spawned binary, we need to bypass codesigning. Various
strategies have been discussed over the years, but the most common current strategy is to register
an exception handler for amfid and then perform a data patch so that amfid crashes when trying to
call MISValidateSignatureAndCopyInfo. This allows us to fake the implementation of that function
to pretend that the code signature is valid.
However, there's another approach which I believe is more robust and flexible: rather than patching amfid at all, we can simply register a new amfid port in the kernel.
The kernel keeps track of which port to send messages to amfid using a host special port called
HOST_AMFID_PORT. If we have unsandboxed root code execution, we can set this port to a new value.
Apple has protected against this attack by checking whether the reply to a validation request
really came from amfid: the cdhash of the sender is compared to amfid's cdhash. However, this
doesn't actually prevent the message from being sent to a process other than amfid; it only
prevents the reply from coming from a non-amfid process. If we set up a triangle where the kernel
sends messages to us, we generate the reply and pass it to amfid, and then amfid sends the reply to
the kernel, then we'll be able to bypass the sender check.
There are numerous advantages to this approach, of which the biggest is probably access to
additional flags in the verify_code_directory service routine. Even though amfid does not use
them all, there are many other output flags that amfid could set to control the behavior of
codesigning. Here's a partial prototype of verify_code_directory:```C
kern_return_t
verify_code_directory(
mach_port_t amfid_port,
amfid_path_t path,
uint64_t file_offset,
int32_t a4,
int32_t a5,
int32_t a6,
int32_t * entitlements_valid,
int32_t * signature_valid,
int32_t * unrestrict,
int32_t * signer_type,
int32_t * is_apple,
int32_t * is_developer_code,
amfid_a13_t a13,
amfid_cdhash_t cdhash,
audit_token_t audit);
탈옥 개발자에게 특히 흥미로운 점은 `is_apple` 매개변수입니다. 이 매개변수는
amfid에서 사용되지 않는 것으로 보이지만, 설정된 경우 커널이
`CS_PLATFORM_BINARY` 코드 서명 플래그를 설정하게 하여 애플리케이션에 플랫폼 바이너리 권한을 부여합니다. 특히
이는 애플리케이션이 이제 태스크 포트를 사용하여 플랫폼 바이너리를 직접
수정할 수 있음을 의미합니다.
이 공격에 사용된 허점
---------------------------------------------------------------------------------------------------
이 공격은 그 자체로 보안 취약점은 아니지만 다양한 익스플로잇 완화 조치의 효율성을 낮추는 여러 허점을 이용합니다.
이러한 허점 중 일부는 부분적으로 중복되므로 모두 함께 해결할 필요는 없지만,
어쨌든 모두 나열할 가치가 있습니다.
커널 내부:
1. `task_threads`는 검사 전용 `task_inspect_t`를 수정 가능한 `thread_act_t`로 승격할 수 있습니다.
2. 스레드를 위해 `task_conversion_eval`의 역할을 수행하는 `thread_conversion_eval`이 없습니다.
3. 비플랫폼 바이너리는 플랫폼 바이너리에 대한 `task_inspect_t` 권한을 사용할 수 있습니다.
4. 샌드박스가 적용되지 않은 프로세스의 예외 메시지가 샌드박스가 적용된 프로세스로 전달될 수 있으며, 이는
샌드박스를 탈출하는 방법을 제공합니다. 이 허점에 대한 깔끔한 해결책이 있는지
명확하지 않습니다.
5. 샌드박스가 적용되지 않은 코드 실행, host-priv 포트, 그리고 `task_for_pid-allow` 프로세스를
크래시시킬 수 있는 능력을 결합하여 `task_for_pid` 우회 방법을 만들 수 있습니다. (우회 방법은
`host_set_exception_ports`를 호출하여 새로운 호스트 수준 예외 처리기를 설정한 다음,
`task_for_pid-allow` 프로세스를 크래시시켜 해당 태스크 포트를 받고 권한과 함께 코드를 실행하는 것입니다.)
앱 확장에서:
1. 애플리케이션 그룹을 공유하는 앱 확장은, 문서가 호스트 앱과 앱 확장 간의 통신이
불가능하다고 시사함에도 불구하고, Mach 메시지를 사용하여
통신할 수 있습니다.
권장 수정 사항 및 완화 조치
---------------------------------------------------------------------------------------------------
다음과 같은 수정 사항을 대략적인 중요도 순서로 권장합니다:
1. `KERN_SUCCESS`를 반환할 때만 launchd 서비스 루틴에서 Mach 포트를 할당 해제하십시오. 이렇게 하면
Mach 포트 교체 취약점이 수정됩니다.
2. 비플랫폼 바이너리가 플랫폼 바이너리의 태스크 포트를 사용하여 코드 실행을 달성할 수 있게 하는
`task_threads` 허점을 차단하십시오.
3. ReportCrash의 크래시 문제를 수정하십시오.
4. 컨테이너 샌드박스 내에서 접근 가능한 Mach 서비스 집합은 최소화되어야 합니다. 대부분의 iOS 앱이
ReportCrash 또는 SafetyNet과 통신할 정당한 이유는 없다고 생각합니다.
5. 가능한 한 많은 프로세스가 샌드박스 처리되어야 합니다. druid가 제대로 기능하려면 샌드박스가
해제되어야 하는지 확실하지 않지만, 그렇지 않다면 적절한 샌드박스에 배치해야 합니다.
6. 죽은 코드는 제거되어야 합니다. SafetyNet은 의도된 기능을 수행하지 않는 것 같습니다.
더 이상 필요하지 않다면 제거하는 것이 좋습니다.
7. `host_set_exception_ports` 기반 `task_for_pid` 우회를 차단하십시오. 예를 들어,
`host_set_exception_ports`를 root로 제한하거나 일부 구성에서 host-priv 포트의 사용성을
제한할 가치가 있는지 고려하십시오. 이는 Mach의 우아한 기능 기반 설계를 위반하지만,
`host_set_exception_ports`는 악용 가능성이 높은 대상일 수 있습니다.
8. `task_inspect_t`에 `task_conversion_eval`을 추가할 가치가 있는지 고려하십시오.
Blanket 실행하기
---------------------------------------------------------------------------------------------------
Blanket은 iOS 11.2.6을 실행하는 모든 기기에서 작동해야 합니다.
1. 프로젝트를 다운로드하십시오: ```
git clone https://github.com/bazad/blanket
cd blanket
headers/config.h 파일을 편집하고, APP_GROUP을 앞서 지정한
앱 그룹 식별자로 변경하세요.그러면 기기에서 프로젝트를 빌드하고 실행할 수 있을 것입니다.
blanket이 성공하면 페이로드 바이너리를 실행합니다(소스는
blanket_payload/blanket_payload.c에 있음). 기본적으로 4242 포트에 바인드 셸을 띄웁니다. netcat으로
해당 포트에 연결하여 임의의 셸 명령을 실행할 수 있습니다.
Ian Beer와 Jonathan Levin의 훌륭한 iOS 보안 및 내부 구조 연구에 깊은 감사를 드립니다.
저는 2018년 1월에 이 취약점을 발견했고, 2월 말에 익스플로잇 개발을 시작했습니다. 4월 13일에 이 문제를 Apple에 보고했습니다. Apple은 launchd의 Mach 포트 교체 취약점을 CVE-2018-4280으로 지정했으며, 이 취약점은 7월 9일 iOS 11.4.1 및 macOS 10.13.6에서 패치되었습니다.
Blanket은 MIT 라이선스로 배포됩니다.
Brandon Azad