我有一个简单的 bash 脚本bash.sh
,它使用 启动另一个 bash 实例pkexec
。
#!/bin/bash
bash -c 'pkexec bash'
执行时,会提示用户输入密码。主脚本bash.sh
以普通用户身份运行,但由它启动的 bash 实例以具有提升权限的 root 身份运行。
当我打开终端窗口并尝试向提升的 bash 进程的标准输入写入一些命令时,它会抛出权限错误(如预期)。
echo 'echo hello' > /proc/<child-bash-pid>/fd/0
问题是,当我写入父进程 ( bash.sh
) 时,它会传递给子 bash 进程,然后子进程执行该命令。
echo 'echo hello' > /proc/<parent-bash.sh-pid>/fd/0
我不明白这怎么可能?由于父进程以普通用户身份运行,为什么我(普通用户)可以将命令传递给以更高权限运行的子进程?
我知道子进程的标准输入连接到父脚本的标准输入,但如果允许的话,任何普通进程都可以通过写入 root bash 进程的父进程来执行 root 命令。
这似乎不符合逻辑。我缺少什么?
/usr/share
注意:我通过删除只有 root 有权执行的文件来验证子级是否正在执行传递给父级的命令。
sudo touch /usr/share/testfile
echo 'rm -f /usr/share/testfile' > /proc/<parent-bash.sh-pid>/fd/0
文件已成功删除。
答案1
这个是正常的。为了理解它,让我们看看文件描述符如何工作以及它们如何在进程之间传递。
您提到您正在使用GLib.spawn_async()
生成 shell 脚本。该函数大概会创建一个管道,用于将数据发送到子级的标准输入中(或者您可能自己创建管道并将其传递给函数)。要生成子进程,该函数将fork()
关闭一个新进程,重新排列其文件描述符,使 stdin 管道变为 fd 0
,然后是exec()
您的脚本。由于脚本以 开头#!/bin/bash
,内核通过exec()
bash shell 来解释它,然后运行您的 shell 脚本。该 shell 脚本分叉并执行另一个 bash(顺便说一句,这是多余的;您实际上并不需要bash -c
那里的 bash)。没有文件描述符被重新排列,因此新进程继承与其 stdin 文件描述符相同的管道。请注意,这本身并不“连接”到其父进程 - 事实上,文件描述符引用同一个管道,即由 . 创建或分配的管道GLib.spawn_async()
。实际上,我们只是为管道创建别名:这些进程中的 fd 0 都引用了管道。
调用时会重复该过程pkexec
- 但pkexec
它是一个 suid root 二进制文件。这意味着,当该二进制文件被exec()
编辑时,它以 root 身份运行,但其标准输入仍然连接到原始管道。pkexec
然后进行权限检查(其中涉及提示输入密码),最后exec()
是 bash。现在我们有一个根 shell,它从管道获取输入,而用户拥有的许多其他进程也引用该管道。
需要理解的重要一点是,在 POSIX 语义下,文件描述符没有权限。文件具有权限,但文件描述符代表访问文件(或像管道这样的抽象缓冲区)的权限。您可以将文件描述符传递给新进程,甚至传递给现有进程(通过 UNIX 套接字),并且访问文件的权限会随文件描述符一起传递。您甚至可以打开一个文件,然后将其所有者更改为另一个用户,但仍然可以通过原始 fd 作为先前所有者访问该文件,因为仅在打开文件时检查权限。通过这种方式,文件描述符允许跨权限边界进行通信。通过让您的用户拥有的进程和 root 拥有的进程共享相同的文件描述符,您就授予这两个进程对该文件描述符的相同权限。而且,由于 fd 是一个管道,并且 root 进程正在从该管道获取命令,因此允许用户拥有的其他进程以 root 身份发出命令。管道本身没有所有者的概念,只是一系列碰巧拥有打开的文件描述符的进程。
此外,由于基本的 Linux 安全模型假设用户对其所有进程具有完全控制权,这意味着您可以/proc
像您所做的那样窥探以获取对 fd 的访问权限。您无法通过/proc
以 root 身份运行的 bash 进程的条目来执行此操作(因为您不是 root),但您可以为自己的进程执行此操作,并且获得的结果管道文件描述符与您可以执行的操作完全相同它直接到以 root 身份运行的子进程。因此,将数据回显到管道中会导致内核将其反弹回从管道读取命令的进程 - 在这种情况下,只有子根 shell 正在主动从管道读取命令。
如果从终端调用 shell 脚本,则将数据回显到其标准输入文件描述符实际上最终会写入数据到终端,它将显示给用户(但不由 shell 执行)。这是因为终端设备是双向的,事实上,终端将连接到 stdin 和 stdout(以及 stderr)。但是,终端具有用于注入输入数据的特殊 ioctl 方法,因此仍然可以以用户身份将命令注入到 root shell(它只需要一个简单的echo
)。
一般来说,您已经发现了有关权限升级的一个不幸的事实:当您允许用户通过任何方式有效地升级到 root shell 时,该用户运行的任何应用程序都应该被假定为能够滥用该升级(而它存在)。出于安全目的和目的,用户成为 root。即使这种标准输入注入不可能,例如,如果您在终端下运行脚本,您也可以简单地使用 X 服务器键盘注入支持直接在图形级别发送命令。或者您可以使用gdb
打开的管道附加到进程并向其中注入写入。弥补这个漏洞的唯一方法是让 root shell 直接连接到(物理)用户的安全 I/O 通道,该通道不能被非特权进程篡改。如果不严格限制可用性,这是很难做到的。
最后一件值得注意的事情:通常,(匿名)管道有一个读取端和一个写入端,即两个单独的文件描述符。作为 stdin 传递给子进程的结尾是读取端,而写入端将保留在调用 的原始进程中GLib.spawn_async()
。这意味着子进程实际上无法写入标准输入以将数据发送回自己或bash
以 root 身份运行(当然,进程通常不会写入标准输入,尽管没有什么说不能 - 但在这种情况下当 stdin 是管道的读取端时它不起作用)。然而,内核/proc
从另一个进程访问文件描述符的机制颠覆了这一点:如果一个进程有一个打开的 fd 到管道的读取端,但您尝试打开其各自的/proc
fd 文件进行写入,那么内核实际上会给您而是写同一管道的末尾。或者,您可以查找与/proc
调用的原始进程相对应的条目GLib.spawn_async()
,找到打开用于写入的管道末端,然后写入该末端,这不依赖于这种特殊的内核行为;这主要是出于好奇,但并没有真正改变安全问题。