承接上一篇CTF VM入門,主要講TWCTF的一道題 EscapeMe。題目的利用中涉及到了MMU,比較複雜。

總體框架分析

先逆向host kvm.elf

首先是open /dev/kvm『等一系列初始化操作,之後會將運行時傳入的文件分別打開,將文件描述符存入全局變數 ptr。

之後會調用

sub_2B8E(a1, 0, 0LL, 0LL, 0LL);

其中關鍵的是

fd = *((_DWORD *)ptr + a2 + 1);
......
v9 = palloc(v8, (nbytes + 4095) & 0xFFFFFFFFFFFFF000LL);
......
read(fd, (void *)(*(_QWORD *)(a1 + 16) + v9), nbytes) == -1
......
result = v9;

其中a2是ptr的索引,a1是之前創建的vm struct,0x10偏移位置是作為guest物理地址的虛擬地址。palloc是題目自己實現的類似ptmalloc的分配器。並沒有仔細去逆向這套機制。palloc返回地址加上mmap的地址之後,直接就能在host中讀寫guest地址空間。不用做其他的轉換。

隨後v9被直接設置為guest的rip

v20 = v5;
v21 = 2LL;
if ( ioctl(fd, 0x4090AE82uLL, &v19) >= 0 )

其中v5是剛才的v9, v21是一個其它的寄存器。

0x4090AE82uLL的定義為是KVM_SET_REGS。

這樣kvm_run之後,進入kernel執行

kernel.bin並沒有符號表。我直接先從vmmcall入手分析。在kvm.elf的函數sub_1D61中,可以看到對應的IO操作。包括read,write,palloc,pfree等。其中當rax為0x30時調用的函數sub_2B8E,就是之前說過的載入bin文件的函數。返回的地址,將會被放在rax中,返回guest。

v21 = v8;
v26 += 3LL;
if ( ioctl(fd, 0x4090AE82uLL, &v21) >= 0 )

guest在得到入口地址之後,會做一系列檢查操作,最後進入177f這個函數。

seg000:0000000000001787 mov ax, 2Bh ; +
seg000:000000000000178B mov ds, eax
seg000:000000000000178D assume ds:nothing
seg000:000000000000178D push 2Bh
seg000:000000000000178F push rsi
seg000:0000000000001790 pushfq
seg000:0000000000001791 or [rsp+18h+var_18], 200h
seg000:0000000000001799 push 23h
seg000:000000000000179B push rdi
.......
iretq

這樣的話,iretq之後,rip的值即為rdi的值,rdi為上一個函數返回的elf文件的入口地址。

在理解了這套kvm系統如何運行的,我們再來看這個剛載入的elf文件。

一個菜單程序,在edit函數中:

v4 = strlen(*(_QWORD *)(16LL * v5 + memo));
read(0LL, *(_QWORD *)(16LL * v5 + memo), v4);

可以造成off by one。

off by one就不講了,直接貼上出題人的exp。

exploit.py?

github.com

說一下調試的一些事情。我們用gdb直接調試的是host,如何看guest的堆棧呢?

首先,讀出host為guest分配的物理地址。通過在相應的mmap位置下端點即可讀出。在這個題目中mmap的返回值是:0x7ffff75e4000。

其後,只要將guest中的虛擬地址轉換成物理地址,再加上這塊mmap的地址,就能得到相應的host的虛擬地址,即可通過gdb讀出數值。

這個地方比較繞,舉個例子,在guest的菜單程序中,有一個全局變數memo。要讀取memo的值,首先找到它在guest中的虛擬地址:0x604028。第二步,將它轉換成guest的物理地址(guest的MMU機制實現在下一部分)。可以自己讀出cr3的值,一步一步計算,但我採取的方法是:在host中的translate函數下端點,修改第三個參數 laddr為0x604028,再讀取返回值,這樣比較省事。

uint64_t __cdecl translate(vm *vm, uint64_t pml4_addr, uint64_t laddr, int write, int user)

第三步,將得到的數值,加上0x7ffff75e4000就得到了memo在host中的虛擬地址。就可以通過gdb讀出來了。

MMU機制實現

linux內核中的頁表是分級的,見上圖。

來看一下ESCAPE實現的頁表機制。當調用mmap的時候,最終會來到 do_mmap_in_user 這個函數

首先,將虛擬地址分段取出,作為各級頁表的索引。

uint16_t idx[] = { (vaddr>>39) & 0x1ff, (vaddr>>30) & 0x1ff, (vaddr>>21) & 0x1ff, (vaddr>>12) & 0x1ff };

之後,取出cr3中,第一級頁表的物理地址

asmvolatile ("mov %0, cr3":"=r"(pml4_phys));

由第一級頁表物理地址和第一級頁表索引,可以得到第二級頁表的位置。

pdpt_phys = pml4[idx[0]] & ~0xfff;

同理,得到第三級頁表位置。

pd_phys = pdpt[idx[1]] & ~0xfff;

注意這裡有一個檢查

pdpt = (uint64_t*)(pdpt_phys+STRAIGHT_BASE);
if(!(pdpt[idx[1]] & PDE64_PRESENT))
goto new_pd;
if(!(pdpt[idx[1]] & PDE64_USER))
{
ret= -1;
goto end;
}

頁表項的分配是動態的,如果二級頁表相應的索引位置沒有三級頁表項,就會新分配一個三級頁表。並且在三級頁表的四級頁表索引位置分配新的四級頁表。

new_pd:
if((pd_phys = (uint64_t)hc_malloc(0, 0x1000)) == -1)
return -1;
pd = (uint64_t*)(pd_phys+STRAIGHT_BASE);
pdpt[idx[1]] = PDE64_PRESENT | PDE64_RW | PDE64_USER | pd_phys;
new_pt:
if((pt_phys = (uint64_t)hc_malloc(0, 0x1000)) == -1)
return -1;
pt = (uint64_t*)(pt_phys+STRAIGHT_BASE);
pd[idx[2]] = PDE64_PRESENT | PDE64_RW | PDE64_USER | pt_phys;
new_page:
for(int i = 0; i < pages; i++)
pt[idx[3]+i] = PDE64_PRESENT | (prot & PROT_WRITE ? PDE64_RW : 0) |
(prot & (PROT_READ | PROT_WRITE) ? PDE64_USER : 0) | (paddr+(i<<12));
if(remain > 0)
if(do_mmap_in_user(vaddr+(pages<<12), paddr+(pages<<12), remain<<12, prot) != vaddr+(pages<<12))
{
for(int i = 0; i < pages; i++)
pt[idx[3]+i] = 0;
return -1;
}

其中 PDE64_PRESENT 的值為1。

注意實際的linux系統中,物理頁的是從buddy system中申請的,但是在這裡是通過vmmcall,調用host中的pcalloc來進行分配。返回值是host虛擬地址空間中的內存,作為guest的物理地址使用。

hc_malloc:
mov rax, 0x21
mov rbx, rdi
mov rcx, rsi
vmmcall
ret

palloc是題目自己實現的類似於ptmalloc的分配機制,

case0x21: // palloc(phys_addr, size=0)
if((ret = palloc(arg[0], arg[1])) != -1)
memset(guest2host(vm, ret), 0, arg[1]);
break;

guest的物理地址,是host之前通過mmap為其分配的,通過guset2host即可實現地址空間的轉換。

#define guest2host(vm, addr) (vm->mem + addr)

再來看 munmap_user,前面操作同 do_mmap_in_user,一步一步將虛擬地址對應的頁表項找到,調用hc_free,逐頁釋放。再將對應的頁表項清零。

for(int i = 0; i < pages; i++)
{
uint64_t page_phys = pt[idx[3]+i] & ~((1<<9)-1);
hc_free((void*)page_phys, 0x1000);
pt[idx[3]+i] = 0;
}

漏洞就出現在munmap的逐頁釋放上。先來看hc_free的實現

雖然hc_free接受了兩個參數,但host不關心其傳入的第二個參數size

case0x22: // pfree(phys_addr);
ret = pfree(arg[0]);
break;

所以,連續的幾頁,在第一次hc_free時候,全被釋放掉了。如果我們構造下面這種代碼:

mmap(addr1, 0x2000, ......);
munmap(addr1,0x1000, ......);
mmap(addr2, 0x1000,........);

只要addr2之前沒有分配過頁表,mmap時候就會hc_malloc(0x1000)為其分配頁表。這樣,我們之前釋放掉的addr1就可能被申請出來,存在一個UAF的漏洞,addr1+0x1000可控,我們就有機會改寫addr2的頁表。

我沒有仔細去分析palloc/free 是如何實現的,但是可以use after free的這塊chunk,一定被作為addr2的三級或四級頁表。我們可以調試一下,如果是三級頁表申請到了這塊,我們剛開始的時候可以多申請一塊,創造兩塊UAF的chunk。經調試發現,是四級頁表申請到,因此改寫addr1+0x1000就可以改寫addr2的四級頁表。

再來看,在host中是如何將guest的虛擬地址映射到物理地址的。

uint64_t translate(struct vm *vm, uint64_t pml4_addr, uint64_t laddr, int write, int user)

調用translate將虛擬地址翻譯成物理地址,之後調用guest2host轉換成host里的虛擬地址。

具體的過程和mmap相同,一級一級的找到虛擬地址對應的頁表,並取出。paddr就是最終的guest物理地址。

paddr = (pt[idx[3]] & ~0xfff) | (laddr&0xfff);

只要能修改addr2的頁表項,就能將addr2映射到任意位置。

所以,只要利用剛才提到的UAF修改addr2的頁表項,將其映射到kernel base,就可以控制程序。

通過之前的提到的轉換方法,設置translate的rdx,並在

paddr = pt[(laddr >> 12) & 0x1FF] & 0xFFFFFFFFFFFFF000LL | laddr & 0xFFF;

下斷點,就可以讀出對應的頁表地址。

可以看到,這個頁表已經被我們修改。接下來就可以將任意的shellcode寫到kernel base上了。


推薦閱讀:
相关文章