LWN:内核该如何处理argc为0的情况?
关注了就能看到更多这么棒的文章哦~
Handling argc==0 in the kernel
By Jonathan Corbet
January 28, 2022
DeepL assisted translation
https://lwn.net/Articles/882799/
现在大多数读者可能已经了解了被称为 CVE-2021-4034 的 Polkit 漏洞。Polkit 的 fix 方法相对简单,并且正在网络上广泛推行。不过,这个问题的根源来自对于程序在 Unix 类似系统上运行方式有误解。这个问题极有可能也存在于其他程序中,所以最好能找到一个更普遍的解决方案。要解决这个问题,最好是能在内核中完成,但看起来很难在不引起 regression 的情况下来绕过这种误解。
I'd like to have an argument, please
大多数开发者都熟悉 C 语言表达中的 main 函数的原型(prototype):
int main(int argc, char *argv[], char *envp[]);
在程序被调用时,命令行参数会位于 argv 中,环境变量在 envp 中。两者都是指向 char * 字符串类型的数组的指针,数组以 NULL 结尾。argv 中不为空的项目的数量放在 argc 中。不过这个 API 是用户空间看到的情况。当内核开始执行一个程序时情况并不是这样。在 Linux 上,这个程序本身也作为指针传递给 argv 数组。envp 数组会紧跟着 argv 里面的 NULL 值来开始。因此,C 程序中在进入 main()时下面的语句结果会是 true:
envp == argv + argc + 1
按惯例,argv[0] 就是正在执行的程序的名称,许多程序都依赖这个惯例。然而,这个约定只是一个约定而已,并不是一个保证。argv 的实际内容完全由调用 execve() 来运行程序的人控制,而且调用者并不是必须要把程序名放在 argv[0] 位置的。
事实上,调用者根本不需要提供 argv[0]。如果传递给 execve()的 argv 数组是空的(或者 argv 指针是 NULL),那么新创建出来的进程代码中 argv 数组中的第一个指针将是 NULL,而 envp 数组会紧随其后。不幸的是,Polkit(或者更精确地说,是 setuid pkexec 工具)"认为" argv[0] 总是存在的,所以它通过从 argv[1]开始遍历 argv 数组来处理其他参数。如果完全没有提供参数的话,argv[1] 就是 envp 了,所以 pkexec 实际上是在遍历所有的环境变量。还会对这里的参数进行修改(pkexec 会对它的 argv 数组重新写入内容),pkexec 就可以被用来去改写其环境变量,从而绕过了针对 setuid 程序进行的对这些变量的检查工作。然后一切都完了。
这个问题并不新鲜,人们也早已意识到这类问题。Ryan Mallon 在 2013 年写了一篇关于这个问题的文章,指出 "它确实可以让 setuid 二进制文件产生一些特别行为"。他曾经还提交了一个 Polkit patch 来解决这个问题,但这个 patch 从未被合入。甚至来到更早的 2007 年的时候,Michael Kerrisk 就报了一个 bug,认为内核的行为是个错误,但该报告几乎没有经过什么讨论就被关闭了。因此这个问题一直存在到现在,最终导致管理员们现在忙着修补这个漏洞。
Toward a more general fix
修复这个问题本身很是简单,只要让 pkexec 检查 argc 来确保至少有一个参数就好。但肯定还有其他程序也会有着类似的假设。鉴于 argv[0] 含义已经是大家的共识了,那么允许程序运行时的 argv 数组为空,这种做法是否有意义。也许根本不合理,但当前的 API 有长久的历史,无法不经深思熟虑就去修改。
Ariadne Conill 用一个 patch 来开启了 linux-kernel 中的讨论,该 patch 直接禁止 argv 中完全没有内容的情况下调用 execve()。违规的调用者会得到 EFAULT 错误返回值。这就会确保 argv 不为空,从而解决问题,但这种做法又有着自己的问题。其中之一就是 Kees Cook 所发现的,实际上有相当多的代码在调用 execve()时的 argv 数组都是为空。Conill 认为这些属于 "偷懒写出来的 test case 代码,应该被 fix",但是哪怕这些代码无法正常运行了也算是 regression。另外,正如 Heikki Kallasjoki 和 Rich Felker 都指出的,POSIX 标准本身实际上允许 argv 数组为空。
Felker 还提出了一个会引入更少的 regression 的替代方案:只在特权切换的位置确保 argv 非空。换句话说,也就是当 execve() 调用是要运行一个 setuid 程序的时候。Cook 认为,只要有可能他都希望尽量避免把特权切换考虑进来。他提出了另一个解决方案:在全空的 argv 数组的末尾额外插入一个空指针,这样那些试图直接跳过 argv[0]的代码就会看到这里的空指针。不过事实证明这个解决方案也是行不通的:ABI 要求 envp 需要紧接着 argv 开始,而额外加入的这个 NULL 破坏了这一承诺。显然是有一些程序就依赖于这种排列方式的,如果有变化的话它们肯定无法正常执行。
还有一种方法,首先是由 Eric Biederman 提出的,就是用包含一个指向空字符串的单一指针的 argv 代替空的 argv。这个建议得到了一些支持(尽管到现在为止还没有人进行具体实现),但也引起了一些相关的担忧。可能会有一些程序会对使用 null 字符串的参数报错,或者可能会在 argc 为零时做一些特别的动作。改变 execve() 运行的程序所传递的参数数量看起来可能还是会造成一些意外的。
Cook 最终这样进行了总结:
鉴于我们已经发现一些依赖于 NULL argv 的代码,我认为我们很可能无法直接进行修改了,所以我们陷入了这个奇怪的两难境地,一方面试图拒绝我们可以拒绝的东西,另一方面又要为目前存在的问题想办法给出解决。
尽管如此,他还是继续声明他更加偏好最初的改动(就是直接禁止没有参数的 execve()调用,不过换成 EINVAL 返回值而不用 EFAULT),并建议对一个 Valgrind test 进行 fix,这个 test 是已知会因为该限制而被破坏的。Conill 提供了新的一版 patch,这次它在对调用 execve() 时因为 argv 为空而返回 EINVAL 之前会给出 warning。Cook 接受了这个 patch,说 "咱们来试试看会有什么问题";Biederman 表示赞同,说:"尤其是既然你已经表态愿意 fix 那些 tests 了"。
这就是截至目前的讨论情况,但还不确定这个问题最终会如何解决。最重要的是,这个 patch 还没有与 Linus Torvalds 商讨过,Linus 可能会因其会引入的潜在的 regression 风险而表示反对。毕竟这是一个 ABI 的变动,很可能会有一些代码被这个变动所破坏。事实上 BSD 系统已经禁止 argv 为空了,幸运的话,它已经帮助解决了大部分的担忧。如果不幸真的出现了 regression,那几乎肯定还需要再找另一个不同的解决方案了。
全文完
LWN 文章遵循 CC BY-SA 4.0 许可协议。
长按下面二维码关注,关注 LWN 深度文章以及开源社区的各种新近言论~