设置容器/沙箱的一个常见场景是希望在新的 tmpfs 中创建最小的设备节点集(而不是暴露主机/dev
),而我知道的唯一(非特权)方法是通过绑定安装想要的进入其中。我正在使用的命令(内部unshare -mc --keep-caps
)是:
mkdir /tmp/x
mount -t tmpfs none /tmp/x
touch /tmp/x/null
mount -o bind /dev/null /tmp/x/null
打算将安装座移动到 的顶部/dev
。然而,即使在执行移动之前,运行echo > /tmp/x/null
也会产生“权限被拒绝”错误 ( EACCES
)。
但如果我另外执行:
mkdir /tmp/x/y
touch /tmp/x/y/null
mount -o bind /dev/null /tmp/x/y/null
echo > /tmp/x/y/null
写入成功,正如它应该的那样。我已经对此进行了相当多的尝试,但找不到根本原因或应该发生这种情况的原因。可以通过将绑定安装的节点放在子目录中并将它们的符号链接放在将成为 new 的文件系统的顶层来解决这个问题/dev
,但这似乎不是必需的。
这是怎么回事?对此有合理的解释吗?或者是某些访问控制逻辑出了问题?
答案1
嗯,这似乎是一个非常有趣的效果,这是三种机制结合在一起的结果。
第一个(琐碎的)点是,当您将某些内容重定向到文件时,shell 会打开目标文件,并带有选项O_CREAT
以确保在文件尚不存在时将创建该文件。
第二件要考虑的事情是,它/tmp/x
是一个tmpfs
挂载点,而/tmp/x/y
它是一个普通目录。假设您tmpfs
在没有选项的情况下挂载,挂载点的权限会自动更改,以便它变得全局可写并具有粘性位(1777
,这是 的一组常用权限/tmp
,因此这感觉像是一个合理的默认值),而 的权限/tmp/x/y
是可能0755
(取决于你的umask
)。
最后,难题的第三部分是设置用户命名空间的方式:指示unshare(1)
将主机用户的 UID/GID 映射到新命名空间中的相同 UID/GID。这是唯一的映射到新的命名空间,因此尝试在父/子命名空间之间转换任何其他 UID 将导致所谓的溢出UID,默认情况下是65534
—nobody
用户(请参阅user_namespaces(7)
部分Unmapped user and group IDs
)。这使得/dev/null
(及其绑定挂载)由子用户命名空间内部拥有(因为子用户命名空间中nobody
没有主机用户的映射):root
$ ls -l /dev/null
crw-rw-rw- 1 nobody nobody 1, 3 Nov 25 21:54 /dev/null
将所有事实结合在一起,我们得出以下结论:echo > /tmp/x/null
尝试使用选项打开现有文件O_CREAT
,而该文件驻留在全局可写的粘性目录中并且由 拥有nobody
,而该文件不是包含该文件的目录的所有者。
现在,请openat(2)
仔细阅读,逐字逐句:
欧洲航空航天中心
如果指定了 O_CREAT,则启用 protected_fifos 或 protected_regular sysctl,文件已存在并且是 FIFO 或常规文件,文件的所有者既不是当前用户也不是包含目录的所有者,并且包含目录都是 world - 或组可写且粘性。具体请参见proc(5)中/proc/sys/fs/protected_fifos和/proc/sys/fs/protected_regular的说明。
这不是很棒吗?这看起来几乎就像我们的情况......除了手册页告诉的事实仅有的关于普通文件和 FIFO 并讲述没有什么关于设备节点。
好吧,让我们看一下实际实现这个的代码。我们可以看到,本质上,它首先检查必须成功的异常情况(第一个if
),然后如果粘性目录是全局可写的,则它只是拒绝任何其他情况的访问(第二个if
,第一个条件):
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;
}
因此,如果目标文件是字符设备(不是常规文件或 FIFO),O_CREAT
当该文件位于全局可写粘性目录中时,内核仍然拒绝打开它。
为了证明我正确地找到了原因,我们可以检查问题是否在以下任一情况下消失:
- 安装— 这
tmpfs
将-o mode=777
不是使挂载点有一个粘性位; - 打开
/tmp/x/null
为O_WRONLY
,但不带选项(要测试这一点,请编写一个调用和O_CREAT
的程序,然后编译并运行它以查看每个调用的返回值)。open("/tmp/x/null", O_WRONLY | O_CREAT)
open("/tmp/x/null", O_WRONLY)
strace -e trace=openat
我不确定这种行为是否应该被视为内核错误,但文档显然openat(2)
不包括全部该系统调用时的情况实际上失败并显示EACCES
.