Linux – Bind Mounts of Device Nodes Break with EACCES in Root of tmpfs

bind-mountlinuxmountnamespace

A common scenario for setting up a container/sandbox is wanting to create a minimal set of device nodes in a new tmpfs (rather than exposing the host /dev), and the only (unprivileged) way I know to do this is by bind-mounting the desired ones into it. The commands I'm using (inside unshare -mc --keep-caps) are:

mkdir /tmp/x
mount -t tmpfs none /tmp/x
touch /tmp/x/null
mount -o bind /dev/null /tmp/x/null

with the intend of moving the mount on top of /dev. However, even before doing the move, running echo > /tmp/x/null produces a "Permission denied" error (EACCES).

Yet if I additionally perform:

mkdir /tmp/x/y
touch /tmp/x/y/null
mount -o bind /dev/null /tmp/x/y/null
echo > /tmp/x/y/null

the write succeeds as it should. I've played around with this quite a bit, but can't find a root cause or reason this should be happening. It's possible to work around it by putting the bind-mounted nodes in a subdirectory and symlinks to them in the top-level of the filesystem that will become the new /dev, but it seems like this shouldn't be necessary.

What's going on? Is there a reasonable explanation for this? Or is it some access control logic gone wrong?

Best Answer

Well, this seems to be a very interesting effect, which is a consequence of three mechanisms combined together.

The first (trivial) point is that when you redirect something to the file, the shell opens the target file with the O_CREAT option to be sure that the file will be created if it does not yet exist.

The second thing to consider is the fact that /tmp/x is a tmpfs mountpoint, while /tmp/x/y is an ordinary directory. Given that you mount tmpfs with no options, the mountpoint's permissions automagically change so that it becomes world-writable and has a sticky bit (1777, which is a usual set of permissions for /tmp, so this feels like a sane default), while the permissions for /tmp/x/y are probably 0755 (depends on your umask).

Finally, the third part of the puzzle is the way you set up the user namespace: you instruct unshare(1) to map UID/GID of your host user to the same UID/GID in the new namespace. This is the only mapping in new namespace, so trying to translate any other UID between the parent/child namespaces will result in so-called overflow UID, which by default is 65534 — a nobody user (see user_namespaces(7), section Unmapped user and group IDs). This makes /dev/null (and its bind-mounts) be owned by nobody inside the child user namespace (as there is no mapping for host's root user in the child user namespace):

$ ls -l /dev/null
crw-rw-rw- 1 nobody nobody 1, 3 Nov 25 21:54 /dev/null

Combining all the facts together we come to the following: echo > /tmp/x/null tries to open an existing file with O_CREAT option, while this file resides inside the world-writable sticky directory and is owned by nobody, who is not the owner of the directory containing it.

Now, read openat(2) carefully, word by word:

EACCES

Where O_CREAT is specified, the protected_fifos or protected_regular sysctl is enabled, the file already exists and is a FIFO or regular file, the owner of the file is neither the current user nor the owner of the containing directory, and the containing directory is both world- or group-writable and sticky. For details, see the descriptions of /proc/sys/fs/protected_fifos and /proc/sys/fs/protected_regular in proc(5).

Isn't this brilliant? This seems almost like our case... Except the fact that the man page tells only about ordinary files and FIFOs and tells nothing about device nodes.

Well, let's take a look at the code which actually implements this. We can see that, essentially, it first checks for exceptional cases which must succeed (the first if), and then it just denies the access for any other case if the sticky directory is world-writable (the second if, first condition):

static int may_create_in_sticky(umode_t dir_mode, kuid_t dir_uid,
        struct inode * const inode)
{
  if ((!sysctl_protected_fifos && S_ISFIFO(inode->i_mode)) ||
      (!sysctl_protected_regular && S_ISREG(inode->i_mode)) ||
      likely(!(dir_mode & S_ISVTX)) ||
      uid_eq(inode->i_uid, dir_uid) ||
      uid_eq(current_fsuid(), inode->i_uid))
    return 0;

  if (likely(dir_mode & 0002) ||
      (dir_mode & 0020 &&
       ((sysctl_protected_fifos >= 2 && S_ISFIFO(inode->i_mode)) ||
        (sysctl_protected_regular >= 2 && S_ISREG(inode->i_mode))))) {
    const char *operation = S_ISFIFO(inode->i_mode) ?
          "sticky_create_fifo" :
          "sticky_create_regular";
    audit_log_path_denied(AUDIT_ANOM_CREAT, operation);
    return -EACCES;
  }
  return 0;
}

So, if the target file is a char device (not a regular file or a FIFO), the kernel still denies opening it with O_CREAT when this file is in the world-writable sticky directory.

To prove that I found the reason correctly, we may check that the problem disappears in any of the following cases:

  • mount tmpfs with -o mode=777 — this will not make the mountpoint have a sticky bit;
  • open /tmp/x/null as O_WRONLY, but without O_CREAT option (to test this, write a program calling open("/tmp/x/null", O_WRONLY | O_CREAT) and open("/tmp/x/null", O_WRONLY), then compile and run it under strace -e trace=openat to see the returned values for each call).

I'm not sure whether this behavior should be considered a kernel bug or not, but the documentation for openat(2) clearly does not cover all the cases when this syscall actually fails with EACCES.