作者: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分析一路走到了內核漏洞披露, 相當佩服作者這種求索精神, 可以想象一個人在代碼堆中翻閱各種實現細節時的辛酸, 也感謝作者如此詳細的披露與分享。
參考鏈接
本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/1843/
暫無評論