【问题标题】:Race condition when using dup2使用 dup2 时的竞争条件
【发布时间】:2014-05-03 04:21:23
【问题描述】:

This manpage 用于dup2 系统调用说:

EBUSY(仅限 Linux)这可能会在运行期间由 dup2() 或 dup3() 返回 open(2) 和 dup() 的竞争条件。

它谈论什么竞争条件,如果dup2 给出EBUSY 错误,我该怎么办?我应该像EINTR一样重试吗?

【问题讨论】:

  • 到目前为止,我在 OpenBSD 中发现了以下问题,可能db.risk.io/cves/CVE-2001-1047 不确定类似的情况是否适用于 Linux,但如果处理得当,那么,是的,你应该把它当作 EINTR

标签: c linux posix race-condition dup2


【解决方案1】:

我并不完全了解 Linux 做出的选择,但其他答案中 Linux 内核的评论指出了我 13 年前在 OpenBSD 中所做的工作,所以我在这里尝试记住到底是什么继续。

由于open的实现方式,它首先分配一个文件描述符,然后它实际上尝试在解锁文件描述符表的情况下完成打开操作。一个原因可能是我们实际上不想引起 open 的副作用(最简单的方法是更改​​文件的时间,但例如打开设备可能会产生更严重的副作用),因为我们退出了文件描述符。这同样适用于分配文件描述符的所有其他操作,当您阅读下面的文本时,只需将 open 替换为“分配文件描述符的任何系统调用”即可。我不记得这是 POSIX 规定的还是只是事情一直以来的做法。

open 可以分配内存,进入文件系统并做一堆可能长时间阻塞的事情。对于像 fuse 这样的文件系统,在最坏的情况下,它甚至可能会回到用户态。由于这个原因(和其他原因),我们实际上不想在整个打开操作期间锁定文件描述符表。内核中的锁在睡眠时很难保持,如果完成锁定操作可能需要与用户空间进行交互[1]。

当有人在一个线程(或共享相同文件描述符表的进程)中调用 open 时,会出现问题,它分配了一个文件描述符但尚未完成,而同时另一个线程执行 @ 987654329@ 指向与open 刚刚获得的相同文件描述符。由于未完成的文件描述符仍然无效(例如,readwrite 在您尝试使用它时会返回 EBADF),我们实际上还不能关闭它。

在 OpenBSD 中,这是通过使用复杂的引用计数跟踪已分配但尚未打开的文件描述符来解决的。大多数操作会假装文件描述符不存在(但它也不能分配)并且只会返回EBADF。但是对于dup2,我们不能假装它不存在,因为它存在。最终结果是,如果两个线程同时调用opendup2,open实际上会对文件执行完全打开操作,但是由于dup2赢得了文件描述符的竞争,最后一件事open做是减少它刚刚分配的文件的引用计数并再次关闭它。与此同时,dup2 赢得了比赛并假装关闭了open 得到的文件描述符(它实际上并没有这样做,实际上是open 做到了)。内核选择哪种行为并不重要,因为在这两种情况下,这都会导致opendup2 出现意外行为。充其量,返回 EBUSY 的 Linux 只是缩小了比赛的窗口,但比赛仍然存在,没有什么可以阻止 dup2 调用的发生,就像open 在另一个线程中返回并替换之前的文件描述符一样open 的调用者有机会使用它。

当您参加这场比赛时,您问题中的错误很可能会发生。为避免这种情况,请不要dup2 指向您不知道其状态的文件描述符,除非您确定没有其他人将同时访问文件描述符表。唯一可以确定的方法是成为唯一运行的线程(文件描述符一直由库在你背后打开)或者确切地知道你正在覆盖什么文件描述符。首先允许dup2 覆盖未分配的文件描述符的原因是关闭 fds 0、1 和 2 以及 dup2 /dev/null 进入它们是一种常见的习惯用法。

另一方面,在dup2 之前不关闭文件描述符将丢失来自close 的错误返回。不过我不担心,因为来自close 的错误很愚蠢,一开始就不应该存在:Handling C Read Only File Close Errors 另一个线程意外行为的例子以及文件描述符如何奇怪地表现,因为我一直在谈论这里看到这个问题:Socket descriptor not getting released on doing 'close ()' for a multi-threaded UDP client

这里有一些示例代码来触发这个:

#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <err.h>
#include <pthread.h>

static void *
do_bad_things(void *v)
{
    int *ip = v;
    int fd;

    sleep(2);   /* pretend this is proper synchronization. */

    if ((fd = open("/dev/null", O_RDONLY)) == -1)
        err(1, "open 2");

    if (dup2(fd, *ip))
        warn("dup2");

    return NULL;
}

int
main(int argc, char **argv)
{
    pthread_t t;
    int fd;

    /* This will be our next fd. */
    if ((fd = open("/dev/null", O_RDONLY)) == -1)
        err(1, "open");
    close(fd);

    if (mkfifo("xxx", 0644))
        err(1, "mkfifo");

    if (pthread_create(&t, NULL, do_bad_things, &fd))
        err(1, "pthread_create");

    if (open("xxx", O_RDONLY) == -1)
        err(1, "open fifo");

    return 0;
}

FIFO 是导致open 阻塞的标准方法,只要您愿意。正如预期的那样,这在 OpenBSD 上静默运行,在 Linux 上 dup2 返回 EBUSY。在 MacOS 上,由于某种原因,它会杀死我执行“echo foo > xxx”的 shell,而打开它进行编写的普通程序工作正常,我不知道为什么。

[1] 这里有一个轶事。我一直参与编写用于 AFS 实现的类似熔断器的文件系统。我们遇到的一个错误是我们在调用用户空间时持有文件对象锁。目录项查找的锁定协议要求您持有目录锁,然后查找目录项,锁定该目录项下的对象,然后释放目录锁。由于我们持有文件对象锁,其他一些进程进来并试图查找文件,这导致该进程在仍然持有目录锁的同时为文件锁休眠。另一个进程进来了,试图查找目录,并最终持有父目录的锁。长话短说,我们最终得到了一连串锁,直到我们到达根目录。与此同时,文件系统守护进程仍在通过网络与服务器通信。由于某种原因,网络操作失败,文件系统守护进程需要记录错误消息。为此,它必须读取一些语言环境数据库。为此,它需要使用完整路径打开文件。但由于根目录被其他人锁定,守护进程等待该锁定。我们有一个 8 锁长的死锁链。这就是为什么内核经常执行复杂的柔术体操以避免在长时间操作期间持有锁,尤其是文件系统操作。

【讨论】:

  • 感谢您的详细解答。我认为这解决了我的主要问题,即EBUSY 是否可以在dup2 的正确使用下发生:其中目标文件描述符在dup2 调用之前已知是打开的,并且不会在调用期间关闭,或者有保证dup2调用过程中没有其他线程会分配fd,基本上只有在程序是单线程或者极其简单的时候才能保证。这个答案绝对值得赏金,所以+500。 :-)
  • 添加了一些示例代码并更新了一些文本,因为我有机会在上面睡觉。
  • 您的示例使这个答案变得更好,因为它 (1) 演示了在 EBUSY 上循环导致死锁的情况,并且 (2) 几乎违反了 XSH 2.9.7 Thread Interactions with Regular File Operations,除了 FIFO不是常规文件。如果 Linux 可以在打开常规文件时导致这个EBUSY 发生(具有更难打的比赛),那么我认为这个问题实际上是一个一致性问题。
  • 您绝对可以参加这场比赛以获得常规文件。如果你想尝试它,最简单的方法是在 NFS 挂载上打开一个常规文件,而 NFS 服务器已关闭或具有巨大的网络延迟。 Linux 在这里已经违反了 POSIX,因为据我了解,您不能返回随机错误,并且 EBUSY 未指定为 POSIX 中 dup2 的有效错误。另一方面,这可能意味着 POSIX 将得到更新。
  • 说到更新POSIX和dup2,看看FD_CLOEXEC是怎么说的。我敢肯定它在十年前是不存在的。有关历史的有根据的猜测,请查看以下内容:openbsd.org/cgi-bin/cvsweb/~checkout~/src/regress/sys/kern/… FD_CLOEXEC 的清除和 FD_CLOEXEC 的未清除都没有记录在案。 POSIX 刚刚更新以匹配每个人都犯的相同的实现错误。现在 Linux 有 dup3 可以明确地指出这个错误的行为。
【解决方案2】:

fs/file.cdo_dup2()中有解释:

/*
 * We need to detect attempts to do dup2() over allocated but still
 * not finished descriptor.  NB: OpenBSD avoids that at the price of
 * extra work in their equivalent of fget() - they insert struct
 * file immediately after grabbing descriptor, mark it larval if
 * more work (e.g. actual opening) is needed and make sure that
 * fget() treats larval files as absent.  Potentially interesting,
 * but while extra work in fget() is trivial, locking implications
 * and amount of surgery on open()-related paths in VFS are not.
 * FreeBSD fails with -EBADF in the same situation, NetBSD "solution"
 * deadlocks in rather amusing ways, AFAICS.  All of that is out of
 * scope of POSIX or SUS, since neither considers shared descriptor
 * tables and this condition does not arise without those.
 */
fdt = files_fdtable(files);
tofree = fdt->fd[fd];
if (!tofree && fd_is_open(fd, fdt))
    goto Ebusy;

看起来EBUSY 会在要释放的描述符处于某种不完整状态且仍在打开时返回(fd_is_open 但在fdtable 中不存在)。

编辑(更多信息并且想要赏金)

为了了解!tofree &amp;&amp; fd_is_open(fd, fdt) 是如何发生的,让我们看看文件是如何打开的。这里是sys_open 的简化版:

long do_sys_open(int dfd, const char __user *filename, int flags, umode_t mode)
{
    /* ... irrelevant stuff */
    /* allocate the fd, uses a lock */
    fd = get_unused_fd_flags(flags);
    /* HERE the race condition can arise if another thread calls dup2 on fd */
    /* do the real VFS stuff for this fd, also uses a lock */
    fd_install(fd, f);
    /* ... irrelevant stuff again */
    return fd;
}

基本上会发生两件非常重要的事情:分配文件描述符,然后才真正由 VFS 打开它。这两个操作修改了进程的fdt。他们都使用锁,所以在这两个调用中没有什么不好的。

为了记住哪个fds 被分配了一个名为open_fds 的位向量,fdt 使用。在get_unused_fd_flags() 之后,fd 已被分配,并在open_fds 中设置了相应的位。 fdt 上的锁已被释放,但真正的 VFS 工作尚未完成。

此时,另一个线程(或共享fdt 的另一个进程)可以调用不会阻塞的dup2,因为锁已被释放。如果 dup2 在此处采用其正常路径,则 fd 将被替换,但 fd_install 仍将针对旧文件运行。因此检查并返回Ebusy

我在fd_install() 的 cmets 中找到了有关此竞争条件的更多信息,这证实了我的解释:

/* The VFS is full of places where we drop the files lock between
 * setting the open_fds bitmap and installing the file in the file
 * array.  At any such point, we are vulnerable to a dup2() race
 * installing a file in the array before us.  We need to detect this and
 * fput() the struct file we are about to overwrite in this case.
 *
 * It should never happen - if we allow dup2() do it, _really_ bad things
 * will follow. */

【讨论】:

  • 您能否提供更多关于何时会发生这种情况的详细信息?我的印象是它绝对不会发生在单线程进程中(任务不共享他们的 fd 表 (CLONE_FILES) 但内核源代码中的 cmets (“所有这些都超出了 POSIX 或 SUS 的范围,因为认为共享描述符表...") 似乎可能是错误的,因为线程确实共享描述符表并且由 POSIX 指定。
  • 此外,我的印象是,当正确使用 dup2 原子地替换调用者知道的描述符时,不会发生此错误,仅当使用 dup2 和未分配的 new fd 时如果另一个线程调用open,则会受到竞争条件的影响。我的理解是dup2 的这种用法会在多线程进程中调用未定义的行为,因此可以忽略EBUSY 错误(如果没有UB,它不会发生)。这个对吗?我正在开放赏金,并将其提供给能够澄清这些事情的人。
  • @R.. 好的,但这听起来比这个问题更复杂......为什么不打开一个新问题?
  • @ElliottFrisch:手册页中的注释是一个错误; dup2dup3重点 是原子地替换文件描述符。当目标 fd 尚未打开时调用 dup2dup3 是一个严重的错误,除了在单线程进程中之外,它会带来可怕的安全性和数据完整性后果。几个小时前我向 Michael Kerrisk(手册页维护者)报告了这个问题,我怀疑他会修复它。
  • 那么有人想为 500 赏金写一个详细的总结吗?
猜你喜欢
  • 1970-01-01
  • 2011-12-12
  • 2019-02-10
  • 2015-04-28
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2019-01-07
相关资源
最近更新 更多