6月1號,我提交了一個linux內(nèi)核中的任意遞歸漏洞。如果安裝Ubuntu系統(tǒng)時選擇了home目錄加密的話,該漏洞即可由本地用戶觸發(fā)。如果想了解漏洞利用代碼和短一點的漏洞報告的話,請訪問https://bugs.chromium.org/p/project-zero/issues/detail?id=836。
背景知識
在Linux系統(tǒng)中,用戶態(tài)的棧空間通常大約是8MB。如果有程序發(fā)生了棧溢出的話(比如無限遞歸),棧所在的內(nèi)存保護頁一般會捕捉到。
Linux內(nèi)核棧(可以用來處理系統(tǒng)調(diào)用)和用戶態(tài)的棧很不一樣。內(nèi)核棧相對來說更短:32位x86架構(gòu)平臺為4096byte , 64位系統(tǒng)則有16384byte(內(nèi)核棧大小由THREAD_SIZE_ORDER 和 THREAD_SIZE 確定)。它們是由內(nèi)核的伙伴內(nèi)存分配器分配,伙伴內(nèi)存分配器是內(nèi)核常用來分配頁大小(以及頁大小倍數(shù))內(nèi)存的分配器,它不創(chuàng)建內(nèi)存保護頁。也就是說,如果內(nèi)核棧溢出的話,它將直接覆蓋正常的數(shù)據(jù)。正因如此,內(nèi)核代碼必須(通常也是)在棧上分配大內(nèi)存的時候非常小心,并且必須阻止過多的遞歸。
Linux上的大多數(shù)文件系統(tǒng)既不用底層設(shè)備(偽文件系統(tǒng),比如sysfs, procfs, tmpfs等),也不用塊設(shè)備(一般是硬盤上的一塊)作為備用存儲設(shè)備。然而, ecryptfs 和overlayfs是例外。這兩者是堆疊的文件系統(tǒng),這種文件系統(tǒng)會使用其他文件系統(tǒng)上的文件夾作為備用存儲設(shè)備(overlayfs則使用多個不同文件系統(tǒng)上的多個文件作為備用存儲設(shè)備)。被用作備用存儲設(shè)備的文件系統(tǒng)稱為底層文件系統(tǒng),其上的文件稱為底層文件。這種層疊文件系統(tǒng)的特點是它或多或少的會訪問底層文件系統(tǒng),并對訪問的數(shù)據(jù)做一些修改。 Overlayfs融合多個文件系統(tǒng),ecryptfs則進行了相應(yīng)的加密。
層疊文件系統(tǒng)實際上存在潛在風(fēng)險,因為其訪問虛擬文件系統(tǒng)的函數(shù)常會訪問到底層文件系統(tǒng)的函數(shù),相較直接訪問底層文件系統(tǒng)的句柄,這會增大棧空間。考慮這樣一個場景:如果用層疊文件系統(tǒng)作為另外一個層疊系統(tǒng)的備用存儲設(shè)備,由于每一層文件系統(tǒng)的堆疊都增大了棧空間,內(nèi)核棧就會在某些情況下溢出。但是,設(shè)置FILESYSTEM_MAX_STACK_DEPTH 限制文件系統(tǒng)的層數(shù),只允許最多兩層層疊文件系統(tǒng)放在非層疊文件系統(tǒng)上,就可以避免這個問題。
在Procfs偽文件系統(tǒng)上,系統(tǒng)中運行的每一個進程都有一個文件夾,每個文件夾包含一些描述該進程的文件。值得注意的是每個進程的“mem”,“ environ”和“cmdline”文件,因為訪問這些文件會同步訪問目標進程的虛擬內(nèi)存。這些文件顯示了不同的虛擬內(nèi)存地址范圍:
1.“mem”文件顯示了整個虛擬內(nèi)存地址范圍(需要PTRACE_MODE_ATTACH 權(quán)限)
2.“environ”文件顯示了mm->env_start 到mm->env_end的內(nèi)存范圍(需要PTRACE_MODE_READ權(quán)限)
3.“cmdline”文件顯示了mm->arg_start 到mm->arg_end的地址范圍(如果mm->arg_end的前一個字符是null 的話)
如果可以用mmap()函數(shù)映射“mem”文件的話(啥意義也沒有,別想太多),就可以映射成如下圖所示的樣子:
接下來,假設(shè)/proc/$pid/mem的映射有一些錯誤,那么在進程C里的內(nèi)存讀取錯誤,將會導(dǎo)致從進程B中映射的內(nèi)存出錯,進而導(dǎo)致進程B里出現(xiàn)其它的內(nèi)存錯誤,進而導(dǎo)致從A進程映射的內(nèi)存出錯,這就是一個遞歸內(nèi)存錯誤。
可是,現(xiàn)實中這是不可行的,“mem”,“environ”,“cmdline ”文件只能用VFS函數(shù)讀寫,mmap無法使用:
staticconst struct file_operations proc_pid_cmdline_ops = {
.read = proc_pid_cmdline_read,
.llseek = generic_file_llseek,
};
[...]
staticconst struct file_operations proc_mem_operations = {
.llseek = mem_lseek,
.read = mem_read,
.write = mem_write,
.open = mem_open,
.release = mem_release,
};
[...]
staticconst struct file_operations proc_environ_operations = {
.open = environ_open,
.read = environ_read,
.llseek = generic_file_llseek,
.release = mem_release,
};
相關(guān)ecryptfs文件系統(tǒng),比較有趣的一個細節(jié)在于它支持mmap()。用戶看到的內(nèi)存映射必須是解密的,而底層文件系統(tǒng)的內(nèi)存映射是加密的,因而ecryptfs 文件系統(tǒng)不能將mmap()函數(shù)直接映射到底層文件系統(tǒng)的mmap()函數(shù)上。Ecrypt 文件系統(tǒng)在內(nèi)存映射時使用了自己的頁緩存。
ecryptfs文件系統(tǒng)處理頁錯誤的時候,必須以某種方式讀取底層文件系統(tǒng)上加密的頁。這可以通過讀取底層文件文件系統(tǒng)的頁緩存(使用底層文件系統(tǒng)的mmap函數(shù))來實現(xiàn),但是這樣比較消耗內(nèi)存。于是它直接使用底層文件系統(tǒng)的 VFS讀取函數(shù)(通過kernel_read()),這樣做更加直接有效,但是這個做法有副作用,就是有可能會mmap() 到通常不能映射的解密后的文件(因為只要底層文件有讀權(quán)限并且包含合法的加密數(shù)據(jù), ecryptfs文件系統(tǒng)的mmap函數(shù)就能工作)。
漏洞分析
在此,我們就能描繪完整的攻擊方式了。首先創(chuàng)建一個進程A,進程號為$A。然后創(chuàng)建一個ecrypptfs 掛載/tmp/$A,使/proc/$A作為它的底層文件系統(tǒng)(ecryptfs 應(yīng)該只有一個 key,這樣文件名才不會被加密)。現(xiàn)在,如果/proc/$A下相應(yīng)的文件有合法的ecryptfs 文件頭的話,那么 /tmp/$A/mem, /tmp/$A/environ 和 /tmp/$A/cmdline就可以被映射。除非有 root 權(quán)限,否則無法將內(nèi)存映射到進程 A的00處,也就是 /proc/$A/mem 的開頭。因此從開始讀取 /proc/$/A 總是會返回-EIO,而且 /proc/$A/mem 不會有一個合法的 ecryptfs 文件頭。如此,environ 和 cmdline 文件才有攻擊的可能性。
在使用CONFIG_CHECKPOINT_RESTORE編譯的內(nèi)核(至少是Ubuntu的 distro 內(nèi)核)中,非特權(quán)用戶可以通過prctl(PR_SET_MM, PR_SET_MM_MAP, &mm_map,sizeof(mm_map), 0)設(shè)置mm_struct 中的 arg_start, arg_end, env_start 和 env_end值。這使得映射 /proc/$A/environ 和 /proc/$A/cmdline到任意虛擬內(nèi)存范圍成為可能。(不支持checkpoint-restore的內(nèi)核中,攻擊過程就稍微有點麻煩,但使用所需的參數(shù)區(qū)域和環(huán)境變量的長度重新執(zhí)行,然后取代部分棧空間的映射,還是有可能的。)
如果一個有效加密的ecryptfs文件被加載到進程A的內(nèi)存中,并且它的環(huán)境變量也被配置為指向這塊區(qū)域,那么環(huán)境變量區(qū)域里的解密形式的數(shù)據(jù)就可以在 /tmp/$A/environ文件中獲取。這個文件也可以被映射到進程B的內(nèi)存中。為了能夠重復(fù)該進程,某些數(shù)據(jù)需要反復(fù)加密,進而創(chuàng)建一個加密的matroska 文件,并將這個文件加載到進程 A的內(nèi)存中。這樣一來,映射互相進程解密環(huán)境變量區(qū)域的進程鏈就建立起來了:
如果映射到進程C和進程B的內(nèi)存相應(yīng)范圍內(nèi)沒有數(shù)據(jù),進程C 中的內(nèi)存錯誤(這個內(nèi)存錯誤可能是用戶空間產(chǎn)生也可能是由于用戶空間訪問內(nèi)核空間,比如通過copy_from_user()函數(shù))將會導(dǎo)致ecryptfs讀取 /proc/$B/environ ,進而導(dǎo)致進程B中的內(nèi)存錯誤,接下來導(dǎo)致ecryptfs讀取 /proc/$A/environ ,最后導(dǎo)致進程A中的進程錯誤。如此循環(huán)往復(fù),最終溢出內(nèi)核棧,使內(nèi)核崩潰。內(nèi)核棧如下:
[...]
[
[
[
[
[
[
[
[
[
[
[
[
[
[
[
[
[
[
[
[
[
[
[
[...]
關(guān)于這個漏洞的可利用性:利用該漏洞,需要能夠掛載/proc/$pid為ecryptfs文件系統(tǒng)。安裝完ecryptfs-utils包之后(如果安裝Ubuntu時選擇了home目錄加密, Ubuntu 會自動安裝),使用 /sbin/mount.ecryptfs_私有的setuid輔助函數(shù)就可以做到這一點。
漏洞利用
接下來的描述是平臺相關(guān)的,這里指amd64。
以前要利用這一類漏洞還是相當簡單的,可以直接覆蓋棧底的thread_info結(jié)構(gòu)體,用合適的數(shù)值重寫restart_block或者 addr_limit,然后根據(jù)所用方式,選擇執(zhí)行用戶空間映射的代碼,還是用copy_from_user() 和 copy_to_user() 直接讀寫內(nèi)核數(shù)據(jù)。
但是,restart_block已經(jīng)從thread_info結(jié)構(gòu)體中移除,并且由于棧溢出觸發(fā)時棧中有 kernel_read() 的棧幀,所以addr_limit已經(jīng)是KERNEL_DS,而且函數(shù)退出時將會重置成 USER_DS 。另外, Ubuntu 16.04以后的內(nèi)核都打開了CONFIG_SCHED_STACHK_END_CHECK 內(nèi)核配置選項。打開這個選項以后,每次調(diào)度到這個線程時, thread_info 結(jié)構(gòu)體上方的金絲雀值都會被檢查;如果金絲雀值不正確的話,內(nèi)核遞歸就會出錯然后崩潰。
由于thread_info結(jié)構(gòu)體中很難照到有價值的攻擊目標(同時移除thread_info中的數(shù)據(jù)并非有效的緩解措施),我就選擇了其它方式:溢出棧到棧之前的空間,然后利用棧和其它內(nèi)存空間之間會重合這一點。這種方式的問題就是一定要保證金絲雀值和 thread_info結(jié)構(gòu)中的其它成員不被覆蓋。棧溢出的內(nèi)存布局如下所示(綠色表示可以覆蓋,紅色表示不能覆蓋,黃色表示覆蓋后可能會有問題):
幸運的是,有些棧幀中存在空洞(如果遞歸的最底部采用cmdline而不是environ),遞歸的過程中就會有一個5個QWORD空洞沒有被訪問到。這些空洞足夠用來存放從SRACK_END_MAIC到flags的所有數(shù)據(jù)。這一點可以通過一個安全遞歸和一個內(nèi)核調(diào)試模塊來實現(xiàn),這個內(nèi)核調(diào)試模塊將棧中的所有空洞標綠便于觀察:
接下來的問題是空洞只會出現(xiàn)在特定的位置,而漏洞利用就需要空洞在準確的位置出現(xiàn)。下面有一些技巧可以用來對齊棧空間:
1.在每個遞歸層上都可以選擇“environ”文件或者“cmdline”文件,它們的棧幀大小和空洞模式都不一樣。
2.任何調(diào)用copy_from_user()都會導(dǎo)致內(nèi)存錯誤。甚至可以將寫入系統(tǒng)調(diào)用和VFS寫入句柄結(jié)合起來,所以每一個寫入系統(tǒng)調(diào)用和 VFS寫入句柄都會影響深度(合并深度可以計算出來,而不用測試每個變量)。
在測試了各種組合之后,我找到一組environ文件和cmdline文件, 還有write ()系統(tǒng)調(diào)用和進程的VFS寫句柄的組合。
隨后,就可以遞歸到之前分配的空間,而不會覆蓋任何危險數(shù)據(jù)了。然后暫停內(nèi)核線程的執(zhí)行,此時棧指針指向之前分配的內(nèi)存空間,這些內(nèi)存空間應(yīng)該用新的棧來覆蓋,然后繼續(xù)內(nèi)核線程的執(zhí)行。
為了暫停遞歸中內(nèi)核線程的執(zhí)行,在建立起映射鏈后,映射鏈最后的annonymous映射可以用FUSE映射取代( userfaultfd 函數(shù)并不適用,它不能捕捉遠程的內(nèi)存訪問)。
對于先前分配的內(nèi)存,我的exp使用管道(Pipes)。當寫入數(shù)據(jù)到新分配的空管道時,伙伴內(nèi)存分配器會分配一個內(nèi)存頁,來存放這些數(shù)據(jù)。我的exp通過管道內(nèi)存頁分配來填充大量內(nèi)存,所以使用clone()創(chuàng)建新進程時就會觸發(fā)內(nèi)存錯誤。這里使用clone() 而非fork(),因為調(diào)用clone()時只要控制好參數(shù),系統(tǒng)就會復(fù)制較少的信息,可以減少內(nèi)存分配的干擾。 Clone( ) 函數(shù)調(diào)用過程中,所有的管道內(nèi)存頁都被填充滿,除了第一次保存的 RIP值——遞歸進程暫停在FUSE中時,它保存在期望的 RSP 值之后。寫入較少的數(shù)據(jù)就能致使第二個管道寫入目標棧數(shù)據(jù),這些數(shù)據(jù)在 RIP控制實現(xiàn)之前就被使用,可能會導(dǎo)致內(nèi)核崩潰。隨后,遞歸進程在FUSE 中暫停時,第二次向所有管道寫入數(shù)據(jù),會覆蓋保存的 RIP值和其后的數(shù)據(jù),攻擊者也就能夠完全控制全新的棧了。
此時,最后一道防線就是KASLR了。Ubuntu支持KASLR ,只不過KASLR需要手動開啟。這個b最近該BUG已經(jīng)修復(fù)了,現(xiàn)在distros內(nèi)核應(yīng)該是默認就開啟KASLR的。雖說這項安全特性幫不上太大的忙,但畢竟KASLR不需要占用太多資源,開啟這項特性就顯得相當理所當然了。由于大多數(shù)的設(shè)備并不支持向內(nèi)核命令行傳輸特殊參數(shù),所以這里假設(shè)KASLR雖然編譯進了內(nèi)核,但仍處于未激活狀態(tài),攻擊者也知道內(nèi)核代碼和靜態(tài)數(shù)據(jù)的地址。
然后就可以用ROP在內(nèi)核里做各種事情了,漏洞利用具體有兩個方向可以繼續(xù)。可以使用ROP進行 commit_creds 類似操作。不過我用了另一個方法。在棧溢出過程中,原來addr_limit的KERNEL_DS 值保存了起來。棧一次次返回,最終將會把 addr_limit 重置為USER_DS。但如果我們直接返回到用戶空間, addr_limit 將保持 KERNEL_DS 。所以我這樣構(gòu)造新棧,或多或少復(fù)制了棧頂?shù)臄?shù)據(jù):
unsigned longnew_stack[] = {
0xffffffff818252f2,/* return pointer of syscall handler */
/* 16 uselessregisters */
0x1515151515151515,0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
(unsignedlong) post_corruption_user_code, /* user RIP */
0x33, /* userCS */
0x246, /*EFLAGS: most importantly, turn interrupts on */
/* user RSP*/
(unsignedlong) (post_corruption_user_stack + sizeof(post_corruption_user_stack)),
0x2b /* userSS */
};
殺掉FUSE服務(wù)進程后,遞歸進程繼續(xù)運行到post_corruption_user_code函數(shù)上。這個函數(shù)可以使用管道向任意內(nèi)核地址寫數(shù)據(jù),因為 copy_to_user()中的地址檢查已經(jīng)失效。
voidkernel_write(unsigned long addr, char *buf, size_t len) {
int pipefds[2];
if (pipe(pipefds))
err(1, "pipe");
if (write(pipefds[1], buf, len) != len)
errx(1, "pipe write");
close(pipefds[1]);
if (read(pipefds[0], (char*)addr, len) !=len)
errx(1, "pipe read tokernelspace");
close(pipefds[0]);
}
現(xiàn)在你就可以在用戶態(tài)舒服地執(zhí)行任意讀寫操作了。如果你想要root shell,可以覆蓋coredump函數(shù),它存儲在一個靜態(tài)變量里,然后觸發(fā)一個 SIGSEGV,就可以以root權(quán)限執(zhí)行coredump函數(shù):
char*core_handler = "|/tmp/crash_to_root";
kernel_write(0xffffffff81e87a60,core_handler, strlen(core_handler)+1);
漏洞修復(fù)
有兩個獨立的補丁可用于修復(fù)該BUG:其中,2f36db710093禁止通過ecryptfs打開沒有mmap函數(shù)的文件, e54ad7f1ee26禁止在procfs 上層疊任何東西,因為的確沒什么道理要在其上層疊任何東西。
不過,我還是寫了一個完整的root提權(quán)漏洞利用程序。我主要想說明linux棧溢出可能會以非常隱蔽的方式出現(xiàn),即便開啟了一些現(xiàn)有的漏洞緩解措施,它們?nèi)匀豢衫谩T谖覍懙穆┒磮蟾嬷校矣刑岬浇o內(nèi)核增加內(nèi)存保護頁,移除棧底部的 thread_info結(jié)構(gòu)體,這樣緩解這類漏洞的利用,有其他操作系統(tǒng)就是這么干的。Andy Lutomirski已經(jīng)開始著手這方面的工作,并發(fā)布了增加了內(nèi)存保護頁的補丁包: https://lkml.org/lkml/2016/6/15/1064。
* 本文譯者:Michael23,文章參考來源:Blogspot,轉(zhuǎn)載請注明來自FreeBuf黑客與極客(FreeBuf.COM)