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

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

Patching until the COWs come home (part 2)

March 25, 2021
This article was contributed by Vlastimil Babka
DeepL assisted translation
https://lwn.net/Articles/849876/

在上一次的文章中,我们介绍了这种用来避免不必要的内存 page 复制的 COW 机制(copy-on-write),然后详细介绍了该机制中的一个可能会导致敏感数据被泄露的错误。Linus Torvalds 写了一个 patch 并合入 5.8 内核,似乎修复了这个问题并且没有对系统其他地方产生副作用。但是,COW 是一个极端复杂的机制,经常会出现意料之外的情况。这个故事其实距离结束还很远。

Torvalds 的期望很快就被证明是过于乐观了。2020 年 8 月,Peter Xu 报告了一个 bug,是关于 userfaultfd() 的。这是一个可以在 user space 进程中来处理 page fault 的机制。此机制允许这个处理进程将一部分内存区域改成 write-protected,并在后续这个范围内发生写入事件时会通知此进程。这个功能的一个使用场景是用来在监控进程(monitoring process)将这块内存的内容写入下一级的存储设备时,阻止这部分内存被修改。然而,这种写入操作可能会引发对这些写保护状态的页面进行只读类型的的 get_user_pages()(GUP)调用,这本身是没有问题的。但上次讲到,Torvalds 的 fix 方法就是将只读的 get_user_pages() 调用都改成跟写访问的调用一样了,这样做的目的是为了强制打破相关 page 上的 COW 引用。在 userfaultfd()的情况下,这会导致监控进程中产生一个意料之外的 write fault,从而导致这个进程 hang。

Xu 最初提出的 fix,是希望沿着原本的 fix 方案继续细化,根据更加细化的规则来打破 GUP 引起的 COW,在其中针对 userfaultfd() 增加了一些特有的处理。但在讨论过程中,Torvalds 却提出了一个完全不同的方法,这引出了 Xu 的另一套 patch。这些 patch 基本上将 Torvalds 的改动都 revert 掉了,也就是放弃了总是打破 GUP call 相关的 COW 的做法。取而代之的是利用 commit 09854ba94c6a("mm:do_wp_page() simplification")来修改了用来处理 write-protected page(受到写保护的 page)的 do_wp_page() 函数,确保采用更严格地检查来确认 page 是否被多个进程共享。

有了这个 commit 之后,只有当 page 是只被一个进程映射、并且引用数没有增加之时(比如来自一个 GUP 操作),才允许在不打破 COW 的情况下对 page 进行写入。在原来的 vmsplice() PoC 中,调用 vmsplice() 的子进程就又拿到跟父进程共享的 page 的引用了,而且在从自己的 page table 中对这个 page 进行 unmap 之后也仍然能够保留这个引用。然而,当父进程试图向 page 进行写入时,page-fault handler 就会注意到它的引用数增加过了,于是就决定打破 COW,从而提供给父进程一个新的副本(子进程无法访问)。这个想法和实现也更加简单,并且由于减少了 page locking,应该性能会有提高。确实也正如 Torvalds 所期望,后来 Intel test bot 也证实了这一点。

另一个 bug 是 Mikulas Patocka 在 9 月份报出来的,他观察到在 DAX 文件系统(一个提供对底层 persistent memory 进行直接访问的文件系统)上运行 strace 会触发内核 warning 并导致被跟踪的进程被杀死。这个 bug 没有完全分析出其 root cause,但 git bisect 查出来也是同一个 COW 问题,上面提到的用来修复 userfaultfd() bug 的 patch 也修复了这个 strace 的 bug。

Touble with RDMA

当时,只有 Hugh Dickins 对这种依靠增加 reference count 来做决定的做法表示担心。他列举了几个例子,说明这种方法在 Linux kernel 2.6 的时代就出过问题。不过他的担心没有得到人们的重视,几天后 Torvalds 将这组 patch 合入了,并且后来发布在了 Linux 5.9-rc5 中。但 Dickins 的担心被证明是有道理的,Jason Gunthorpe 在一天之后就报告说,新的 fix 方法破坏了 RDMA self-tests。

这次的问题似乎是因为 RDMA self-test 里面创建了一个匿名私有映射(anonymous private mapping),然后调用了一种特殊形式的 GUP,也就是 pin_user_pages(),广义上说,这是一个内核接口,用来允许驱动程序 (比如 RDMA) 在执行目的或者来源是在这些 page 上的数据传输时,确保 page 不会由于底层变动而突然消失。接下来,self-test 会调用 fork() 来生成一个短命的子进程。该子进程实际上并不去操作任何 page,但由于 fork() 调用的原因,page-table 中的页表项由于 COW 的原因成为 write-protected 状态,并且在子进程退出后也继续保持 write-protected 状态。父进程为了 RDMA 操作,会发起一些对这些 page 的写入动作,就会改动这些被锁定(pinned)的 page。但是,由于引用计数增加了,这些写入操作会 "意外" 导致 COW 被打断,尽管该进程其实是唯一一个映射了这些 page 的进程。这个问题就是来源于 commit 09854ba94c6a 的改动。

这里其实也可以通过对这个提交给 RDMA 的 mapping 来使用 madvise(MADV_DONTFORK) 设置,从而 fix 这个问题,这样也可以防止该 mapping 被包含在子进程的地址空间中。这种改动将使这个 self-test 更加健壮,因为不能完全依靠子进程能飞快退出,这不是一个百分之百可靠的做法,甚至在 commit 09854ba94c6a 之前这种做法也会有概率导致意外的打破 COW。然而,可能还有其他使用 RDMA 的程序没有在 fork()之前调用 madvise(MADV_DONTFORK),尽管这些程序本身也有问题,但是如果因为改变了 Linux kernel 的行为而导致这些程序无法正常工作,这也是不可接受的。另外,正如 Gunthorpe 所指出的那样,就算我们想去 fix,也很难能对每一处 RDMA(或通常的 page-pinning 操作)使用代码都进行修复。

5.9 内核此时已进入 RC 阶段的后期,所以需要紧急修复,最终 Xu 又做了一个 patch set。这次并没有从根本上改变 fix 方式。在 fork()调用过程中,如果遇到任何被 pin 住的 page,子进程会立即得到一份副本,而不是与父进程共享一个 COW page。对 page 是否被 pin 住的检测并不是非常精确,如果该 page 因为其他原因导致引用计数大幅增加,可能会被误判,但在 fork() 过程中多复制少数几个 page 应该不会影响性能。为了将增加的 fork() 开销降到最低,一个预防性的 patch 会在 pin_user_pages() 调用时会对这个进程设置一个 flag,表示该进程已经有一些 page 被 pin 住了。这样一来,所有在 fork()之前没有调用过 pin_user_pages() 的进程都可以跳过这次新增的检查,这应该涵盖了绝大多数进程的情况。

通过一些小的后续 fix,最终的 5.9 内核在 10 月份发布,并解决了 RDMA 问题。后续的更多工作是为了解决一个理论上会出现的场景,也就是父进程在 fork() 调用的同时也在执行 page pinning 操作的情况。这些 patch 最终被合并到 5.11-rc1 中。

An unwanted holiday present

我们的故事本应该到此为止了。不过,在圣诞节前不久,Nadav Amit 报告了一个 userfaultfd() self-test 失败的问题,最终被 Yu Zhao 确认了跟 commit 09854ba94c6a 有关。同样是一个 write-protected page(由 userfaultfd() 创建出来的)由于其引用计数变大而被复制了,但是另一个 CPU 拥有一个指向原 page 的过期 TLB 条目。在最新版本的 fix patch 中对这个复杂的场景进行了相当完整的解释,其中指出缺失的 TLB flush 其实是一个很久之前就存在的 bug,但由于 commit 09854ba94c6a 导致了更积极地打破 cow,使得这个问题暴露了出来。

在 soft-dirty 机制中也有个类似的问题也被 Zhao 报了出来,这个机制是用来允许监控(开销低但粒度也比较粗)一个进程写到了哪些 page 的。这是通过写入/proc/[pid]/clear_refs 文件来实现的,这就可以让所有进程的 page-table entry 都变成了 write-protected。然后,page-fault handler 在响应 write fault 的时候,就会在 page-table entry 中设置一个 soft-dirty bit;这些 bit
可以从/proc/[pid]/pagemap 中读取到,从而我们就能确定自从 clear_refs 操作以来,哪些 page 被写入了。

对这些 bug 的调查引出了长时间的讨论,在讨论过程中,更多的问题显现了出来,对于 fix 这些问题的最佳方法上,大家还存在一些分歧。正如 Zhao 所指出的,这里核心问题是内核内两个操作之间的 race condition:

  • page-fault handler 在做 page 复制动作(之前它本来不需要复制的),并且同时……

  • 相关联的 page-table entry 正在被修改,可能是因为 change_pte_range(),这是来自 userfaultfd() 调用的;也可能是 clear_soft_dirty(),它是用来处理对/proc/[pid]/clear_refs 的写入的。

上述两个操作都是在 mmap_lock 保护的情况下发生的,但这个 lock 是用来保护 read 操作的,这样一来,上述的这些操作就可能会并发进行了。Torvalds 认为,这些 write-protect 操作只要简单将 mmap_锁也用在 write 操作上就好。然而,Andrea Arcangeli 对这一解决方案并不满意,他认为没有 write locking 是 userfaultfd() 相对于 mprotect()的优势之一。

Arcangeli 针对 userfaultfd()的问题提出了不同的解决方法,这个方法比较复杂,但避免了在 write 操作时获取 mmap_lock。然而,在 cover letter 中,他也认为之前这种基于 page-reference-count-based 这种检测方式来决定是否打破 COW 的做法仍然是有问题的,而且正在修复的问题只是 "冰山一角",预计还会有更多的代码会碰到问题。因此,他说,最好是能将目前所有合并了的、用于修复原 vmsplice() 漏洞的 commit 都 revert 掉,而且在真正修复之前,应该把 vmsplice() 改为一个特权操作。他还赞同了 Hugh 之前对于单纯依靠提升引用计数来打破 COW 这种方式的担忧,并表示,如果 commit 17839856fd58 的 "GUP causes COW break" 的方法有问题,应该直接进行修复。最后,他对自己实现的的 userfaultfd() fix 标记为 NAK,不建议合入。

然而,Torvalds 并不赞同;他指出,迄今为止尝试的两种 fix 方法都有 corner case,但目前的方法在概念上对内存管理子系统的核心代码来说实现要简单得多。他说,最好还是坚持使用这一种方法,并处理后续的 corner case。然后,Arcangeli 介绍了他所担心的一个具体例子,他说,在 commit 09854ba94c6a 之后,如果 PoC 代码被修改为使用 transparent huge page(THP)而不是使用 base page(4096-byte),那么 vmsplice() 漏洞就会再次出现。他不知道相应的 Project Zero issue 已经被公开,于是也附上了相应的能复现问题的 patch。作者也已经验证过了,用 5.12-rc2 kernel,使用打过 patch 的 PoC 确实重现了老问题。

虽然在随后的讨论中确认了这个问题,但尚未得到 fix。问题在于,在代码处理 write-protect page 的 write fault 的时候,base page 会依赖这个 page reference count,而 THP 的话则是根据 page_trans_huge_mapcount(),这是一个类似于 page 的 mapping count 的数据。正如前面所解释的那样,在子进程将 page 从自己的地址空间 unmap 后,mapping count 等于 1,哪怕子进程通过 vmsplice() 管道保留了对 page 的访问。Gunthorpe 建议,应该调整 vmsplice()执行的 GUP 调用,从而立即打断 COW——这种方法又类似于 commit 17839856fd58 中的原始修复了,但只限于 GUP 中的一类长期 pin 操作,也包括 vmsplice()创建的 pin。到目前为止,这个想法似乎还没有得到实现。即使实现了,也许除了 vmsplice() 之外可能还有其他一些不那么明显的方法来利用这个漏洞,尽管这种担心可能只存在于理论中。

New rules

迄今为止的最后一轮讨论(至少在公共邮件列表上)发生在 2021 年 1 月中旬左右。Arcangeli 再次提出要恢复到 Linux 5.8 之前的状态,采用其他方式来解决 vmsplice() 漏洞。他提出了一个 PoC来证明,用当前基于 page 的引用计数的方法来打破 COW,是有可能发生数据丢失的。一个进程如果用 O_DIRECT 方式从一个文件读取数据到 buffer,同时另一个线程中(由 CPU)在向同一个 page 内的不同 buffer 进行写入,此时如果第三个线程(或者其他任何进程)向/proc/[pid]/clear_refs 文件写入,那么很可能会导致丢失正在读取的数据。这就构成了 ABI break(与 ABI 之前的行为不一致了)。这里与之前 TLB flush 问题不同,使用 mmap_lock 来对 write 操作进行保护也是无法解决的。他还再次提醒了 THP 相关的 vmsplice() 漏洞未被 fix 的问题。

Torvalds 回答说,应该修复 clear_refs 的实现逻辑,而不是 revert 之前的 fix。他提出了一个 draft patch(后来这个 patch 被合并到 5.11-rc4 中,成为 commit 9348b73c2e1bf)。这个改动的逻辑是,如果一个 page 看起被 pin 住了,那么 clear_refs 处理逻辑根本不会对它的 page-table entry 修改成写保护状态。这样一来,后续的来自 CPU 的写操作就不会造成 page fault 以及打破 COW 了。使用 soft-dirty 机制的地方会把这个 page 一直看作是 dirty 的,在有 DMA 会写入这些这个 page 的情况下,这应该是一个合理的结果。然后,关于如何处理这些针对 DMA 传输或者 write-protect 情况下进行的 page pinning 操作的一般规则,他进行了一些扩展,也就是:

  • 在考虑是否允许对 write-protected PTE 上是直接允许写操作还是创建一个副本的时候,如果不能确定该进程是不是独占这个 page 的时候,就必须创建副本。page reference count 变大就可以说明当前没有独占这个 page。

  • 如果 page 被 pin 的时候使用了一个 cache-coherent GUP(比如 DMA 传输这种情况),那么这个 page-table entry 就必须要是 writable 的。如果 DMA 传输无论如何都可以写到这个 page,那么把它标记成 read-only 是没有道理的。

虽然这些规则从概念上来说很简单,但魔鬼总是存在于细节之中。如果 DMA 传输只是用来进行读取呢?Gunthorpe 提到了 virtual-machine 的场景,在 live-migration 的时候,这个 machine 的 memory 由于 RDMA 的原因被 pin 住,并且接下来进行了 clear_refs 操作(比如是为了检测哪些页面因为内容改变而不得不再次迁移)。如果 clear_refs 不对这些被 pin 住的 page 进行写保护,而让它们被标记为 dirty,那么这个方案就会变得效率很低,因为虚拟机的所有内存一直都是 dirty 状态。而且,与 clear_refs 不同,userfaultfd() 一直没有得到 fix,所以在与 RDMA 结合时,就会出现意外的 COW break。

为了在新的 COW rule 中解决这些情况,也许比起利用增加过的 page 引用计数或或者 PTE 是否 writable 来说,可能还有其他更好的启发式规则(heuristics)来判定是否独占这个 page。同样,检测一个 page 是否被 pin 住的这种判断也可能会出现误报——毕竟这个函数连名字都是 page_maybe_dma_pinned()。这里有几个想法已经被提出了,比如增加一个新的 page flag,或者或者对 DMA pin 和 long-term pin 都进行更精确的计数统计,可以从引用计数里面分出一些 bit 来单独作计数之用。这两种想法都不容易实现,因为 struct page 已经很拥挤了,而且如果减少引用计数的上限的话,就会更容易出现已知的增加引用计数的攻击,从而增加拒绝服务攻击(denial of service)的风险。但讨论似乎已经结束了,没有任何具体的 patch。在该主题的最后一条消息中,David Hildenbrand 的最后一句话很好地总结了这一点:"复杂的问题:)"

Are we done yet

那么现在是什么情况?为了 fix 一个除了 Android 之外很难被利用的 information-leak vulnerability,5.8 内核在发布时对 COW 机制进行了重大修改。由于报出的 bug,5.9 内核又做了一次重大改动,并且这些代码包含在 5.10 LTS 中了。在 5.11 中则修复了更多的 bug,而且对 userfaultfd() TLB flush 问题的 fix 似乎也快要完成了。然而,同时使用 RDMA 与 soft-dirty 或 userfaultfd()机制的某些场景现在可能会有问题。

目前的 COW 实现是基于很合理的原则的,最坏的那些 corner case 希望已经都被解决了。因此,从结果来说,我们可能已经实现了一个比 5.8 内核之前版本更加优秀的、更有未来感(future-proof)的 copy-on-write 模型。然而,考虑到这一领域的历史,未来如果有更多的 bug 报出来,我们一点也不会感到奇怪。

很讽刺的是,引发这一系列问题的原始漏洞,在使用 transparent huge page 的场景下仍然没有被修复。针对这个问题来说,只修改 vmsplice() 来立即打破 COW 可能是最安全的选择,特别是这些 fix 还需要 backport 到旧版本的 LTS kernel。然而,这个未被修复的 THP 漏洞也可能是一个信号,说明 transparent huge pages 其实并没有遵循那些为 base page 建立的新 COW model,如果我们无法调整这一点的话(因为 THP 的引用计数本身就是一个复杂的话题),很可能我们的故事还没有结束。

[感谢 Jan Kara 和 Michal Hocko 对文章早期版本的宝贵反馈意见]。

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

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

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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值