宋寶華:談一談Linux寫時拷貝(COW)的安全漏洞(1)

2022-01-10 10:00:11

寫時拷貝的原理我們沒什麼好贅述的,就是當P1 fork出來P2後,P1和P2會以唯讀的形式共用page,直到P1或者P2寫這個page的內容,才發生page fault導致寫的程序得到一份新的資料拷貝。 下面的程式碼演示了它的效果:

int data = 10;

int child_process()
{
	printf("Child process %d, data %d\n", getpid(), data);
	data = 20;
	printf("Child process %d, data %d\n", getpid(), data);
	_exit(0);
}

int main(int argc, char *argv[])
{
	int pid;
	pid = fork();

	if (pid == 0) {
		child_process();
	} else {
		sleep(1);
		printf("Parent process %d, data %d\n", getpid(), data);
		exit(0);
	}
}

上面的程式碼,執行的時候列印:

baohua@baohua-VirtualBox:~$ ./a.out 
Child process 3498, data 10
Child process 3498, data 20
Parent process 3497, data 10

子程序把10改為20後,父程序1秒後列印,得到的仍然是10。如果到這裡為止,你看不懂,這篇文章不適合你這樣的Linux初學者,請勿繼續往下閱讀。

從技術上來講,在父程序寫過資料後,子程序應該讀不到父程序新寫的資料;在子程序寫過資料後,父程序也應該讀不到子程序新寫的資料。這才符合「程序是資源封裝的單位」的本質定義。

如果都是上面的經典模型,那麼歲月靜好,與君白頭偕老。但是,總會有人在花田裡犯了錯,破曉前仍然沒有忘掉。這個COW技術,就爆出了巨大的漏洞,讓父子程序間可以向對方洩露寫過的新資料,成為了Linux核心的驚天大瓜。

我們先來看看是怎樣的一個程式,讓COW的人設崩塌了呢?

    static void *data;

    posix_memalign(&data, 0x1000, 0x1000);
    strcpy(data, "BORING DATA");

    if (fork() == 0) {
	// child
	int pipe_fds[2];
	struct iovec iov = {.iov_base = data, .iov_len = 0x1000 };
	char buf[0x1000];

	pipe(pipe_fds);
	vmsplice(pipe_fds[1], &iov, 1, 0);
	munmap(data, 0x1000);

	sleep(2);
	read(pipe_fds[0], buf, 0x1000);
	printf("read string from child: %s\n", buf);
   } else {
	// parent
	sleep(1);
	strcpy(data, "THIS IS SECRET");
   }

上面的程式,父子程序最初共用了data指向的0x1000這麼大1個page的內容。然後父程序在data裡面寫「BORING DATA」,之後,父程序fork子程序。子程序接下來建立了一個pipe,並用vmsplice,把data指向的buffer拼接到了pipe的寫端,而後子程序通過munmap()去掉data的對映,再睡眠2秒製造機會讓父程序在data裡面寫"THIS IS SECRET"。2秒後,子程序read pipe的讀端,這個時候,神奇的事情發生了,子程序讀到了父程序寫的祕密資料。

為什麼會發生這種事情呢?魔鬼就在細節裡。這裡面有2個細節:

1. 子程序munmap,導致data的mapcount減-1,這樣欺騙了Linux核心,使得父程序在寫THIS IS SECRET的時候,並不會發生COW,因為核心理解data只有1個程序有map,製造拷貝顯然是多餘的。

2.子程序呼叫vmsplice,這是一種0拷貝技術,避免管道的寫端從userspace往kernel space進行拷貝。vmsplice的底層,會通過傳說中的GUP(get_user_pages)技術,來增加page的參照計數,導致page不會被回收和釋放。

所以,子程序通過pipe的寫端hold住了老的page,然後通過read(),把這個page經過父程序寫後的新內容讀出來了。這真地很神奇有木有!這個漏洞的編號是CVE-2020-29374,它的官方描述如下:

An issue was discovered in the Linux kernel before 5.7.3, related to mm/gup.c and mm/huge_memory.c. The get_user_pages (aka gup) implementation, when used for a copy-on-write page, does not properly consider the semantics of read operations and therefore can grant unintended write access, aka CID-17839856fd58.

這個瓜大地直接驚動了祖師爺Linus Torvalds發patch來進行「修復」,Linus的「修復」patch編號是17839856fd58 ("gup: document and work around 'COW can break either way' issue")。祖師爺的修復方法比較簡單直接,對於任何要COW的page,如果你做GUP,哪怕你後面對這個page的行為是唯讀的,也要得到一份新的copy。對應前面的參考程式碼,其實就是子程序呼叫vmsplice的行為,打破了COW的常規邏輯,之後子程序read(pipe[0])的時候,讀到的是新的page。

所以沒有Linus的patch的時候,data的記憶體在父子程序分佈如下:

有了Linus的patch後,data的記憶體在父子程序分佈如下:

顯然,這樣之後,父程序寫data後,寫的是藍色區域,子程序讀的是黃色的區域,這樣子程序是肯定讀不到SECRET資料了。

Linus是永遠正確的?必須是!當Linus把這個patch合入5.8核心的時候,人們以為故事就此結束了,卻沒想到瓜才剛剛開始。作為Linus核心的吃瓜群眾,我們的激情從不曾磨滅,因為「吃在嘴裡,甜在心裡」,吃瓜的甜蜜誘惑引誘我們一步步走入Linux核心的深淵,誤了一生。

redhat的Peter Xu童鞋,在2020年8月報了一個bug,直指祖師爺的patch造成了問題,因為它破壞了類似userfaultfd-wp和umapsort這樣的應用程式。注意,子曾經曰過,「If a change results in user programs breaking, it's a bug in the kernel. We never EVER blame the user programs」,有圖有真相:

一個典型的umap程式碼倉庫在:

GitHub - LLNL/umap: User-space Page Management

這種app利用userfaultfd的原理,在userspace處理page fault,從而提供userspace特定的page cache evict機制。關於userfaultfd的原理和用法,你可以閱讀我之前的文章

宋寶華:論一切都是檔案之匿名inode_宋寶華-CSDN部落格

簡單來說,umap這樣的程式通過3個步驟來evict page。

  (1) 用mode=WP來對即將被evict的page執行防寫,從而block對於page P的寫,保持page的clean;
  (2) 把page P寫入磁碟;
  (3) 通過MADV_DONTNEED來evict這個page。

其中的第2步會用到一個read形式的GUP。不過,Linus已經通過他的patch,強迫哪怕是read形式的GUP也要發生COW,這樣觸發了一個app完全沒有預期到的page fault,導致uffd執行緒出錯hang死。顯然Linus自己break了userspace,等待他的結局是,他的patch的行為也要被revert掉。這一次仍然是Linus親自出手,他提交了09854ba94c6a ("patch: mm: do_wp_page() simplification"),導致程式的行為再次發生了翻天覆地的變化。

前面我們提到,通過Linus的17839856fd58 ("gup: document and work around 'COW can break either way' issue") patch,子程序vmsplice的GUP行為會強迫子程序進行COW,得到新的拷貝。但是,現在Linus不這個幹了,vmsplice的pipe寫端還是指向老的頁面,他重新選擇了在父程序進行實際的寫的時候,不再只是傻傻地判斷page的mapcount,他還會判斷是不是有人間接通過GUP等形式,增加了page的參照計數,如果是,則在父程序寫的時候,進行copy-on-write,這個時候,父程序寫過"THIS IS SECRET"後,data在父子程序的記憶體分佈變成:

 由於父程序是在新的黃色page進行寫,而子程序用的是老的藍色page,所以"THIS IS SECRET"不會洩露給子程序。Linus的最主要修改是直接變更了do_wp_page()函數,邏輯變成:

        struct page *page = vmf->page;

        if (page_count(page) != 1)
                goto copy;
        if (!trylock_page(page))
                goto copy;
        if (page_mapcount(page) != 1 && page_count(page) != 1) {
                unlock_page(page);
                goto copy;
        }
        /* Ok, we've got the only map reference, and the only
         *  page count reference, and the page is locked,
         * it's dark out, and we're wearing sunglasses. Hit it.
         */
        wp_page_reuse(vmf);
        unlock_page(page);
        return VM_FAULT_WRITE

因為GUP的行為會增加page的refcount,從而觸發父程序在寫data的wp的page fault裡面,進行COW。所以Linus是守信用的,自己提交的patch犯的錯,含淚也要revert掉。

那麼故事就此結束了嗎?正當所有的吃瓜群眾都把西瓜皮扔到垃圾桶準備休息一陣的時候,蕾神再次以驚天之錘,錘向了「花田裡犯的錯」。

累了,睡覺了。欲知後事如何,請聽下回分解。