LWN:COW以及它最近引出的一系列问题 - 第一部分

关注了就能看到更多这么棒的文章哦~

Patching until the COWs come home (part 1)

March 22, 2021

This article was contributed by Vlastimil Babka
DeepL assisted translation
https://lwn.net/Articles/849638/

内核中的内存管理子系统是建立在许多基础概念之上的,其中一个概念叫做 "写时拷贝,copy on write",也就是 "COW"。COW 背后概念很简单,但细节却很棘手,很多历史信息需要注意。对其进行任何改变都可能会产生意想不到的后果,从而对现存的一些 workload 造成很难以下察觉到的破坏。因此,去年我们看到内核的 COW 代码发生了两次大的变化,这是很令人惊讶的。不过,不出意料,这两次改动都产生了意想不到的后果,产生了一些破坏。在第一次改动已经过来将近十个月的今天,由其产生的一些问题仍然未能得到修复,而引入这个改动的最初原因(为了修复一个安全漏洞)也其实没有得到完全修复。请继续阅读来了解 COW、该漏洞以及起初的 fix。本系列的最后一篇文章将介绍由此引出的棘手情况。

Copy on write 是一种标准机制,用在进程之间共享某个对象的单一实例(single instance)的情况下,让每个进程都感觉到好像拥有了该对象的独立、私有的副本。比如进程间共享的内存页,或文件间共享的数据扩展(data extents)。要了解 COW 如何在内存管理子系统中使用的话,可以考虑一下当一个进程调用 fork()时发生了什么情况:该进程自己的私有内存区域中的 pages(内存页面)不应该在父进程和子进程之间共享。但是,在 fork()调用期间,内核并不会为子进程创建新的 page 的副本,而是简单地将父进程的 page 映射到子进程的 page table 中。但是,父进程和子进程中的 page-table entry(页表条目)都被设置为只读(写保护)了。

如果任何一个进程试图向其中的某个 page 进行写入操作,就会发生 page fault,内核的 page fault 处理程序会对此 page 创建一个新的副本,然后用一个指向新 page 的 PTE(page-table entry) 来替换掉发生 page fault 的进程中的 PTE,这个 PTE 就允许写入了。这个动作通常被称为 "breaking COW"。如果其他进程再尝试向同一 page 写入,就会发生另一个 page fault,因为该进程的 PTE 仍然被标记为只读。但现在,page fault 处理程序将认识到该 page 不再是共享的,因此,PTE 可以直接被修改成可写的,进程可以完成写入操作了。

这种方案的好处是降低了内存消耗,减少了在 fork() 调用过程中复制页面所需的 CPU 时间。通常很多 page 都永远不会需要进行这个复制动作,因为子进程很可能会在父进程或子进程对这些 page 进行写入之前就调用了 exit()或 exec()。

虽然 COW 机制看起来很简单,但魔鬼总是隐藏在细节中,这一点在过去就已经充分展示给我们了。最近出现的这类麻烦之一是从 2020 年开始的,它导致了两个重大的改动,主要是试图修复一个漏洞(实际上这个漏洞仍然没有在所有场景中得到修复)并导致了许多 corner case,其中一些仍然没有完全处理干净。

The trouble begins

COW 机制出现问题的第一个公开迹象出现在 2020 年 5 月底的commit 17839856fd58("gup: document and work around 'COW can break either way' issue") 中。这个 changelog 并没有完全讲清楚出错的场景,但还是提供了一些看起来不太对的线索:

最终结果是:get_user_pages() 这个调用可能会导致一个 page pointer 不再与原来的 VM 相关联,而是与另一个 VM 相关联,并完全由另一个 VM 控制了。

当我们注意到 Reported-by 标签中提到 Jann Horn 时,对于该 commit 是否修复了安全漏洞就不再有怀疑了。Horn 的报告大概是通过一些非公开的安全渠道提交的。在没有明确标记某些漏洞是否真的漏洞之前就立即公开某些漏洞的修复方案,这并不是什么新鲜事,尤其是在 COW 领域。后来,相关的 Project Zero issue 内容在 8 月份被公开,在 12 月份的时候给它分配了 CVE-2020-29374 这个编号,两者都指向了上述 commit 作为最终的 fix。

由于 Project Zero issue 里也描述了概念验证(PoC,proof-of-concept)代码,我们可以在根据该代码来仔细查看 fix commit 的内容了,而不用依赖不完整的 commit log。PoC 中最重要的部分如下:

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");
}

这段代码先分配了一个匿名的私有页(anonymous, private page),并在其中写入一些数据,然后调用 fork()。这时,页面变成了 COW page,对父进程来说是通过修改相应的页表项改为只读来进行写保护的;对子进程来说则创建了一个相同的 PTE。然后,当父进程在 sleep() 里面被阻塞时,子进程创建一个管道(pipe),并通过 vmsplice()将 page 传递给该管道,这个系统调用类似于 write(),但它允许对 page 的内容进行零拷贝(zero-copy)方式地数据传输。为了实现这一点,内核通过 get_user_page() 或它的变体在 source page 上取得一个引用(增加它的引用计数),这些函数通常被统称为 "GUP"。然后子进程从它自己的 page table 中对该页进行 unmap(但保留管道中的引用)并进入睡眠状态。

父进程从睡眠中醒来,向 page 写入新数据。page table 是写保护的所以写操作会导致 page fault。page fault 处理程序可以判断出这是 COW page 发生的 fault,依据是当前的 mapping 允许写访问,而 PTE 是有写保护的。如果有更多的进程对页面进行映射,那么内容将不得不被复制(破坏 COW),但是如果只有一个单一映射,page 就可以直接变成可写的。内核依靠 page_mapcount()返回的值来确定存在多少个映射。

问题是:page_mapcount()在 PoC 执行的这一点上只包括了父进程的 mapping,因为子进程的 mapping 已经对该 page 调用了 munmap()。这个函数没有考虑到子进程仍然可以通过管道访问到父进程的 page,忽略了当前的 page 引用计数已经增加了。因此,内核就允许父进程向该 page 写入了新的数据,该 page 也就不再被认为是 COW 页。最后,子进程醒来,从管道中读取新的数据,其中可能包括父进程不希望子进程看到的敏感信息。

Corralling the problem

有人可能会问,这种从父进程到子进程泄露数据的问题,在实际中为什么很重要呢?两个进程通常都是在执行同一个二进制程序,而 fork() 只是作为代码中的一个分支而已。所以我们可以假设,要么二进制是可信的,因此子进程也是可信的;要么二进制程序本身就是不可信的,那么我们可能一开始就不应该让父进程访问任何敏感数据。而且,从一个受信任的二进制程序 fork()之后如果担心会用 exec() 来加载一个恶意二进制文件的情况,那么 exec()本身会在加载新的二进制文件之前就删除子进程地址空间中的所有共享页面。

但是,正如 Project Zero issue 中提到的那样,有一些环境,比如 Android,出于性能的考虑,每个进程都是从同一个 zygote 进程中 for 出来的,后续没有执行 exec() 操作。这种场景看起来就很像是这个 bug 的 PoC exploit 所针对的情况了。

此外,vmsplice() 这个 syscall 可能只是一个更广泛的问题其中一种表现形式,因为在内核中还有很多其他地方调用了 GUP 函数。所以一般来说,最好不要让子进程在父进程向 page 写入新内容的同时,仍能保留通过 COW 机制与父进程共享的 page。

为了防止这种行为,commit 17839856fd58 禁用了通过 GUP 获取 COW 共享页面的引用(哪怕是只读引用也不可以)。现在,所有这样的做法都会导致打破 COW 状态,并返回一个指向新副本的引用。因此,在上面的 PoC 代码中,调用 vmsplice()现在会使子进程在相应的页表项中用新的 page 替换掉这个 shared COW page,然后将新 page 传递给管道。之后,子进程不再能访问到父进程的 page 以及其写入那里的新内容了。

commit 中也提到对于一些 GUP 调用来说,性能可能会变差,尤其是那些依赖 get_user_pages_fast() 这样的 lockless 类型接口的地方。changelog 还提到说,今后可能会添加更细化的规则来针对那些很明确可以继续共享 COW page 的情况(也就是这个 page 永远不会写入新的、潜在的敏感内容)。系统公用的零页(system-wide zero-page)就是这类情况的一个例子。但除此之外,Linus Torvalds(这个 commit 的作者)预计对 GUP 进行的这种更积极的 COW-breaking 方法不会产生什么根本性的问题。Linux 5.8 正式发布的时候已经包含了这笔 commit。

人们可能认为,问题就这么结束了。但是,正如一开始提到的,COW 是一头复杂而微妙的野兽。事实上问题才刚刚开始。本文后半部分将深入探讨 COW 的 fix 是如何导致出现了大量新问题,而这些问题仍然没有得到彻底解决。

全文完
LWN 文章遵循 CC BY-SA 4.0 许可协议。

欢迎分享、转载及基于现有协议再创作~

长按下面二维码关注,关注 LWN 深度文章以及开源社区的各种新近言论~

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值