close
作者:ghost461@知道創宇404實驗室時間:2022年3月11日
簡介

2022年2月23日, Linux內核發布漏洞補丁, 修復了內核5.8及之後版本存在的任意文件覆蓋的漏洞(CVE-2022-0847), 該漏洞可導致普通用戶本地提權至root特權, 因為與之前出現的DirtyCow(CVE-2016-5195)漏洞原理類似, 該漏洞被命名為DirtyPipe。

在3月7日, 漏洞發現者Max Kellermann詳細披露了該漏洞細節以及完整POC。Paper中不光解釋了該漏洞的觸發原因, 還說明了發現漏洞的故事, 以及形成該漏洞的內核代碼演變過程, 非常適合深入研究學習。
漏洞影響版本:5.8 <= Linux內核版本 < 5.16.11 / 5.15.25 / 5.10.102

漏洞復現

在ubuntu-20.04-LTS的虛擬機中進行測試, 內核版本號5.10.0-1008-oem, 在POC執行後成功獲取到root shell

從POC看漏洞利用流程提交投稿

限於篇幅,這裡截取POC的部分代碼
static void prepare_pipe(int p[2]){ if (pipe(p)) abort(); // 獲取Pipe可使用的最大頁面數量 const unsigned pipe_size = fcntl(p[1], F_GETPIPE_SZ); static char buffer[4096]; // 任意數據填充 for (unsigned r = pipe_size; r > 0;) { unsigned n = r > sizeof(buffer) ? sizeof(buffer) : r; write(p[1], buffer, n); r -= n; } // 清空Pipe for (unsigned r = pipe_size; r > 0;) { unsigned n = r > sizeof(buffer) ? sizeof(buffer) : r; read(p[0], buffer, n); r -= n; }}int main(int argc, char **argv){ ...... // 只讀打開目標文件 const int fd = open(path, O_RDONLY); // yes, read-only! :-) ...... // 創建Pipe int p[2]; prepare_pipe(p); // splice()將文件1字節數據寫入Pipe ssize_t nbytes = splice(fd, &offset, p[1], NULL, 1, 0); ...... // write()寫入任意數據到Pipe nbytes = write(p[1], data, data_size); // 判斷是否寫入成功 if (nbytes < 0) { perror("write failed"); return EXIT_FAILURE; } if ((size_t)nbytes < data_size) { fprintf(stderr, "short write\n"); return EXIT_FAILURE; } printf("It worked!\n"); return EXIT_SUCCESS;}
創建pipe;
使用任意數據填充管道(填滿, 而且是填滿Pipe的最大空間);
清空管道內數據;
使用splice()讀取目標文件(只讀)的1字節數據發送至pipe;
write()將任意數據繼續寫入pipe, 此數據將會覆蓋目標文件內容;
只要挑選合適的目標文件(必須要有可讀權限), 利用漏洞Patch掉關鍵字段數據, 即可完成從普通用戶到root用戶的權限提升, POC使用的是/etc/passwd文件的利用方式。
仔細閱讀POC可以發現, 該漏洞在覆蓋數據時存在一些限制, 我們將在深入分析漏洞原理之後討論它們。

復現原始Bug

在作者的paper中可以了解到, 發現該漏洞的起因不是專門的漏洞挖掘工作, 而是關於日誌服務器多次出現的文件錯誤, 用戶下載的包含日誌的gzip文件多次出現CRC校驗位錯誤, 排查後發現CRC校驗位總是被一段ZIP頭覆蓋。
根據作者介紹, 可以生成ZIP文件的只有主服務器的一個負責HTTP連接的服務(為了兼容windows用戶, 需要把gzip封包即時封包為ZIP文件), 而該服務沒有寫入gzip文件的權限。
即主服務器同時存在一個writer進程與一個splicer進程, 兩個進程以不同的用戶身份運行, splicer進程並沒有寫入writer進程目標文件的權限, 但存在splicer進程的數據寫入文件的bug存在。
簡化兩個服務進程
根據描述, 簡易還原出bug觸發時最原本的樣子, poc_p1與poc_p2兩個程序:

編譯運行poc_p1程序, tmpFile內容為全A

運行poc_p2程序, tmpFile文件時間戳未改變, 但文件內容中出現了B

仔細觀察每次出現髒數據的間隔, 發現恰好為4096字節, 4kB, 也是系統中一個頁面的大小

如果將進程可使用的全部Pipe大小進行一次寫入/讀出操作, tmpFile的內容發生了變化

同時可以注意到, tmpFile文件後續並不是全部被B覆蓋, 而是在4096字節處保留了原本的內容

此時不執行任何操作, 重啟系統後, tmpFile將變回全A的狀態, 這說明, poc_p2程序對tmpFile文件的修改僅存在於系統的頁面緩存(page cache)中。
以上便是漏洞出現的初始狀態, 要分析其詳細的原因, 就需要了解造成此狀態的一些系統機制。

Pipe、splice()與零拷貝

限於篇幅, 這裡簡要介紹一下該漏洞相關的系統機制
CPU管理的最小內存單位是一個頁面(Page), 一個頁面通常為4kB大小, linux內存管理的最底層的一切都是關於頁面的, 文件IO也是如此, 如果程序從文件中讀取數據, 內核將先把它從磁盤讀取到專屬於內核的頁面緩存(Page Cache)中, 後續再把它從內核區域複製到用戶程序的內存空間中;
如果每一次都把文件數據從內核空間拷貝到用戶空間, 將會拖慢系統的運行速度, 也會額外消耗很多內存空間, 所以出現了splice()系統調用, 它的任務是從文件中獲取數據並寫入管道中, 期間一個特殊的實現方式便是: 目標文件的頁面緩存數據不會直接複製到Pipe的環形緩衝區內, 而是以索引的方式(即 內存頁框地址、偏移量、長度 所表示的一塊內存區域)複製到了pipe_buffer的結構體中, 如此就避免了從內核空間向用戶空間的數據拷貝過程, 所以被稱為"零拷貝";
管道(Pipe)是一種經典的進程間通信方式, 它包含一個輸入端和一個輸出端, 程序將數據從一段輸入, 從另一端讀出; 在內核中, 為了實現這種數據通信, 需要以頁面(Page)為單位維護一個環形緩衝區(被稱為pipe_buffer), 它通常最多包含16個頁面, 且可以被循環利用;
當一個程序使用管道寫入數據時, pipe_write()調用會處理數據寫入工作, 默認情況下, 多次寫入操作是要寫入環形緩衝區的一個新的頁面的, 但是如果單次寫入操作沒有寫滿一個頁面大小, 就會造成內存空間的浪費, 所以pipe_buffer中的每一個頁面都包含一個can_merge屬性, 該屬性可以在下一次pipe_write()操作執行時, 指示內核繼續向同一個頁面繼續寫入數據, 而不是獲取一個新的頁面進行寫入。

描述漏洞原理
splice()系統調用將包含文件的頁面緩存(page cache), 鏈接到pipe的環形緩衝區(pipe_buffer)時, 在copy_page_to_iter_pipe 和 push_pipe函數中未能正確清除頁面的"PIPE_BUF_FLAG_CAN_MERGE"屬性, 導致後續進行pipe_write()操作時錯誤的判定"write操作可合併(merge)", 從而將非法數據寫入文件頁面緩存, 導致任意文件覆蓋漏洞。
這也就解釋了之前原始bug造成的一些問題:
由於pipe buffer頁面未清空, 所以第一次poc_p2測試時, tmpFile從4096字節才開始被覆蓋數據;
splice()調用至少需要將文件頁面緩存的第一個字節寫入pipe, 才可以完成將page_cache索引到pipe_buffer, 所以第二次poc_p2測試時, tmpFile並沒有全部被覆蓋為"B", 而是每隔4096字節重新出現原始的"A";
每一次poc_p2寫入的數據都是在tmpFile的頁面緩存中, 所以如果沒有其他可寫權限的程序進行write操作, 該頁面並不會被內核標記為「dirty」, 也就不會進行頁面緩存寫會磁盤的操作, 此時其他進程讀文件會命中頁面緩存, 從而讀取到篡改後到文件數據, 但重啟後文件會變回原來的狀態;
也正是因為poc_p2寫入的是tmpFile文件的頁面緩存, 所以無限的循環會因文件到尾而寫入失敗, 跳出循環。
閱讀相關源碼
要了解漏洞形成的細節, 以及漏洞為什麼不是從splice()引入之初就存在, 還是要從內核源碼了解Pipe buffer的can_merge屬性如何迭代發展至今,
Linux 2.6, 引入了splice()系統調用;
Linux 4.9, 添加了iov_iter對Pipe的支持, 其中copy_page_to_iter_pipe()與push_pipe()函數實現中缺少對pipe buffer中flag的初始化操作, 但在當時並無大礙, 因為此時的can_merge標識還在ops即pipe_buf_operations結構體中。如圖, 此時的buf->ops = &page_cache_pipe_buf_ops操作會使can_merge屬性為0, 此時並不會觸發漏洞, 但為之後的代碼迭代留下了隱患;

Linux 5.1, 由於在眾多類型的pipe_buffer中, 只有anon_pipe_buf_ops這一種情況的can_merge屬性是為1的(can_merge字段在結構體中占一個int大小的空間), 所以, 將pipe_buf_operations結構體中的can_merge屬性刪除, 並且把merge操作時的判斷改為指針判斷, 合情合理。正是如此, copy_page_to_iter_pipe()中對buf->ops的初始化操作已經不包含can_merge屬性初始化的功能了, 只是push_write()中merge操作的判斷依然正常, 所以依然不會觸發漏洞;

page_cache_pipe_buf_ops類型也在此時被修改

然後是新的判斷can_merge的操作, 直接判斷是不是anon_pipe_buf_ops類型即可

Linux 5.8中, 把各種類型的pipe_buf_operations結構體進行合併, 正式把can_merge標記改為PIPE_BUF_FLAG_CAN_MERGE合併進入flag屬性中, 知道此時, 4.9補丁中沒有flag字段初始化的隱患才真正生效
合併後的anon_pipe_buf_ops不能再與can_merge強關聯

再次修改了merge操作的判斷方式

添加新的PIPE_BUF_FLAG_CAN_MERGE定義, 合併進入pipe buffer的flag字段

內核漏洞補丁, 在copy_page_to_iter_pipe()和push_pipe()調用中專門添加了對buffer中flag的初始化。

拓展與總結

關於該漏洞的一些限制:
顯而易見的, 被覆寫的目標文件必須擁有可讀權限, 否則splice()無法進行;
由於是在pipe_buffer中覆寫頁面緩存的數據, 又需要splice()讀取至少1字節的數據進入管道, 所以覆蓋時, 每個頁面的第一個字節是不可修改的, 同樣的原因, 單次寫入的數據量也不能大於4kB;
由於需要寫入的頁面都是內核通過文件IO讀取的page cache, 所以任意寫入文件只能是單純的「覆寫」, 不能調整文件的大小;
該漏洞之所以被命名為DirtyPipe, 對比CVE-2016-5195(DirtyCOW), 是因為兩個漏洞觸發的點都在於linux內核對文件讀寫操作的優化(寫時拷貝/零拷貝); 而DirtyPipe的利用方式要比DirtyCOW的更加簡單, 是因為DirtyCOW的漏洞觸發需要進行條件競爭, 而DirtyPipe可以通過操作順序直接觸發;
值得注意的是, 該內核漏洞不僅影響了linux各個發行版, Android或其他使用linux內核的IoT系統同樣會受到影響; 另外, 該漏洞任意覆蓋數據不只是影響用戶或系統文件, 塊設備、只讀掛在的鏡像等數據一樣會受到影響, 基於此, 實現容器穿透也是有可能的。
一點個人總結, 想想自己剛開始做漏洞復現的時候, 第一個復現的內核提權就是大名鼎鼎的DirtyCOW, 所以看到DirtyPipe就不由得深入研究一下。這個漏洞的發現經歷也非常有趣, 作者居然是從軟件bug分析一路走到了內核漏洞披露, 相當佩服作者這種求索精神, 可以想象一個人在代碼堆中翻閱各種實現細節時的辛酸, 也感謝作者如此詳細的披露與分享。
參考鏈接
Max Kellermann的paper
https://dirtypipe.cm4all.com/
Linux內核補丁
https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=9d2231c5d74e13b2a0546fee6737ee4446017903
android補丁
https://android-review.googlesource.com/c/kernel/common/+/1998671
漏洞POC
https://github.com/Arinerron/CVE-2022-0847-DirtyPipe-Exploit

作者名片

往期熱門

(點擊圖片跳轉)


arrow
arrow
    全站熱搜
    創作者介紹
    創作者 鑽石舞台 的頭像
    鑽石舞台

    鑽石舞台

    鑽石舞台 發表在 痞客邦 留言(0) 人氣()