• 售前

  • 售后

热门帖子
入门百科

宋宝华:谈一谈Linux写时拷贝(COW)的安全漏洞(1)

[复制链接]
蓝天下的白云 显示全部楼层 发表于 2022-1-12 12:07:30 |阅读模式 打印 上一主题 下一主题
写时拷贝的原理我们没什么好赘述的,就是当P1 fork出来P2后,P1和P2会以只读的形式共享page,直到P1或者P2写这个page的内容,才发生page fault导致写的进程得到一份新的数据拷贝。 下面的代码演示了它的效果:
  1. int data = 10;
  2. int child_process()
  3. {
  4.         printf("Child process %d, data %d\n", getpid(), data);
  5.         data = 20;
  6.         printf("Child process %d, data %d\n", getpid(), data);
  7.         _exit(0);
  8. }
  9. int main(int argc, char *argv[])
  10. {
  11.         int pid;
  12.         pid = fork();
  13.         if (pid == 0) {
  14.                 child_process();
  15.         } else {
  16.                 sleep(1);
  17.                 printf("Parent process %d, data %d\n", getpid(), data);
  18.                 exit(0);
  19.         }
  20. }
复制代码
上面的代码,执行的时候打印:
   baohua@baohua-VirtualBox:~$ ./a.out 
Child process 3498, data 10
Child process 3498, data 20
Parent process 3497, data 10
  子进程把10改为20后,父进程1秒后打印,得到的仍然是10。如果到这里为止,你看不懂,这篇文章不适合你这样的Linux初学者,请勿继续往下阅读。
从技术上来讲,在父进程写过数据后,子进程应该读不到父进程新写的数据;在子进程写过数据后,父进程也应该读不到子进程新写的数据。这才符合“进程是资源封装的单位”的本质定义。
如果都是上面的经典模型,那么岁月静好,与君白头偕老。但是,总会有人在花田里犯了错,破晓前仍然没有忘掉。这个COW技术,就爆出了巨大的漏洞,让父子进程间可以向对方泄露写过的新数据,成为了Linux内核的惊天大瓜。
我们先来看看是怎样的一个程序,让COW的人设崩塌了呢?
  1.     static void *data;
  2.     posix_memalign(&data, 0x1000, 0x1000);
  3.     strcpy(data, "BORING DATA");
  4.     if (fork() == 0) {
  5.         // child
  6.         int pipe_fds[2];
  7.         struct iovec iov = {.iov_base = data, .iov_len = 0x1000 };
  8.         char buf[0x1000];
  9.         pipe(pipe_fds);
  10.         vmsplice(pipe_fds[1], &iov, 1, 0);
  11.         munmap(data, 0x1000);
  12.         sleep(2);
  13.         read(pipe_fds[0], buf, 0x1000);
  14.         printf("read string from child: %s\n", buf);
  15.    } else {
  16.         // parent
  17.         sleep(1);
  18.         strcpy(data, "THIS IS SECRET");
  19.    }
复制代码
上面的程序,父子进程最初共享了data指向的0x1000这么大1个page的内容。然后父进程在data里面写“BORING DATA”,之后,父进程fork子进程。子进程接下来创建了一个pipe,并用vmsplice,把data指向的buffer拼接到了pipe的写端,而后子进程通过munmap()去掉data的映射,再睡眠2秒制造机会让父进程在data里面写"THIS IS SECRET"。2秒后,子进程read pipe的读端,这个时候,神奇的事情发生了,子进程读到了父进程写的秘密数据。
为什么会发生这种事情呢?魔鬼就在细节里。这里面有2个细节:
1. 子进程munmap,导致data的mapcount减-1,这样欺骗了Linux内核,使得父进程在写THIS IS SECRET的时候,并不会发生COW,因为内核理解data只有1个进程有map,制造拷贝显然是多余的。
2.子进程调用vmsplice,这是一种0拷贝技术,避免管道的写端从userspace往kernel space进行拷贝。vmsplice的底层,会通过传说中的GUP(get_user_pages)技术,来增加page的引用计数,导致page不会被回收和释放。
所以,子进程通过pipe的写端hold住了老的page,然后通过read(),把这个page经过父进程写后的新内容读出来了。这真地很神奇有木有!这个漏洞的编号是CVE-2020-29374,它的官方描述如下:
   An issue was discovered in the Linux kernel before 5.7.3, related to mm/gup.c and mm/huge_memory.c. The get_user_pages (aka gup) implementation, when used for a copy-on-write page, does not properly consider the semantics of read operations and therefore can grant unintended write access, aka CID-17839856fd58.
  这个瓜大地直接惊动了祖师爷Linus Torvalds发patch来进行“修复”,Linus的“修复”patch编号是17839856fd58 ("gup: document and work around 'COW can break either way' issue")。祖师爷的修复方法比较简单直接,对于任何要COW的page,如果你做GUP,哪怕你后面对这个page的行为是只读的,也要得到一份新的copy。对应前面的参考代码,其实就是子进程调用vmsplice的行为,打破了COW的常规逻辑,之后子进程read(pipe[0])的时候,读到的是新的page。
所以没有Linus的patch的时候,data的内存在父子进程分布如下:

有了Linus的patch后,data的内存在父子进程分布如下:

显然,这样之后,父进程写data后,写的是蓝色区域,子进程读的是黄色的区域,这样子进程是肯定读不到SECRET数据了。
Linus是永远正确的?必须是!当Linus把这个patch合入5.8内核的时候,人们以为故事就此结束了,却没想到瓜才刚刚开始。作为Linus内核的吃瓜群众,我们的激情从不曾磨灭,因为“吃在嘴里,甜在心里”,吃瓜的甜蜜诱惑引诱我们一步步走入Linux内核的深渊,误了一生。
redhat的Peter Xu童鞋,在2020年8月报了一个bug,直指祖师爷的patch造成了问题,因为它破坏了类似userfaultfd-wp和umapsort这样的应用程序。注意,子曾经曰过,“If a change results in user programs breaking, it's a bug in the kernel. We never EVER blame the user programs”,有图有真相:

一个典型的umap代码仓库在:
GitHub - LLNL/umap: User-space Page Management
这种app利用userfaultfd的原理,在userspace处理page fault,从而提供userspace特定的page cache evict机制。关于userfaultfd的原理和用法,你可以阅读我之前的文章
宋宝华:论一切都是文件之匿名inode_宋宝华-CSDN博客
简单来说,umap这样的程序通过3个步骤来evict page。
  1.   (1) 用mode=WP来对即将被evict的page执行写保护,从而block对于page P的写,保持page的clean;
  2.   (2) 把page P写入磁盘;
  3.   (3) 通过MADV_DONTNEED来evict这个page。
复制代码
其中的第2步会用到一个read形式的GUP。不过,Linus已经通过他的patch,强迫哪怕是read形式的GUP也要发生COW,这样触发了一个app完全没有预期到的page fault,导致uffd线程出错hang死。显然Linus自己break了userspace,等待他的结局是,他的patch的行为也要被revert掉。这一次仍然是Linus亲自出手,他提交了09854ba94c6a ("patch: mm: do_wp_page() simplification"),导致程序的行为再次发生了翻天覆地的变化。
前面我们提到,通过Linus的17839856fd58 ("gup: document and work around 'COW can break either way' issue") patch,子进程vmsplice的GUP行为会强迫子进程进行COW,得到新的拷贝。但是,现在Linus不这个干了,vmsplice的pipe写端还是指向老的页面,他重新选择了在父进程进行实际的写的时候,不再只是傻傻地判断page的mapcount,他还会判断是不是有人间接通过GUP等形式,增加了page的引用计数,如果是,则在父进程写的时候,进行copy-on-write,这个时候,父进程写过"THIS IS SECRET"后,data在父子进程的内存分布变成:

 由于父进程是在新的黄色page进行写,而子进程用的是老的蓝色page,所以"THIS IS SECRET"不会泄露给子进程。Linus的最主要修改是直接变更了do_wp_page()函数,逻辑变成:
  1.         struct page *page = vmf->page;
  2.         if (page_count(page) != 1)
  3.                 goto copy;
  4.         if (!trylock_page(page))
  5.                 goto copy;
  6.         if (page_mapcount(page) != 1 && page_count(page) != 1) {
  7.                 unlock_page(page);
  8.                 goto copy;
  9.         }
  10.         /* Ok, we've got the only map reference, and the only
  11.          *  page count reference, and the page is locked,
  12.          * it's dark out, and we're wearing sunglasses. Hit it.
  13.          */
  14.         wp_page_reuse(vmf);
  15.         unlock_page(page);
  16.         return VM_FAULT_WRITE
复制代码
因为GUP的行为会增加page的refcount,从而触发父进程在写data的wp的page fault里面,进行COW。所以Linus是守信用的,自己提交的patch犯的错,含泪也要revert掉。
那么故事就此结束了吗?正当所有的吃瓜群众都把西瓜皮扔到垃圾桶准备休息一阵的时候,蕾神再次以惊天之锤,锤向了“花田里犯的错”。
累了,睡觉了。欲知后事如何,请听下回分解。

来源:https://blog.caogenba.net/21cnbao/article/details/122396533
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!

帖子地址: 

回复

使用道具 举报

分享
推广
火星云矿 | 预约S19Pro,享500抵1000!
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

草根技术分享(草根吧)是全球知名中文IT技术交流平台,创建于2021年,包含原创博客、精品问答、职业培训、技术社区、资源下载等产品服务,提供原创、优质、完整内容的专业IT技术开发社区。
  • 官方手机版

  • 微信公众号

  • 商务合作