文章翻译自LWN.net, March 22, 2021, 作者Vlastimil Babka: Patching until the COWs come home (part 1) [LWN.net],翻译非商业用途,仅供学习使用
译者按:Linux内核的COW功能虽然思想已然在广大程序员中人尽皆知,但实际的实现中会有各种复杂的场景与问题,至今仍未彻底完善,尤其是在对多个指向同一COW页面的PTE进行free的过程中,如何高效且安全的实现。本文主要描述了Linux内核中COW相关源码在2020-2021年间对此问题的两次重大更新。
内核的内存管理子系统建立在许多概念之上,其中有一关键机制被称为"写时复制"(Copy on Write,或简称COW)。COW背后的思想在概念上很简单,但实现的细节却很棘手,历史代码也充满了问题。对它进行任何更改都可能产生意想不到的后果,导致现有工作负载出现微妙的故障。因此,很令人惊奇的是,在不远的2020年,我们就可以看到两次对内核COW代码的重大更改;而这些更改带来了意外后果并造成了问题,则显得不那么神奇。到本文发表的时间2021年三月,距离其第一次变更已经接近十个月了,但其中一些问题仍未解决;而引起这些变化的原因------一个安全漏洞------也没有完全修复。下文会继续描述关于COW、出现的漏洞和最初修复方案的描述,后续文章(part2)将描述第一版修复方案后续出现的复杂情况。
COW是一种标准机制,用于在每个进程都希望拥有独立、私有副本的情况下,在多个进程之间共享的单个实例。例如,内存页面在多个进程之间共享,或者数据在多个文件之间的共享。要了解COW如何在内存管理子系统中使用,我们可以考虑当一个进程调用fork()
时会发生什么:该进程私有内存区域中的页面是不应该在父进程和子进程之间共享的,但是在fork()
调用期间,引入COW功能使内核不会为子进程创建这些页面的新副本,而会将父进程的相应页面映射到子进程页表中。而且父进程和子进程的页表条目都会被设置为只读(写保护)。
如果父/子进程尝试写入这些页面中的某一个,将会触发缺页中断(page fault),并且内核的缺页中断处理程序将创建该页面的新副本,分配新的内存页替换中断进程中的页表项(page-table entry, PTE),这个新的内存页允许写操作继续进行。这个动作通常被称为"breaking COW"(打断COW)。如果另一个进程在这之后尝试写入此页,则会再次发生缺页中断,因为该进程自己的PTE中仍然标记为只读。但是现在,缺页中断处理程序将识别到该页面不再共享,所以会直接将对应的PTE设置为可写的,之后进程恢复执行。
这个方案的好处是可以降低多个进程重复内存的消耗,同时降低在fork()调用期间复制内存页面所花费的CPU时间开销。一般来说,许多内存页面,对它做复制是毫无意义的,因为子进程可能在父进程或子进程写入这些内存页面之前就调用了exit()
或exec()
函数,对应的内存页面就被替换掉(exec()
)或者直接释放(exit()
)了。
虽然COW机制看起来很简单,但已经发生的各种事情证明,其中种种问题深藏在它的实现细节当中。COW领域最近的问题始于2020年,Linux内核为了修复一个漏洞而进行了两次重大更改,其导致了许多特殊情况,而且事实上这个漏洞也没有在所有场景下都能得到很好的修复,其中一些特例仍未得到彻底解决。
问题之始
关于COW机制问题研究的首个线索公开出现在2020年5月底的commit 17839856fd58("gup (get_user_pages系列函数):记录并实现'积极打断COW'")。变更日志没有完全描述问题场景,但其中所述已经足够令人不安:
(本次commit达到的)最终效果:get_user_pages()调用可能导致一个页面指针不再与原始虚拟内存相关联,而是与另一个接管它的虚拟内存关联并控制。即gup系列函数对于COW页面不会再返回原页面的引用,而是直接触发打断COW的行为。
对于这个提交是否修复了安全漏洞的疑虑,在注意到报告者标签提及Jann Horn时就可以打消了(译者按:Horn是Google Project Zero成员,Meltdown和Spectre的发现者),可以推测Horn的报告经过了适当的非公开安全渠道。出于公共安全问题,在不明确标记说明正在修复的情况下,立即将某些漏洞修复公之于众并是非新鲜事,尤其是在COW这种内存领域。相关的Project Zero问题在八月份也已经公开、十二月分配给了其漏洞编号CVE-2020-29374,都指向了上述提交已经作为了解决方案。
通常Project Zero会包含概念验证(PoC)代码,我们可以以该代码为依据来审视修复工作,而不仅仅依赖于不完整的提交日志。PoC的最重要部分如下所示:
C
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");
}
代码开始时分配一个匿名的私有页面,并在其中写入一些数据;然后调用fork()。此时,该页面变成了COW页面------通过将相应的页表项设置为只读,使得父进程无法对其进行写操作,且为子进程创建了一个完全相同的PTE。接着,在父进程被阻塞在sleep()中时,子进程创建了一个管道,并使用vmsplice()将页面传递给该管道。vmsplice()是类似于write()的系统调用,但它允许以零拷贝zero-copy方式传输页面内容。为实现这一点,内核通过get_user_page()或其变体之一获取源页面的引用(增加其引用计数),这些函数集合通常被称为"GUP"。然后子进程从自己的页表中取消映射该页面(但管道中的引用依然存在),并进入睡眠状态。
父进程从睡眠中醒来,并向页面写入新数据。因为页面表项被设置为只读,所以写操作会引发缺页中断。缺页中断处理程序判断这是一个写时复制(COW)中断,因为内存映射允许写访问,而PTE却被设置为只读。如果有多个进程映射该页面,则必须复制内容(即前面提到的打断COW),但如果只有一个映射,则可以直接将页面设为可写。内核依赖于page_mapcount()返回的值来确定某个页存在多少个映射关系。
此时会出现的问题是:在PoC执行到此处时,page_mapcount()只包括父进程的映射,因为子进程已经对该页面调用了munmap()。这个函数没有考虑到子进程仍然可以通过管道访问父进程的页面,它忽略了增加的页面引用计数。因此,内核允许父进程向页面写入新数据,而该页面不再被视为COW(写时复制)页面。最后,子进程唤醒并从管道中读取那些新数据,其中可能包含父进程未预料到子进程会看到的敏感信息。
译者按:data对应的页在调用GUP时会增加page引用计数_count,map计数_mapcount不改变。munmap释放的是map计数,最后父进程触发缺页中断时因为判断其_mapcount为1,所以没有复制新页而是直接进行了写。_mapcount是以多少个进程映射了该页计数的,因此在调用GUP时不能增加_mapcount的值。
问题的解决
首先,一个合理的问题是:为什么从父进程泄漏数据到子进程的潜在问题值得在实际场景中讨论,这两个进程通常都执行来自同一个二进制文件的代码,并且fork()只是代码中的一个分支。所以我们可以假设,要么二进制文件是可信任的,因此子进程也是可信任的;要么它不可信任,那么我们可能根本不应该让父进程去访问任何敏感数据。而且,在从受信任的二进制文件进行fork()之后紧接着exec()一个潜在恶意的二进制文件时,exec()也会在加载新二进制文件之前将所有共享页面从子进程地址空间中移除,这样看似乎问题并不严重。
但正如Project Zero所提到的,有些环境(比如Android)会出现每个进程都是从一个zygote进程fork而来且没有后续的exec()操作,这是为了提高性能。这可能导致类似于此漏洞的PoC攻击情况。
此外,此处提到的vmsplice()系统调用可能只是更广泛问题中的一个表现,因为内核中还有许多其他调用 GUP 函数的地方。所以一般来说,在父进程写入新内容到页面时,不要让子进程通过 COW 机制持有共享页面并访问到父进程的敏感数据,是值得考虑去解决的。
为了防止利用这种行为进行攻击,commit 17839856fd58避免了通过GUP获取对COW共享页面的引用(即使是只读引用)。现在所有这样的尝试都会直接打断COW并返回对新副本的引用。因此,在上面的PoC代码中,调用vmsplice()现在会导致子进程将相应页表项中共享的COW页面替换为一个新页面,并将其传递给管道。之后,子进程再也无法访问父进程的页面和其中写入的新内容。
这个提交中注明,一些GUP用户可能会遇到更糟糕的性能,特别是那些依赖于GUP的无锁变体接口(如get_user_pages_fast())的用户。更新日志进一步指出,随后会添加更精细化的规则,以适配明确可以安全地共享COW页面且不会被新的潜在敏感内容覆盖的场景。系统级的zero-copy就是这种情况之一。但除此之外,该变更作者Linus Torvalds预计对于GUP来说,这种积极的打断COW机制不会有根本性问题。Linux 5.8版本已经发布并包含了此次提交。
通常来说,问题到这里就可以画上句号了。但是,正如一开始提到的那样,COW 是一个复杂而微妙的系统。事实上问题才刚刚开始。本文的后半部分(part2)将深入探讨 COW 修复导致的一系列新问题涌现,并且这些问题至今仍未完全解决。