GitLab:我们花了两周时间寻找Linux内核中的NFS漏洞(2018)

2020-10-12 07:51:38

9月14日,GitLab支持团队升级了我们的一位客户遇到的一个严重问题:GitLab可以正常运行一段时间,但一段时间后用户遇到错误。当试图通过Git克隆某些存储库时,用户会看到一条不透明的Stalefile错误消息。错误消息持续了很长一段时间,阻止员工工作,除非系统管理员通过在目录本身中运行ls进行手动干预。

因此,对Git和网络文件系统(NFS)的内部工作方式展开了调查。调查发现了LinuxV4.0NFS客户端的一个缺陷,并以Trond Myklebustand编写的内核补丁达到顶峰,该补丁于10月26日合并到最新的主流Linux内核中。

这篇文章描述了调查这一问题的过程,并详细介绍了我们追踪漏洞的思路、过程和工具。它的灵感来自于我如何花两周时间在Ruby Oleg Dashevskii中寻找内存泄漏的出色侦探工作。

更重要的是,这一经历证明了开源软件调试已经成为一项团队运动,涉及多个人、多个公司和多个地点的专业知识。GitLab的座右铭每个人都可以贡献,这不仅适用于GitLab本身,也适用于其他开源项目,如Linux内核。

虽然我们在GitLab.com上运行NFS已经有很多年了,但我们已经停止使用它来跨我们的应用程序机器访问存储库数据。相反,我们已将所有Git调用抽象到Gitaly.尽管如此,NFS仍然是我们的客户支持的配置,这些客户管理他们自己的GitLab安装,但我们以前从未见过客户描述的确切问题。

这个错误似乎是从他们通过GitGC开始手动运行Git垃圾收集时开始的。

前两项似乎明显相关。当您推送到分支Git时,Git会为文件创建一个松散引用,一个花哨的名称,它将您的分支名称指向提交。例如,推送到master将在存储库中创建一个名为refs/head/master的文件:

GitGC有几个工作,但其中之一是收集这些松散引用(Ref),并将它们捆绑到一个称为Pack-Refs的文件中。这样就不需要读取大量的小文件,而只需要读取一个大文件,因此速度更快。例如,在运行git gc之后,一个打包的引用示例可能如下所示:

这个压缩引用文件究竟是如何创建的?为了回答这个问题,我们用一个松散的裁判礼物运行了strace git GC。以下是与此相关的几句话:

28705 open(";/tmp/libgit2/.git/packed-refs.lock";,O_RDWR|O_CREAT|O_EXCL|O_CLOEXEC,0666)=328705 OPEN(";.GIT/PACKED-REFS";,O_RDONLY)=328705 open(";/tmp/libgit2/.git/packed-refs.new";,O_RDWR|O_CREAT|O_EXCL|O_CLOEXEC,0666)=428705 rename(";/tmp/libgit2/.git/packed-refs.new";,";/tmp/libgit2/.git/packed-refs";)=028705 unlink(";/tmp/libgit2/.git/packed-refs.lock";)=0。

第四步是这里的关键:Git将打包引用放入行动的重命名。除了收集松散的引用之外,gitGC还执行一项代价更高的任务,即扫描未使用的对象并将其删除。对于较大的存储库,此任务可能需要一个多小时。

这让我们不禁要问:对于大型存储库,GitGC在运行扫描时是否保持文件打开?查看strace日志并使用lsof探测该进程,我们发现它执行以下操作:

请注意,只有在可能很长的垃圾收集对象步骤发生后,Package-Ref才会在末尾关闭。

这就提出了下一个问题:当一个节点已打包引用打开,而另一个节点重命名该文件时,NFS的行为如何?

为了进行实验,我们要求客户在两台不同的机器(Alice和Bob)上运行以下实验:

在共享NFS卷上,创建两个内容不同的文件test1.txt和test2.txt以便于区分:

Alice$echo";1-旧文件";>;/path/to/nfs/test1.txt alice$echo";2-新文件";>;/path/to/nfs/test2.txt。

对啰!。我们似乎以一种可控的方式重现了这个问题。然而,使用Linux NFS服务器的相同实验没有这个问题。结果与您预期的一样:在重命名之后添加了新内容:

1-旧文件1-旧文件1-旧文件2-新文件<;-重命名HAPPENED2-新文件2-新文件。

为什么会有行为上的差异呢?事实证明,该客户使用的是仅支持NFS V4.0的Isilon NFS应用装置。通过/etc/fstab中的vers=4.0参数将挂载参数切换到V4.0,测试显示了Linux NFS服务器的不同结果:

1-旧文件1-旧文件<;-重命名HAPPENED1-旧文件1-旧文件。

LinuxNFS V4.0服务器显示的不是陈旧的文件句柄,而是陈旧的内容。事实证明,这种行为差异可以用NFS规范来解释。来自RFC3010:

文件句柄可能会变得陈旧,也可能不会因重命名而过期。但是,强烈建议服务器实现者尝试避免文件句柄变得陈旧或以这种方式过期。

换句话说,NFS服务器可以选择文件重命名时的行为方式;当文件重命名时,任何NFS服务器返回陈旧的文件错误都是完全有效的。我们推测,尽管结果不同,但问题很可能与同一问题有关。我们预计会出现一些缓存验证问题,因为在目录中运行ls会清除该错误。现在我们有了一个可重现的测试用例,我们询问了专家:Linux NFS维护者。

通过一套清晰的复制步骤,我向LinuxNFS邮件列表发送了一封电子邮件,描述了我们发现的内容。在过去的一周里,我与Linux NFS服务器维护人员Bruce Fields来回走动,他认为这是一个NFS错误,查看网络流量会很有用。他认为NFS服务器委派可能存在问题。

简而言之,NFS v4引入了服务器委托作为加速文件访问的一种方式。服务器允许对客户端进行读或写访问,因此客户端不必不断询问服务器该文件是否已被其他客户端更改。简而言之,写作委派类似于有人借给你一本笔记本,然后说:“那就在这里写吧,等我准备好了,我就把它拿回来。你不必每次想写新段落时都要借笔记本,你可以自由支配,直到主人收回笔记本为止。”(这句话的意思是:“你可以随意借笔记本,直到主人拿回笔记本为止。)”(这句话的意思是:“写委派”就像是有人借给你一本笔记本,然后说,你可以在这里写字,等我准备好了,我就把它拿回来。)。在NFS术语中,这个回收过程称为委托召回。

实际上,NFS委派召回中的错误可能解释了陈旧的文件句柄问题。请记住,在较早的实验中,Alice在后来被test2.txt替换时将文件打开到test1.txt。可能是服务器无法重新调用test1.txt上的委派,从而导致状态不正确。为了检查这是否是一个问题,我们转向tcpdump来捕获NFS流量,并使用Wireshark将其可视化。

Wireshark是一个很棒的分析网络流量的开源工具,特别适合查看NFSin操作。我们在NFS服务器上使用以下命令捕获跟踪:

此命令捕获所有NFS流量,通常在TCP端口2049上。由于我们的实验在NFS v4.1上运行正常,但在NFS v4.0上运行正常,因此我们可以比较和对比NFS在非工作和正常工作情况下的行为。使用Wireshark时,我们看到以下行为:

在此图中,我们可以看到在步骤1中,Alice打开test1.txt并获取NFS文件句柄和stateid 0x3000。当Bob尝试重命名文件时,NFS服务器告诉Bob通过NFS4ERR_DELAY消息重试,同时从Alicevia调回CB_RECALL消息的委派(步骤3)。然后,Alice通过DELEGRETURN返回她的委托(步骤4),然后Bob尝试发送另一个重命名消息(步骤5)。重命名在这两种情况下都完成了,但是Alice继续使用相同的文件句柄进行读取。

主要区别在步骤6的底部。请注意,在NFSV4.0(陈旧的文件情况)中,Alice尝试重用相同的stateid。在NFS V4.1(工作情况)中,Alice执行额外的查找和打开,这会导致服务器返回不同的stateid。在V4.0中,这些额外的消息永远不会发送。这就解释了为什么Alice继续查看过时的内容,因为她使用的是旧的文件句柄。

是什么让爱丽丝决定做额外的查找呢?代表团召回似乎运作良好,但可能仍然存在一个问题,例如取消无效步骤。要排除这种情况,我们通过在NFS服务器本身上发出以下命令来禁用NFS委派:

我们重复了实验,但问题仍然存在。所有这些都让我们相信,这不是NFS服务器问题,也不是NFS委派的问题;这是一个导致我们研究内核中的NFS客户端的问题。

该问题出现在CentOS 7.2和Ubuntu 16.04内核上,这两个内核分别使用版本3.10.0-862.11.6和4.4.0-130。然而,这两个内核都落后于最新的内核,后者当时是4.19-rc2。

我们在Google Cloud Platform(GCP)上部署了一个新的Ubuntu16.04虚拟机,克隆了最新的Linux内核,并搭建了内核开发环境。通过make menuconfig生成.config文件后,我们检查了两项:

正如遗传学家会使用果蝇实时研究进化一样,第一个项目允许我们在NFS客户端进行快速更改,而不必重启内核。第二项是为了确保内核在安装后能够真正引导。幸运的是,默认内核设置有所有开箱即用的设置。

使用我们的自定义内核,我们验证了在最新版本中仍然存在过时文件问题。这引发了一些问题:

为了回答这些问题,我们开始研究NFS源代码。因为我们没有可用的内核调试器,所以我们在源代码中使用了两种主要类型的调用:

例如,我们首先做的一件事是挂接fs/nfs/nfs4file.c中的nfs4_file_open()函数:

诚然,我们可以使用Linux动态调试器或使用rpcdebug来激活dprintk消息,但是能够添加我们自己的消息来验证正在进行的更改是件好事。

每次进行更改时,我们都会通过以下命令重新编译模块并将其重新安装到内核中:

安装了NFS模块后,重复实验将打印消息,这将帮助我们更好地理解NFS代码。例如,您可以看到应用程序调用open()时到底发生了什么:

9月24 20:20:38测试内核:[1145.233460]调用跟踪:9月24 20:20:38测试内核:[1145.233462]转储_堆栈+0x8e/0xd5 9月24 20:20:38测试内核:[1145.233480]nfs4_FILE_OPEN+0x56/0x2a0[nfsv4]9月24 20:20:38测试内核:[1145.233488]?Nfs42_CLONE_FILE_RANGE+0x1c0/0x1c0[nfsv4]9月24 20:20:38测试内核:[1145.233490]do_dentry_open+0x1f6/0x360 9月24 20:20:38测试内核:[1145.233492]vfs_open+0x2f/0x409月24 20:20:38测试内核:[1145.233493]path_openat+0x2e8/0x16909月24 20:20:38测试内核:[1145.233496]?MEM_CGROUP_TRY_CHAGE+0x8b/0x190 9月24 20:20:38测试内核:[1145.233497]DO_FILP_OPEN+0x9b/0x110 9月24 20:20:38测试内核:[1145.233499]?__CHECK_OBJECT_SIZE+0xb8/0x1b0 9月24 20:20:38测试内核:[1145.233501]?_ALLOC_FD+0x46/0x170 9月24 20:20:38测试内核:[1145.233503]DO_SYS_OPEN+0x1ba/0x250 9月24:20:38测试内核:[1145.233505]?Do_sys_open+0x1ba/0x250 9月24 20:20:38测试内核:[1145.233507]__x64_sys_openat+0x20/0x30 9月24 20:20:38测试内核:[1145.233508]do_syscall_64+0x65/0x130。

上面的do_dentry_open和vfs_open调用是什么?Linux有一个虚拟文件系统(VFS),这是一个为所有文件系统提供公共接口的抽象层。VFS文档说明:

VFS实现open(2)、stat(2)、chmod(2)和类似的系统调用。VFS使用传递给它们的路径名参数搜索目录条目缓存(也称为dentrycache或dcache)。这提供了一种非常快速的查找机制,可以将路径名(文件名)转换为特定的dentry。Dentry保存在RAM中,永远不会保存到磁盘:它们的存在只是为了性能。

这给了我们一个线索:如果这是dentry缓存的问题怎么办?

我们注意到很多dentry缓存验证是在fs/nfs/dir.c中完成的。特别值得一提的是,nfs4_lookup_revalid()听起来很有希望。作为一项实验,我们破解了该功能,以便及早纾困:

Diff--git a/fs/nfs/dir.c b/fs/nfs/dir.cindex 8bfaa658b2c1..ad479bfeb669 100644-a/fs/nfs/dir.c+b/fs/nfs/dir.c@@-1159,6+1159,7@@static int nfs_lookup_revalify(struct dentry*dentry,unsign int flag)trace_nfs_lookup_revalify_enter(dir,dentry,flag);error=NFS_label(Dir)->;lookup(目录,&;dentry->;d_name,fHandle,fattr,fattr);Trace_nfs_lookup_revalid_exit(目录,dentry,标志,错误);+转到OUT_BAD;IF(ERROR==-ESTALE||ERROR==-ENOENT)转到OUT_BAD;IF(ERROR)

这使得我们实验中的陈旧文件问题消失了!现在我们说到点子上了。

为了回答,为什么这个问题在NFS v4.1中没有发生?";,我们在该函数中的每个if块中添加了pr_info()调用。在使用NFSv4.0和v4.1运行oureeximents之后,我们发现在v4.1情况下运行此特殊条件:

什么是NFS_CAP_ATOM_OPEN_V1?我们看到这个内核补丁提到这是NFS V4.1特有的特性,fs/nfs/nfs4proc.c中的代码确认该标志是V4.1中存在的功能,但在V4.0中没有:

静态常量结构nfs4_Minor_Version_ops nfs_v4_1_Minor_ops={。Minor_Version=1,。Init_caps=NFS_CAP_READDIRPLUS|NFS_CAP_ATOM_OPEN|NFS_CAP_POSIX_LOCK|NFS_CAP_STATEID_NFSV41|NFS_CAP_ATOM_OPEN_V1。

这就解释了行为上的差异:在V4.1中,gotono_open会导致nfs_lookup_revalid()中发生更多验证,但在V4.0中,nfs4_lookup_revalify()会更早返回。现在,我们如何真正解决这个问题呢?

我将发现报告给了NFS邮件列表,并提出了一个简单的补丁。报告发布一周后,Trond Myklebust向List发送了修复此错误的补丁系列,并发现了NFSv4.1的另一个相关问题。

事实证明,对NFS V4.0错误的修复在代码库中比我们看到的更深。Trond在补丁中很好地总结了这一点:

我们需要确保在重新打开已打开的文件时正确进行inode和dentry重新验证。目前,由于';缓存的打开路径,在NFSv4.0的情况下,我们可能最终不会验证这两种方法中的任何一种。让我们通过确保只为开放恢复和委派返回的特殊情况打开缓存来解决这个问题。

我们确认这个修复解决了过时的文件问题,并使用Ubuntu和RedHat归档错误报告。

我们深知内核更改可能需要一段时间才能稳定发布,因此我们还在Gitaly中添加了一个解决方法来处理此问题。我们进行了实验,以测试对压缩引用文件调用stat()似乎会导致内核重新验证重命名文件的dentrycache。为简单起见,这是在Gitaly中实现的,而不管文件系统是否为NFS;我们只在Gitaly";打开存储库之前执行一次,并且已经有其他stat()调用来检查其他文件。

错误可能在您的软件堆栈中的任何位置,有时您必须在应用程序之外寻找才能找到它。在开放源码世界中拥有乐于助人的合作伙伴使这项工作变得容易得多。

我们非常感谢Trond Myklebust解决了这个问题,Bruce Fields回答了问题并帮助我们了解了NFS。他们的响应性和专业性真实地反映了开放源码社区的优秀之处。

注册GitLab每月两次的时事通讯,探索即将到来的网络广播、操作博客,并随时了解每月发布的令人兴奋的新功能:

GitLab不仅仅是源代码管理或CI/CD。它是一个完整的软件开发生命周期&在单个应用程序中的DevOps工具。

免费试用GitLab。

Git是软件自由保护协会的商标,我们对GitLab的使用正在获得许可