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.