为什么在调用fork()之后,在调用exec...()之前我应该关闭所有文件描述符?如何做到这一点?

11

我看到很多C代码都试图在调用fork()和调用exec...()之间关闭所有文件描述符。为什么这样做很常见,以及如何在我的代码中以最佳方式执行此操作,因为我已经看到了许多不同的实现?

2个回答

33
当调用fork()时,操作系统通过克隆现有进程来创建一个新的进程。新进程与其克隆自的进程基本相同,除了进程ID和任何已记录在fork()调用中被替换或重置的属性。
当调用任何形式的exec...()时,调用进程的进程镜像被替换为一个新的进程镜像,但除此之外,进程状态得到保留。一个后果是,在调用exec...()之前,进程文件描述符表中打开的文件描述符仍然存在于该表中,因此新进程代码继承了对它们的访问权限。我猜这可能是为了让子进程自动继承STDINSTDOUTSTDERR
但是,请记住,在POSIX C中,文件描述符不仅用于访问实际的文件,还用于所有类型的系统和网络套接字、管道、共享内存标识符等。如果在调用exec...()之前不关闭这些文件描述符,则新的子进程将获得对它们的访问权限,即使它甚至没有所需的访问权限。想象一下,一个根进程创建一个非根子进程,但这个子进程将获得根父进程的所有打开文件描述符的访问权限,包括只能由根或受保护的服务器套接字下端口1024以下写入的打开文件。
所以,除非你希望一个子进程继承当前打开的文件描述符的访问权限,比如明确希望捕获进程的 STDOUT 或通过 STDIN 向该进程提供数据,否则在调用 exec...() 之前必须关闭它们。这不仅是因为安全性问题(有时可能根本没有作用),而且还因为否则子进程可用的空闲文件描述符将会减少(想象一下一个长链的进程,每个进程都会打开文件,然后生成一个子进程... 可用的空闲文件描述符将会越来越少)。
一种方法是始终使用标志 O_CLOEXEC 打开文件,这可以确保当 exec...() 被调用时,此文件描述符会自动关闭。这种解决方案的一个问题是,您无法控制外部库如何打开文件,因此不能保证所有代码都将始终设置此标志。
另一个问题是,这种解决方案仅适用于使用 open() 创建的文件描述符。您无法在创建套接字、管道等时传递该标志。这是一个已知的问题,一些系统正在通过提供非标准的 accept4()、pipe2()、dup3() 和 SOCK_CLOEXEC 标志来解决这个问题,但这些标志尚未成为 POSIX 标准,并且我们不知道它们是否会成为标准(这是计划中的,但在发布新标准之前我们无法确定,而且需要多年时间才能使所有系统都采用它们)。
你可以通过在文件描述符上使用fcntl()来设置FD_CLOEXEC标志,但请注意,在多线程环境中这样做是不安全的。考虑以下代码:
int so = socket(...);
fcntl(so, F_SETFD, FD_CLOEXEC);

如果另一个线程在第一行和第二行之间调用fork(),这当然是可能的,那么标志位还没有被设置,因此这个文件描述符不会被关闭。
所以唯一真正安全的方法是显式关闭它们,但这并不像看起来那么容易!
我见过很多代码做了像这样愚蠢的事情:
for (int i = STDERR_FILENO + 1; i < 256; i++) close(i);

但仅仅因为一些POSIX系统默认限制为256并不意味着这个限制不能被提高。而且在某些系统上,初始默认限制本来就更高。
使用FD_SETSIZE代替256同样是错误的,因为即使大多数系统上select() API默认有一个硬限制,进程也可以拥有比这个限制更多的打开文件描述符(毕竟你不必使用select(),你可以使用poll() API作为替代,poll()没有文件描述符数量的上限)。
始终正确的做法是使用OPEN_MAX代替256,因为这确实是一个进程可以拥有的文件描述符的绝对最大值。缺点是OPEN_MAX理论上可能非常大,并且不反映进程的实际当前运行时限制。
为了避免关闭太多不存在的文件描述符,您可以使用以下代码:
int fdlimit = (int)sysconf(_SC_OPEN_MAX);
for (int i = STDERR_FILENO + 1; i < fdlimit; i++) close(i);

sysconf(_SC_OPEN_MAX)被记录为在使用setrlimit()提高打开文件限制(RLIMIT_NOFILE)后能够正确更新。资源限制(rlimits)是运行进程和文件的有效限制,它们总是需要在_POSIX_OPEN_MAX(文档中记录为进程始终允许打开的最小文件描述符数,必须至少为20)和OPEN_MAX(必须至少为_POSIX_OPEN_MAX并设置上限)之间。

虽然在循环中关闭所有可能的描述符在技术上是正确的,并且将按预期工作,但它可能会尝试关闭几千个文件描述符,其中大部分通常不存在。即使对于不存在的文件描述符,close()调用很快(这不被任何标准保证),在较弱的系统上可能需要一段时间(考虑嵌入式设备,考虑小型单板计算机),这可能是一个问题。

因此,一些系统开发了更有效的方法来解决这个问题。著名的例子是closefrom()fdwalk(),它们得到了BSD和Solaris系统的支持。不幸的是,The Open Group投票反对将closefrom()添加到标准中(引用):"在保证符合环境的情况下关闭任意文件描述符以上的接口无法标准化。"(来源)这显然是无稽之谈,因为他们自己制定规则,如果他们定义某些文件描述符可以始终从关闭中静默地省略,那么这不会破坏该函数的任何现有实现,并且仍然为我们提供所需的功能。如果没有这些函数,人们将使用循环来做The Open Group试图避免的事情,因此不添加它只会使情况变得更糟。
在某些平台上,例如完全符合POSIX的macOS,你基本上没有什么办法。如果你不想在macOS上关闭所有文件描述符,你唯一的选择是不使用fork()/exec...(),而是使用posix_spawn()。posix_spawn()是一个新的API,用于不支持进程分叉的平台,在支持分叉的平台上可以纯粹地在用户空间中实现fork()/exec...(),并且可以使用平台提供的其他API来启动子进程。在macOS上存在一个非标准标志POSIX_SPAWN_CLOEXEC_DEFAULT,它将把所有文件描述符视为已设置了CLOEXEC标志,除了那些你明确指定了文件操作的文件描述符。
在Linux上,你可以通过查看路径/proc/{PID}/fd/获取文件描述符列表,其中{PID}是你的进程的进程ID(getpid()),也就是说,如果proc文件系统已经被挂载,并且已经被挂载到/proc(但很多Linux工具都依赖于这个,不这样做也会破坏许多其他东西)。基本上,你可以限制自己关闭此路径下列出的所有描述符。

如果在您的循环期间另一个线程打开了文件怎么办? - Nikita
@Nikita 顺序是:fork,关闭子进程中的FD,exec。一旦调用fork,当前进程会被原子地复制,并且调用线程成为子进程的新主线程。在子进程中,所有其他线程都已死亡并且永远不会接收运行时(它们在调用fork时冻结),因此它们无法在您在子进程中关闭它们时打开文件描述符。父进程中的线程仍然可以,但这些线程不再复制到派生的子进程中。请参见https://0cn.de/xh1t和https://0cn.de/pp30(“将创建具有单个线程的进程。如果是多线程...”) - Mecki
如果我们在执行exec之前关闭所有文件,那么标准文件描述符0、1、2是否会为新的进程重新创建? - Sandeep
1
@mk.. 不会。如果你关闭stdout,那么新创建的进程就没有stdout。任何试图写入stdout的尝试都会失败(printf()的结果将是负数,并设置errno)。如果你关闭stdin,任何试图从中读取数据的尝试也会失败。父进程必须为子进程提供这些流,可以通过继承自己的流或创建新的流来实现(例如,你可以打开一个文件,然后将此文件句柄作为stdout,现在所有的printf()调用都会写入该文件;或者你可以将管道的一端作为stdout并捕获子进程的输出)。 - Mecki
1
请注意,如果子进程没有标准输入/输出/错误,并打开一个文件,则第一个文件的描述符ID为0,下一个为1,以此类推。因此,这些文件将成为stdin/out/err。从安全角度来看,这是很危险的,也可能导致可怕的故障,因为代码通常不会检查这一点。因此,最好的做法是打开/dev/null并使用该文件ID作为stdin/out/err,如果您不希望子进程拥有任何这些内容。写入/dev/null只是无处可去,始终是安全的,从中读取只返回“流结束”(EOF)。 - Mecki
谢谢Mecki。我看了一下supervisor/systemd的实现,他们正是按照你所说的方式来做的。非常感谢你的清晰解释。 - Sandeep

12
真实故事:曾经我写了一个简单的C程序,打开了一个文件,发现open返回的文件描述符是4。我想:“这很奇怪,标准输入、输出和错误通常是文件描述符0、1和2,所以你打开的第一个文件描述符通常是3。”
于是我又写了一个小小的C程序,开始从文件描述符3读取(不是打开它,而是假设3是一个预先打开的 fd,就像0、1和2一样)。很快就显然,在我使用的Unix系统上,文件描述符3在系统密码文件上被预先打开了。这显然是login程序中的一个bug,它用fd 3仍然打开着密码文件来执行我的登录shell,而这个杂散的fd又被我从shell运行的程序所继承。
自然而然,我接下来尝试的是一个简单的C程序,尝试向预先打开的文件描述符3 写入数据,看能否修改密码文件并获得root权限。但是,这并没有起作用;杂散的fd 3以只读模式打开了密码文件。
但无论如何,这可以解释为什么当你exec一个子进程时,不应该保留文件描述符的开放状态。
[脚注:我说“真实故事”,它基本上是真的,但为了叙述起见,我改变了一个细节。实际上,/bin/login的有缺陷版本让fd 3在组文件/etc/group上打开,而不是密码文件。]

2
嗯,它是只读的,但一个可写的组文件处理程序已经足够危险了。这将允许您将用户添加到组中,突然之间我成为了“sudo”组的成员,可以获取root shell。 - Mecki

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接