[apue] linux 文件访问权限那些事儿

2022-08-31 10:15:58 浏览数 (1)

前言

说到 linux 上的文件权限,其实我们在说两个实体,一是文件,二是进程。一个进程能不能访问一个文件,其实由三部分内容决定:

  1. 文件的所有者、所在的组;
  2. 文件对所有者、组用户、其它用户设置的权限访问位;
  3. 启动进程的用户、所在的组、有效用户、有效用户组。

下面先简单说明一下这些基本概念,最后再说明它们是如何相互作用并影响进程访问文件的。

用户与组

用户 ID 唯一标识一个登录用户,记录在口令文件 (/etc/passwd) 中。ID 为 0 的用户为超级用户或根用户 (root),具有绕过文件权限检查的特权。

组 ID 用于将一类用户组织在一起,记录在组文件 (/etc/group) 中。下面这段 shell 脚本用来演示如何创建用户并将它们添加到组中:

代码语言:javascript复制
 1 #! /bin/bash
 2 useradd lippman
 3 useradd steven
 4 useradd caveman
 5 useradd paperman
 6 echo "create user ok"
 7 
 8 groupadd men
 9 groupadd share
10 echo "create group ok"
11 
12 usermod -a -G share lippman
13 usermod -a -G share steven
14 usermod -a -G men lippman
15 usermod -a -G men caveman
16 usermod -a -G men paperman
17 echo "add user to group ok"
18 
19 groups lippman steven caveman paperman
20 echo "show user and their group ok"
21 
22 groupdel men
23 groupdel share
24 echo "delete group ok"
25 
26 groups lippman steven caveman paperman
27 echo "show user and their group ok"
28 
29 userdel lippman
30 userdel steven
31 userdel caveman
32 userdel paperman
33 echo "delete user ok"
34 
35 rm -rf /home/lippman
36 rm -rf /home/steven
37 rm -rf /home/caveman
38 rm -rf /home/paperman
39 echo "remve user home dir ok"

这段脚本需要有管理员权限,请确保当前用户为 root 用户或属于 sudoer 用户组并使用 sudo 运行。下面是脚本的输出:

代码语言:javascript复制
$ sudo ./user_init.sh
create user ok
create group ok
add user to group ok
lippman : lippman men share
steven : steven share
caveman : caveman men
paperman : paperman men
show user and their group ok
delete group ok
lippman : lippman
steven : steven
caveman : caveman
paperman : paperman
show user and their group ok
delete user ok
remve user home dir ok

在你的机器上执行这段脚本的时候要特别小心,确保不会有同名的用户或组已经存在,否则可能会将数据误删除。特别是删除用户时,用户的工作目录是不会一并删除的,为了防止下次执行脚本时报警 (工作目录已存在),这里同时删除用户的工作目录 (line 35-38)。groups 命令为参数列表中的每个用户罗列它们所在的组,一个用户可以属于多个组,它创建时所在的组称为初始组,其它组称为附加组,一个用户最多可以添加的附加组数量上限可以通过 sysconf (_SC_NGROUPS_MAX) api 获取 (或通过 getconf NGROUPS_MAX 命令获取),在我的机器上这个值是 65536。关于系统限制值,可以参考我之前写的这篇文章:《[apue] 一个快速确定新系统上各类限制值的工具 》。

从上面两组高亮的输出可以看出,附加组是可以先于用户删除的,删除之后用户就不在组中了。useradd 命令创建的用户初始组名称默认同用户名,也可以通过 -g 参数指定一个已存在的组作为初始组,及通过 -G 参数指定一个或多个附加组,这与 usermod 命令的使用方式是相似的:

代码语言:javascript复制
 1 #! /bin/bash
 2 groupadd men
 3 groupadd share
 4 echo "create group ok"
 5 
 6 useradd lippman -G share,men
 7 useradd -g share steven
 8 useradd -g men caveman
 9 useradd -g men paperman
10 echo "create user ok"
11 
12 groups lippman steven caveman paperman
13 echo "show user and their group ok"
14 
15 groupdel men
16 groupdel share
17 echo "delete group ok"
18 
19 groups lippman steven caveman paperman
20 echo "show user and their group ok"
21 
22 userdel lippman
23 userdel steven
24 userdel caveman
25 userdel paperman
26 echo "delete user ok"
27 
28 rm -rf /home/lippman
29 rm -rf /home/steven
30 rm -rf /home/caveman
31 rm -rf /home/paperman
32 echo "remve user home dir ok"

与之前不同的是,为了在创建用户时指定初始组,组的创建被放在了前面。这段脚本的输出如下:

代码语言:javascript复制
$ sudo ./user_init.sh
create group ok
create user ok
lippman : lippman men share
steven : share
caveman : men
paperman : men
show user and their group ok
groupdel: cannot remove the primary group of user 'caveman'
groupdel: cannot remove the primary group of user 'steven'
delete group ok
lippman : lippman men share
steven : share
caveman : men
paperman : men
show user and their group ok
delete user ok
remve user home dir ok

可以看到通过 -G  添加组时,还是创建了默认的初始组 (lippman),而组在作为用户的初始组存在的情况下是无法被删除的 (line 15-16,附加组可以),所以最好调整一下删除用户和组的顺序:

代码语言:javascript复制
 1 #! /bin/bash
 2 groupadd men
 3 groupadd share
 4 echo "create group ok"
 5 
 6 useradd lippman -G share,men
 7 useradd -g share steven
 8 useradd -g men caveman
 9 useradd -g men paperman
10 echo "create user ok"
11 
12 groups lippman steven caveman paperman
13 echo "show user and their group ok"
14 
15 userdel lippman
16 userdel steven
17 userdel caveman
18 userdel paperman
19 echo "delete user ok"
20 
21 rm -rf /home/lippman
22 rm -rf /home/steven
23 rm -rf /home/caveman
24 rm -rf /home/paperman
25 echo "remve user home dir ok"
26 
27 groupdel men
28 groupdel share
29 echo "delete group ok"

这样再跑就没问题了:

代码语言:javascript复制
$ sudo ./user_init.sh
create group ok
create user ok
lippman : lippman men share
steven : share
caveman : men
paperman : men
show user and their group ok
delete user ok
remve user home dir ok
delete group ok

还有个有意思的点可以关注一下:

  • 删除用户时,用户的初始组也会被一起删除,但仅限该初始组没有被其它用户共享的情况下;
  • 单独创建的附加组即使没有包含任何用户,也不会随着最后用户的删除被自动删除。

为了简化后面的描述,将使用以下术语表示上面的概念:

  • 超级用户: root
  • 用户 ID:uid (user id)
  • 用户组 ID:gid (group id)
  • 用户初始组 (登录组):initgrp (initial group)
  • 用户附加组:supgrp (supplementary group)

与用户和组相关的一些命令罗列如下:

  • 用户:useradd / usermod / userdel / users
  • 用户密码:passwd / useradd | usermod -p
  • 用户组:groupadd / groupmod / groupdel
  • 用户组密码:gpasswd / groupadd | groupmod -p  (你没看错,用户组也可以有密码)
  • 用户与组的关系:id / groups / groupmems / usermod | useradd -g | G / gpasswd -a | -d
  • 用户登录:su / sudo / who / whoami / last / ac

这里需要强调的是通过 usermod 修改用户组时,有三种方式:

  • usermod -a group1,group2... user:将 group1-n 添加到 user 的附加组中,原附加组保持不变;
  • usermod -G group1,group2... user:将 user 的附加组设置为 group1-n,原附加组被清除;
  • usermod -g group user:将 user 的初始组设置为 group。

另外,像上面例子那样,删除用户时需要同时删除用户 home 目录的时候,只需要给 userdel 添加一个 -r 参数即可。有时除了 home 目录,系统还会为新用户创建邮件目录,如果删除用户不清理这些目录的话,再次创建的同名用户就会告警,这里都可以通过 -r 参数一并删除,避免后顾之忧。

后面我们会用这里创建的用户及组来做一些验证,具体就是在 line 14 插入一些测试脚本,用于验证一些在多用户场景下的权限问题。使用 su 命令可在多用户之间切换,如果用户设置了密码,则在切换时要求输入密码,这里为了测试的便利性,都没有给用户帐号添加密码,在实际场景中应避免这样使用。

文件的用户与组

文件本身有很多类型:

  • 普通文件
  • 目录文件
  • 符号链接
  • 块设备
  • 字符设备
  • FIFO
  • 套接字

所有文件都有创建者的 uid 和 gid,也有对应的文件权限位。针对普通文件,还可以再做一细分:

  • 可执行文件
  • 一般文件

可执行文件一般符合某种固定格式 (例如 elf),是进程的载体。针对这种文件,可以多设置两种标志位:

  • 设置用户 ID
  • 设置组 ID

它们决定了以该文件作为进程启动时,新进程所使用的 uid 和 gid。对于 Solaris 系统,设置组 ID 也可以给普通的一般文件设置,不过含义也大为不同:表示启用强制性文件记录锁,这是一种非标准扩展,不在本文的讨论范围,这里就不再展开说明了。

针对目录文件,也可以多设置两种标志位:

  • 设置组 ID
  • 粘住位 (sticky bit / svtx)

设置组 ID 与文件中的标志位相同,但是作用于目录时,意义又不一样了:表示该组下创建的文件的用户组 ID 将追随自己,而不是创建进程的组 ID,关于进程的组 ID 详见下一节,关于目录设置组 ID 位后新建文件的所有权,详见“新建文件的权限”这一节;目录加入粘住位时,会改变目录默认的删除文件、修改文件名的规则,具体见“进程访问文件时内核权限检查过程”这一节。

为了简化后面的描述,将使用以下术语表示上面的概念:

  • 文件创建者(拥有者)用户 ID:ouid (owner uid)
  • 文件创建者(拥有者)用户组 ID:ogid (owner gid)
  • 文件权限位:perm (permission)
  • 文件设置用户 ID:setuid
  • 文件设置组 ID:setgid
  • 文件粘住位:svtx (saved text bit)

需要注意的是文件没有附加组的概念,它属于上一节"用户与组"的范畴,文件只能属于一个用户组,在创建时确定,不随用户的变更而变更,本节末尾有一个测试用例来验证这一点。以上与文件相关的概念术语对应到文件的 stat 结构体的关系如下:

  • ouid:st_uid
  • ogid:st_gid
  • 文件类型:type = st_mode & S_IFMT
    • 普通文件:type & S_IFREG
    • 目录文件:type & S_IFDIR
    • 符号链接:type & S_IFLNK
    • 块设备:type & S_IFBLK
    • 字符设备:type & S_IFCHR
    • FIFO:type & S_IFFIFO
    • 套接字:type & S_IFSOCK
  • setuid:st_mode & S_ISUID
  • setgid:st_mode & S_ISGID
  • svtx:st_mode & S_ISVTX

这个结构体使用 stat / fstat / lstat 等 api 获取,如果要获取符号链接本身的属性,需要使用 lstat,否则获取的是符号链接指向的目标属性。此外,还可以通过在 find 命令中指定参数来查找特定类型的文件,以上内容与 find 参数之间的对应关系如下:

  • 普通文件:-type f
  • 目录文件:-type d
  • 符号链接:-type l
  • 块设备:-type b
  • 字符设备:-type c
  • FIFO:-type p
  • 套接字:-type s

这些符号简写其实与 ls 输出的文件类型是一致的 (每行第一个字符),关于 ls 的输出例子,请参考后面和 find 结合查找文件的例子。三个额外的标志位通过 chmod 修改时,使用的关键字符如下:

  • setuid:chmod u /-s
  • setgid:chmod g /-s
  • svtx:chmod o /-t

注意额外标志位是与固定的 u/g/o 权限组搭配的,关于权限组请参考“文件访问权限位”一节。如果进行了错误的搭配,虽然不会报错,但是也不会生效。由于这三个标志位是与执行权限放在一起的,所以最终显示什么字符还与之前有没有设置可执行 (x) 权限有关:

  • setuid x:rws --- ---
  • setuid-x:rwS --- ---
  • setgid x:--- rws ---
  • setgid-x:--- rwS ---
  • svtx x : --- --- rwt
  • svtx-x :--- --- rwT

即小写字母表示有执行权限,大写表示没有。也可以使用 find 搜索带有特定标志位的文件,上面的内容与 find 搜索参数的对应关系为:

  • setuid:-perm -u s
  • setgid:-perm -g s
  • svtx:-perm -o t

格式与 chmod 非常类似。其它的 rwx 权限位也都是可以搜索的,这里就不赘述了。下面我们用这个命令在测试机上搜索一些“特殊”的文件,首先看下 setuid 标志位:

代码语言:javascript复制
$ find / -perm -u s 2>/dev/null | xargs ls -ldh
-rwsr-xr-x 1 root root     52K Oct 31  2018 /usr/bin/at
-rwsr-xr-x 1 root root     73K Aug  9  2019 /usr/bin/chage
-rws--x--x 1 root root     24K Feb  3 00:31 /usr/bin/chfn
-rws--x--x 1 root root     24K Feb  3 00:31 /usr/bin/chsh
-rwsr-xr-x 1 root root     57K Aug  9  2019 /usr/bin/crontab
-rwsr-xr-x 1 root root     32K Oct 31  2018 /usr/bin/fusermount
-rwsr-xr-x 1 root root     77K Aug  9  2019 /usr/bin/gpasswd
-rwsr-xr-x 1 root root     44K Feb  3 00:31 /usr/bin/mount
-rwsr-xr-x 1 root root     41K Aug  9  2019 /usr/bin/newgrp
-rwsr-xr-x 1 root root     28K Apr  1  2020 /usr/bin/passwd
-rwsr-xr-x 1 root root     24K Apr  1  2020 /usr/bin/pkexec
---s--x--- 1 root stapusr 208K Oct 14  2020 /usr/bin/staprun
-rwsr-xr-x 1 root root     32K Feb  3 00:31 /usr/bin/su
---s--x--x 1 root root    144K Jan 27 05:56 /usr/bin/sudo
-rwsr-xr-x 1 root root     32K Feb  3 00:31 /usr/bin/umount
-rwsr-sr-x 1 abrt abrt     15K Oct  2  2020 /usr/libexec/abrt-action-install-debuginfo-to-abrt-cache
-rwsr-x--- 1 root dbus     57K Sep 30  2020 /usr/libexec/dbus-1/dbus-daemon-launch-helper
-rwsr-xr-x 1 root root     16K Apr  1  2020 /usr/lib/polkit-1/polkit-agent-helper-1
-rwsr-xr-x 1 root root     11K Apr  1  2020 /usr/sbin/pam_timestamp_check
-rwsr-xr-x 1 root root     36K Apr  1  2020 /usr/sbin/unix_chkpwd
-rws--x--x 1 root root     40K Aug  9  2019 /usr/sbin/userhelper
-rwsr-xr-x 1 root root     12K Nov 17  2020 /usr/sbin/usernetctl

搜索到的全是普通文件,且是可执行文件,大部分位于 /usr/bin 下面,一般是超级用户开放给普通用户使用的命令。再看下 setgid 针对普通文件的情况:

代码语言:javascript复制
$ find / -type f -perm -g s 2>/dev/null | xargs ls -lh
-r-x--s--x 1 root slocate   40K Apr 11  2018 /usr/bin/locate
---x--s--x 1 root nobody   374K Aug  9  2019 /usr/bin/ssh-agent
-r-xr-sr-x 1 root tty       15K Jun 10  2014 /usr/bin/wall
-rwxr-sr-x 1 root tty       20K Feb  3 00:31 /usr/bin/write
-rwsr-sr-x 1 abrt abrt      15K Oct  2  2020 /usr/libexec/abrt-action-install-debuginfo-to-abrt-cache
---x--s--x 1 root ssh_keys 455K Aug  9  2019 /usr/libexec/openssh/ssh-keysign
-r-x--s--x 1 root utmp      11K Jun 10  2014 /usr/libexec/utempter/utempter
-rwxr-sr-x 1 root root      11K Nov 17  2020 /usr/sbin/netreport
-rwxr-sr-x 1 root postdrop 214K Apr  1  2020 /usr/sbin/postdrop
-rwxr-sr-x 1 root postdrop 258K Apr  1  2020 /usr/sbin/postqueue

情况和 setuid 文件类似,一般是一些特殊的用户组 (slocate / nobody / tty / postdrop …) 开放给普通用户使用的命令。setgid 针对目录时含义完全不同,所以这里限定了查找类型是普通文件,关于 setgid 目录的例子,留到后面再说明。最后顺便从上面的 ls 输出看一下各列含义:

  • 第一列主要是 perm,其中也附带显示一些其它信息:
    • 第一个字母是类型,- 表示普通文件;
    • 之后分别是三组权限位,特殊标志也显示在这里
  • 第二列为硬链接数;
  • 第三列为 ouid;
  • 第四列为 ogid;
  • 第五列为 size,对于目录只表示目录文件本身占用的空间,不代表目录内文件占用总空间,想要显示目录占用总空间,需要使用 du 命令;
  • 第六列为最后修改日期;
  • 第七列为文件名。

使用 -h 选项使用 human readable 方式显示文件大小——添加合适的单位 (K/M/G) 来让人更易读,否则直接显示字节数;使用 -d 选项来打印目录文件本身而不是列出目录下的文件;ls 选项非常多,有兴趣的同学可以自行 man 页查看。

case:file_group_unchanged.sh

这个用例用来验证文件的 ogid 在创建时确定,不随用户所属用户组的变更而变更。它由两部分脚本组成,第一部分脚本中用户将基于现在的组创建文件,在用户切换所属组后,第二部分脚本中将基于新切换的组再创建文件,并分别列出两个文件的详情,通过观察它们的 ogid 来证明原文件的组不变。先来看第一部分脚本:

代码语言:javascript复制
1 #! /bin/sh
2 echo "switch to user $(whoami)"
3 # ensure new user can create file
4 cd /tmp
5 
6 touch this_is_a_test_file
7 ls -lh this_is_a_test_file

再来看第二部分脚本:

代码语言:javascript复制
 1 #! /bin/sh
 2 echo "switch to user $(whoami)"
 3 # ensure new user can create file
 4 cd /tmp
 5 
 6 groups $(whoami) 
 7 echo "show user and their group ok"
 8 
 9 touch this_is_a_demo_file
10 ls -lh this_is_a*
11 
12 rm this_is_a*
13 echo "remove testing file ok"

最后来看框架脚本中添加的部分:

代码语言:javascript复制
1     # case: file group unchanged
2     cp ./file_group_unchanged_1.sh /tmp/
3     cp ./file_group_unchanged_2.sh /tmp/
4     su - lippman -s /tmp/file_group_unchanged_1.sh
5     # change current owner's group
6     usermod -g share lippman
7     su - lippman -s /tmp/file_group_unchanged_2.sh
8     # change group back, otherwise we will got error on delete group
9     usermod -g lippman lippman

其中:

  • cp 命令将脚本放置在所有用户可访问的目录 (默认位置为用户私有工作目录,其它用户一般不能访问),以便下一步做测试;
  • su 命令将以新用户的身份运行脚本,这里使用了 lippman 用户,当然也可以选用其它任何用户,usermod -g 在 root 权限下执行时,可将任意用户的 initgrp 设置为任意已存在的用户组。
  • 夹在两部分之间的脚本 (line 6) 用于切换用户所属的组。

这个用例需要使用两个分开脚本的原因可以罗列如下:

  • 在脚本中调用 usermod 总是报错,提示没有权限 (即使只是将 initgrp 修改为 supgrp 中的一个也是如此);
  • 如果使用 sudo usermod,则需要将 lippman 加入 sudoer 文件才能起作用,但是那样就感觉测试用例的可移植性差一些了;
  • usermod 命令修改用户组之后,用户需要重新登录才能生效,这里每次 su 就相当于一次用户登录。

上面脚本的运行结果如下:

代码语言:javascript复制
$ sudo ./user_init.sh
create group ok
create user ok
lippman : lippman men share
steven : share
caveman : men
paperman : men
show user and their group ok
switch to user lippman
-rw-r--r-- 1 lippman lippman 0 May 30 21:13 this_is_a_test_file
switch to user lippman
lippman : share men
show user and their group ok
-rw-r--r-- 1 lippman lippman 0 May 30 21:13 this_is_a_test_file
-rw-r--r-- 1 lippman share   0 May 30 21:13 this_is_a_demo_file
remove testing file ok
delete user ok
remve user home dir ok
delete group ok

重点观察新旧文件的 ogid 项,发现用户切换组后,原文件的 ogid 不受影响,和预期的一致。

最后需要补充的一点是,su -s 选项用来基于新用户身份执行一段脚本,而不能直接输入 su username,否则会在脚本中执行过程中弹出交互式子 shell 从而导致执行被中断。

进程的用户与组

进程有比较多的用户和组属性:

  • 实际用户 ID
  • 实际组 ID
  • 有效用户 ID
  • 有效组 ID
  • 附加组 ID
  • 保存的设置用户 ID
  • 保存的设置组 ID

让我从进程的创建开始一一梳理:用户的初始进程是由登录 (login) 程序启动的,它读取 passwd 配置文件中该用户对应的 uid 和 gid,作为用户根进程的实际用户 ID 和实际组 ID,用于标识进程是谁,一般在整个登录会话过程中不会改变,当然超级用户可以改变它们,这个话题超出了文章的范围,放在以后说明。进程的有效用户 ID 和有效组 ID 默认情况下与实际用户 ID 和实际组 ID 一致,只有当出现以下情况时它们才不一致:  

  • 新进程的可执行文件有 setuid 标志且 ouid 与当前用户不同;
  • 新进程的可执行文件有 setgid 标志且 ogid 与当前用户不同。

场景一,进程的有效用户 ID 被设置为可执行文件的 ouid;场景二,进程的有效组 ID 被设置为可执行文件的 ogid;两个标志可以同时存在,亦可以同时生效 (网上有说法只有一个能生效是不对的,请看本节末尾的验证用例)。

有效用户 ID 与有效组 ID 是进程访问文件时内核权限检查的主要依据,具体的检查过程请参考“进程访问文件时内核权限检查过程”这节。

进程的附加组 ID 即启动进程用户的附加用户组 (supgrp),这个作为有效组 ID 的补充手段用于权限校验,附加组 ID 中每个组都与有效组 ID 的作用等价 (即只要有一个附加用户组匹配了文件 ogid,那么对应的权限就会生效)。没有"设置附加组 ID" 这类的东西,所以附加组都是“原汁原味”不会改变的,这一点请看本节最后的验证用例。

以我们耳熟能详的 access 函数为例,它使用的是实际用户 ID 与实际组 ID 进行访问权限检查,而不是有效用户 ID 和有效组 ID,也就是说 access 返回失败的文件,进程并不一定就不能访问,这一点需要注意 (虽然没什么用,因为你也不能确定它可以访问)。书上有一个很好的例子,本节就不再画蛇添足了,在“进程访问文件时内核权限检查过程”这节中你可以看到一个 shell 版本的 demo,演示了相同的功能。

为了简化后面的描述,将使用以下术语表示上面的概念:

  • 进程实际用户 ID:ruid (real uid)
  • 进程实际组 ID:rgid (real gid)
  • 进程有效用户 ID:euid (effective uid)
  • 进程有效组 ID:egid (effective gid)
  • 进程附加组 ID:supgid (supplementary gid)
  • 进程保存的设置用户 ID:save setuid
  • 进程保存的设置组 ID:save setgid

与进程 ID 相关 api 罗列如下:

  • ruid:getuid / setuid / setreuid / getresuid / setresuid
  • rgid:getgid / setgid / setregid / getresuid / setresuid
  • euid:geteuid / seteuid / setreuid / getresgid / setresgid
  • egid:getegid / setegid / setregid / getresgid / setresgid
  • supgid:getgroups / setgroups
  • save setuid:getresuid / setresuid
  • save setgid:getresgid / setresgid

set 部分一般需要严格的权限检查,留在以后介绍进程关系时说明。save setuid / save setgid 和进程运行过程中执行 exec 相关,也不在这里展开。先来看 get 部分,有些 api 可以一次性获取多个 id,所以在一个 ID 后面会跟多种获取途径。一般通过 ps 命令来显示进程的各种 ID:

代码语言:javascript复制
$ ps -axo pid,ruid,rgid,euid,egid,suid,sgid,supgid,cmd
  PID  RUID  RGID  EUID  EGID  SUID  SGID SUPGID               CMD
 1031    81    81    81    81    81    81 -                    /usr/bin/dbus-daemon --system --address=systemd: --nofork --nopidfile --systemd-activation
 1037   998   997   998   997   998   997 997                  /usr/bin/lsmd -d
 5971   999   998   999   998   999   998 998                  /usr/lib/polkit-1/polkitd --no-debug
12357  1002  1003  1002  1003  1002  1003 1003                 sshd: yunh@pts/0
12465  1002  1003  1002  1003  1002  1003 1003                 /bin/bash -l
14457    89    89    89    89    89    89 12,89                pickup -l -t unix -u
28301    89    89    89    89    89    89 12,89                qmgr -l -t unix -u
28632     0     0     0     0     0     0 -                    /usr/sbin/rsyslogd -n
28675     0     0     0     0     0     0 -                    /usr/sbin/crond -n
28720   997   995   997   995   997   995 -                    /usr/sbin/chronyd
28839    32    32    32    32    32    32 -                    /sbin/rpcbind -w

上面我们讲的各种 ID 和 ps 命令的 format 参数 (-o) 及标题之间关系如下:

  • ruid:-o ruid / RUID
  • rgid:-o rgid / RGID
  • euid:-o euid / EUID
  • egid:-o egid / EGID
  • supgid:-o supgid / SUPGID
  • save setuid:-o suid / SUID
  • save setgid:-o sgid / SGID

ps 还可以展示许多其它的 ID,和本文关系不大,就不一一罗列了。

case:setuid_setgid_order.sh

这个用例用于验证 setuid 和 setgid 可以同时作用于一个可执行文件,并且最终影响启动的进程。这个例子由三段脚本组成,需要在框架脚本中添加如下代码:

代码语言:javascript复制
1     # case: setuid setgid order
2     cp ./setugid /tmp/
3     cp ./setuid_setgid_order_1.sh /tmp/
4     cp ./setuid_setgid_order_2.sh /tmp/
5     cp ./setuid_setgid_order_3.sh /tmp/
6     su - lippman -s /tmp/setuid_setgid_order_1.sh
7     su - caveman -s /tmp/setuid_setgid_order_2.sh
8     su - lippman -s /tmp/setuid_setgid_order_3.sh

其中 setugid 是一个可执行文件,启动后 sleep 10 秒然后退出,主要是用来验证启动进程的一些属性,比较简单就不放源码了; line 3-5 将三段脚本复制到公共目录,原因同上;line 6-8 分别启动三个用户去执行脚本。第一个脚本用来准备 setuid / setgid  程序:

代码语言:javascript复制
 1 #! /bin/sh
 2 echo "switch to user $(whoami)"
 3 # ensure new user can create file
 4 cd /tmp
 5 
 6 # show user & group id for later use
 7 id
 8 
 9 # create setuid/setgid/setuid & setgid program
10 cp setugid setuid_demo 
11 chmod u s,ugo wx /tmp/setuid_demo
12 ls -lh setuid_demo
13 
14 cp setugid setgid_demo
15 chmod g s,ugo wx setgid_demo
16 ls -lh setgid_demo
17 
18 cp setugid setuid_setgid_demo
19 chmod ug s,ugo wx setuid_setgid_demo
20 ls -lh setuid_setgid_demo
21 
22 echo "create testing setuid/setgid file ok"

就是将 setugid 这个程序复制了三份,并分别设置了它们的 setuid / setgid / setuid & setgid 标志位。注意这里使用 id 打印了当前登录用户的各种 ID 值,这个在后面会用到。第二段脚本分别启动三个进程,并打印它们的 ID 值:

代码语言:javascript复制
 1 #! /bin/sh
 2 echo "switch to user $(whoami)"
 3 # ensure new user can create file
 4 cd /tmp
 5 
 6 # show user & group id for later use
 7 id
 8 
 9 ./setuid_demo &
10 ./setgid_demo &
11 ./setuid_setgid_demo &
12 
13 echo "start setuid/setgid program ok"
14 ps -ao pid,ruid,rgid,euid,egid,suid,sgid,supgid,cmd
15 
16 echo "waiting them to exit..."
17 wait

重点就是 line 9-11 了,使用子进程的方式启动,这样可以同步打印 ps 的输出结果 (line 14),在退出这段脚本前使用 wait 等待所有子进程结束。看到这里似乎就足够了,那第三段脚本是用来做什么的呢?答案是清理刚才的可执行文件:

代码语言:javascript复制
1 #! /bin/sh
2 echo "switch to user $(whoami)"
3 # ensure new user can create file
4 cd /tmp
5 
6 rm setuid_demo
7 rm setgid_demo
8 rm setuid_setgid_demo
9 echo "remove testing file ok"

至于为什么要一个单独的脚本来清理,稍后再说,这里先上脚本的输出:

代码语言:javascript复制
$ sudo ./user_init.sh 
create group ok
create user ok
lippman : lippman men share
steven : share
caveman : men
paperman : men
show user and their group ok
switch to user lippman
uid=1003(lippman) gid=1006(lippman) groups=1006(lippman),1004(men),1005(share)
-rwsrwxrwx 1 lippman lippman 9.4K May 31 20:51 setuid_demo
-rwxrwsrwx 1 lippman lippman 9.4K May 31 20:51 setgid_demo
-rwsrwsrwx 1 lippman lippman 9.4K May 31 20:51 setuid_setgid_demo
create testing setuid/setgid file ok
switch to user caveman
uid=1005(caveman) gid=1004(men) groups=1004(men)
start setuid/setgid program ok
  PID  RUID  RGID  EUID  EGID  SUID  SGID SUPGID               CMD
 3218  1002  1003  1002  1003  1002  1003 1003                 /bin/bash -l
16113     0     0     0     0     0     0 0                    sudo ./user_init.sh
16124     0     0     0     0     0     0 0                    /bin/bash ./user_init.sh
16261     0     0     0     0     0     0 1004                 su - caveman -s /tmp/setuid_setgid_order_2.sh
16273  1005  1004  1005  1004  1005  1004 1004                 /bin/sh /tmp/setuid_setgid_order_2.sh
16275  1005  1004  1003  1004  1003  1004 1004                 ./setuid_demo
16276  1005  1004  1005  1006  1005  1006 1004                 ./setgid_demo
16277  1005  1004  1003  1006  1003  1006 1004                 ./setuid_setgid_demo
16278  1005  1004  1005  1004  1005  1004 1004                 ps -ao pid,ruid,rgid,euid,egid,suid,sgid,supg
waiting them to exit...
16275 exit
16277 exit
16276 exit
Last login: Mon May 31 20:51:09 CST 2021 on pts/0
switch to user lippman
remove testing file ok
delete user ok
remve user home dir ok
delete group ok

首先各个文件的 setuid / setgid 位被正确设置了;其次 ps 的输出可以看到,都是使用 uid / gid 的形式,这里就体现到了 id 命令的重要性,它已经提前将两个用户的 id 打印出来了,可以对号入座了:

  • setuid_demo:euid 1003 为 lippman,egid 1004 为 men;
  • setgid_demo:euid 1005 为 caveman,egid 1006 为 lippman;
  • setuid_setgid_demo:euid 1003 为 lippman,egid 1006 为 lippman;

全使用数字输出可能有点乱,推荐将一个程序的 euid / egid  与它的 ruid / rgid 对比着来看,就能看出区别来:每个标志位都能单独起作用,不存在谁生效了另外一个就不生效的问题。

需要注意的一点就是,不能使用 shell 脚本来充当 demo 进程,为 shell 脚本文件设置 setud / setgid 不会起作用,其实这个细想一下也可以想通——真正启动的进程是 sh / bash 这类实体,shell 脚本文件只是它们解释执行的数据文件。

最后来说为什么要将清理脚本单独列出来,这是因为 caveman 没有删除文件的权限,如果合并到脚本二的话,会导致删除失败,所以有必要切回到创建文件的用户再去删除文件,关于删除文件需要的权限,请参考“文件访问权限位”一节; 关于 svtx 位设置后 (/tmp 目录) 的删除文件权限,请参考“进程访问文件时内核权限检查过程”一节。

case:process_supgid_unchanged.sh

这个用例主要用来验证进程启动后 supgid 不随用户 supgrp 改变而改变。这个例子由一段脚本组成,被用户执行两次,用户在执行期间 supgid 发生了改变。需要在框架脚本中添加如下代码:

代码语言:javascript复制
1     # case: process groups unchanged
2     cp ./setugid /tmp/
3     cp ./process_supgid_unchanged.sh /tmp/
4     rm /tmp/should_wait 2>/dev/null
5     su - lippman -s /tmp/process_supgid_unchanged.sh
6     # change current owner's supplementary group
7     usermod -G lippman lippman
8     touch /tmp/should_wait
9     su - lippman -s /tmp/process_supgid_unchanged.sh

主要分为三步,先以当前附加用户组启动进程 (line 5),然后改变用户的附加进程组 (line 7),最后以新的附加用户组启动进程 (line 9)。通过对比两次启动的进程 supgid 来观察它们的差异。这里以用户身份启动一个脚本的方法与之前相同,不同的是设置了一个标志位文件 /tmp/should_wait 来标识是否需要等待启动的进程,这也是研究了很多方法之后找到的一个解决方案,之前尝试过使用环境变量、用户配置文件 (~/.bash_profile),都达不到期望的效果。下面来看测试脚本的内容:

代码语言:javascript复制
 1 #! /bin/bash
 2 echo "switch to user $(whoami)"
 3 # ensure new user can create file
 4 cd /tmp
 5 
 6 # show user & group id for later use
 7 id
 8 
 9 ./setugid &
10 echo "start program ok"
11 ps -ao pid,ruid,rgid,euid,egid,suid,sgid,supgid,cmd
12 
13 if [ -f "/tmp/should_wait" ]; then 
14     echo "waiting them to exit..."
15     wait
16 fi

主要就是启动进程 (line 9),打印进程信息 (line 11),这个进程还是复用的上个用例中的 setugid 程序,主要是利用它启动后 sleep 10 秒的时机通过 ps 来观察一些进程的属性。和之前修改用户组一样,修改了用户的附加组信息后,需要用户重新登录才能生效,所以这里需要同样的用户执行两次脚本。在第二次执行时,如果也不等待 demo 子进程结束就退出,会导致删除用户时报错:

代码语言:javascript复制
userdel: user lippman is currently used by process 4911

而第一次执行时又不能等待子进程 (需要保证旧的进程还运行时修改用户附加组信息),所以这里使用了事先配置好的标志文件 (/tmp/should_wait) 来决定是否等待。下面看下脚本的输出:

代码语言:javascript复制
$ sudo ./user_init.sh 
create group ok
create user ok
lippman : lippman men share
steven : share
caveman : men
paperman : men
show user and their group ok
switch to user lippman
uid=1003(lippman) gid=1006(lippman) groups=1006(lippman),1004(men),1005(share)
start program ok
  PID  RUID  RGID  EUID  EGID  SUID  SGID SUPGID               CMD
 3218  1002  1003  1002  1003  1002  1003 1003                 /bin/bash -l
 4677     0     0     0     0     0     0 0                    sudo ./user_init.sh
 4688     0     0     0     0     0     0 0                    /bin/bash ./user_init.sh
 4892     0     0     0     0     0     0 1004,1005,1006       su - lippman -s /tmp/process_supgid_unchanged
 4909  1003  1006  1003  1006  1003  1006 1004,1005,1006       /bin/bash /tmp/process_supgid_unchanged.sh
 4911  1003  1006  1003  1006  1003  1006 1004,1005,1006       ./setugid
 4912  1003  1006  1003  1006  1003  1006 1004,1005,1006       ps -ao pid,ruid,rgid,euid,egid,suid,sgid,supg
27258  1002  1003  1002  1003  1002  1003 1003                 /bin/bash -l
28544  1002  1003  1002  1003  1002  1003 1003                 vim user_init.sh
Last login: Tue Jun  1 11:34:10 CST 2021 on pts/1
switch to user lippman
uid=1003(lippman) gid=1006(lippman) groups=1006(lippman)
start program ok
  PID  RUID  RGID  EUID  EGID  SUID  SGID SUPGID               CMD
 3218  1002  1003  1002  1003  1002  1003 1003                 /bin/bash -l
 4677     0     0     0     0     0     0 0                    sudo ./user_init.sh
 4688     0     0     0     0     0     0 0                    /bin/bash ./user_init.sh
 4911  1003  1006  1003  1006  1003  1006 1004,1005,1006       ./setugid
 4923     0     0     0     0     0     0 1006                 su - lippman -s /tmp/process_supgid_unchanged
 4934  1003  1006  1003  1006  1003  1006 1006                 /bin/bash /tmp/process_supgid_unchanged.sh
 4936  1003  1006  1003  1006  1003  1006 1006                 ./setugid
 4937  1003  1006  1003  1006  1003  1006 1006                 ps -ao pid,ruid,rgid,euid,egid,suid,sgid,supg
27258  1002  1003  1002  1003  1002  1003 1003                 /bin/bash -l
28544  1002  1003  1002  1003  1002  1003 1003                 vim user_init.sh
waiting them to exit...
4911 exit
4936 exit
delete user ok
remve user home dir ok
delete group ok

重点看一下 setugid 进程的 SUPGID 信息,第一次启动时,lippman 拥有三个附加组:1006 / 1004 / 1005 (通过 id 命令),启动进程的 SUPGID 项和用户 supgrp 内容一致; 第二次启动前,通过 -G lippman 为用户指定了唯一的一个附加组 1006,用户的 supgrp 和新进程的 supgid 果然都变成了 1006,而旧进程的 supgid 仍保持三个不变 (新旧进程可以通过进程号区分)。

这个结论和用户 gid 与文件 ogid 的关系非常类似,都是设置后不随用户的改变而改变了。最后解释一下为什么没有“设置用户附加组 ID”这种东西,根源在于文件 inode 中没有预留空间存储 supgid,附加组都是进程启动时从 /etc/group 中获取的;另一方面,即使能存储,这个用户组权限匹配过程也会变得复杂,由 ogid -> egid supgid 一对多的关系,变为多对多的关系,这么一整就乱套了。

文件访问权限位

所有类型的文件都有访问权限位,包括目录,不过目录的权限位与普通文件的权限位意义稍有不同,下面会详细说明。权限位共有 9 个,按针对的用户范围分为三类:

  • 文件创建者
  • 文件创建者所在的用户组 (该组的所有用户,用户组为该用户的附加组也算)
  • 不在上面范围的其它用户

针对每类用户,又有三类访问权限:

  • 读 (r)
  • 写 (w)
  • 执行 (x)

它们规定了每类用户具有的权限,如果申请的权限超过了给定的权限,访问就会被拒绝。常用的一些操作及它们申请的权限罗列如下:

  • 打开文件
    • 路径中的每个目录:x
    • 路径中的每个符号链接:- (无权限要求,主要看跳转过程中所涉及到目录和文件的权限)
    • 文件本身:
      • O_RDONLY:r
      • O_WRONLY:w
      • O_RDWR:rw
      • O_TRUNC:w
  • 创建文件
    • 路径中的每个目录:x
    • 路径中的每个符号链接:- (无权限要求,主要看跳转过程中所涉及到目录和文件的权限)
    • 直属目录:wx
  • 删除和重命名文件
    • 路径中的每个目录:x
    • 路径中的每个符号链接:- (无权限要求,主要看跳转过程中所涉及到目录和文件的权限)
    • 直属目录:wx
    • 待删除和重命名文件:- (无权限要求)
  • 执行文件 (通过 exec 函数族启动进程):
    • 路径中的每个目录:x
    • 路径中的每个符号链接:- (无权限要求,主要看跳转过程中所涉及到目录和文件的权限)
    • 文件本身:x
  • 列出目录
代码语言:txt复制
- 路径中的每个目录:x
- 路径中的每个符号链接:- (无权限要求,主要看跳转过程中所涉及到目录和文件的权限)
- 目录本身:r

需要对上面的目录权限位做一些单独说明:

  • 目录执行权限位 (x) 也称为搜索位,当一个目录位于路径的一部分时,如果用户没有目录的执行位权限,则不能通过该目录找到下一级文件或目录,权限校验直接失败;
  • 对某个文件进行操作时,至少需要通过一个目录,如果是绝对路径,就是根目录;如果是相对路径,就是当前目录,没有指定 ./ 也会隐含通过当前目录;
  • 对同一个文件,使用不同的路径效果也会不同,例如 /usr/include/stdio.h,使用绝对路径是一种方式,如果当前目录位于 /usr/include/path/to/current/dir,则使用 ../../../../stdio.h 也是一样的,但是如果 path/to/current/dir 中有任意一个目录没有搜索位 (x),则文件访问就会失败;反之亦然。这里主要是想强调一下“路径中的每个目录”的重要性,例子本身举的比较牵强,毕竟那些目录没有搜索位的话,当前目录也是不可能切 (cd) 过去的;

为了简化后面的描述,将使用以下术语表示上面的概念:

  • 权限分组创建者:uperm (owner perm)
  • 权限分组创建者组:gperm (owner group perm)
  • 权限分组其它用户:operm (other perm)
  • 读权限:r (read)
  • 写权限:w (write)
  • 执行/搜索权限:x (execute)

以上权限对应到 stat 中 st_mode 字段的关系如下:

  • uperm
    • r:S_IRUSR
    • w:S_IWUSR
    • x:S_IXUSR
    • rwx:S_IRWXU
  • gperm
    • r:S_IRGRP
    • w:S_IWGRP
    • x:S_IXGRP
    • rwx:S_IRWXG
  • operm
    • r:S_IROTH
    • w:S_IWOTH
    • x:S_IXOTH
    • rwx:S_IRWXO

使用 access 进行权限访问时, mode 参数指定的标志位与权限对应关系如下:

  • R_OK:r
  • W_OK:w
  • X_OK:x
  • F_OK:- (只看文件是否存在,不检查权限位)

四个标志位是可以组合使用的。shell 也有内建命令 (或者说选项) 来检查文件的类型和访问权限,其中文件类型使用的关键字和 ls、find 相同 (-f / -d / -l ...),权限方面则和 access 类似:

  • -r: r
  • -w: w
  • -x: x
  • -e: exist (不检查类型,只检查是否存在)

下节的测试用例演示了 shell 的内建权限检查。

进程访问文件时内核权限检查过程

有了上面的基础,再谈进程访问文件时的权限检查过程就简单多了:

  • euid == root,允许访问
  • euid == ouid:
    • 申请的 perm <= uperm,允许访问
    • 否则拒绝访问
  • egid == ogid 或 supgid 包含 ogid 时:
代码语言:txt复制
- 申请的 perm <= gperm,允许访问
- 否则拒绝访问否则 (归类于 other): 申请的 perm <= operm,允许访问否则拒绝访问

这里需要注意几点:

  • 检查过程是“熔断”的,即一个进程被归类为文件的某个权限分组后,当该分组权限不满足时,即使更低级别的权限分组允许,也不再向后尝试,而是直接拒绝。举个例子,如果某个文件权限位为 --- --- rwx,则只要进程属于用户的 owner 或 owner group,那么一定不允许访问,反而是不在上面范围内的其它用户,可以获得访问权限,具体可参考本节末尾给出的测试用例;
  • 这个检查过程和上一节中提到的文件路径中每个目录需要执行权限是不矛盾的,也就是说,完整的过程是对路径中每个节点,依次执行本节说的检查过程,不过呢,对于路径中通过的目录节点,申请的 perm 固定为 x 而已。举个例子,想要读取 /usr/include/stdio.h 文件,需要分四步(每步都需要执行上面完整的过程):
    • 检查当前进程与 / 目录的 x 权限;
    • 检查当前进程与 /usr 目录的 x 权限;
    • 检查当前进程与 /usr/include 目录的 x 权限;
    • 检查当前进程与 /usr/include/stdio.h 文件的 r 权限。
  • 上一个例子扩展一下,如果是想执行 /usr/local/bin/sed 程序,目录部分完全相同,只是最后一步有一些区别:
    • 检查当前进程与 /usr/local/bin/sed 文件的 x 权限。
  • 如果想要在 /usr/local/bin 下创建新的可执行文件,则整个过程是这样的:
    • 检查当前进程与 / 目录的 x 权限;
    • 检查当前进程与 /usr 目录的 x 权限;
    • 检查当前进程与 /usr/local 目录的 x 权限;
    • 检查当前进程与 /usr/local/bin 目录的 wx 权限;  
    • 新创建文件的权限与当前进程有关,这方面内容请参考“新建文件的权限”一节。
  • 如果要删除或更名文件,大部分检查过程和创建完全一样,就像之前说过的,删除文件和文件本身权限设置其实没什么关系。

不过如果直属目录设置了 svtx 粘住位,则删除、重命名文件的权限检查过程稍有不同:

  • 有直属目录的 wx 权限,这个基本条件不变;
  • 还需要满足以下条件:
    • euid == root
    • 或 euid == 文件 ouid
    • 或 euid == 直属目录 ouid

也就是说删除、重命名 svtx 目录下的文件时,要求非超级用户进程必需拥有该文件或文件所在的直属目录,换个通俗的说法就是——你只能动你自己创建的文件,不能动其它人创建的的文件。我们熟知的 /tmp 目录允许任何人在其中创建文件,而它就设置了 svtx 位。如果你要创建多个帐户之间的共享目录 (如 /share),使用 svtx 位是一个好习惯,具体可参考本节末尾的测试用例。

使用之前介绍过的 find 命令搜索带有特殊标志位文件的办法,来查找一下测试机上的 svtx 目录,得到如下输出:

代码语言:javascript复制
$ find / -perm -o t 2>/dev/null | xargs ls -ldh
drwxrwxrwt  2 root root   60 Mar 31 10:58 /dev/mqueue
drwxrwxrwt  2 root root   80 Apr 25 10:59 /dev/shm
drwxrwxrwt  2 root root 4.0K Mar 31 10:42 /matrix/matrix-bios/run
drwxr-xr-t  3 root root 4.0K May 22 16:36 /matrix/matrix-bios/var/state
drwxrwxrwt 12 root root 4.0K May 22 16:45 /tmp
drwxrwxrwt  2 root root 4.0K Mar  4  2019 /tmp/.font-unix
drwxrwxrwt  2 root root 4.0K Mar  4  2019 /tmp/.ICE-unix
drwxrwxrwt  2 root root 4.0K Mar  4  2019 /tmp/.Test-unix
drwxrwxrwt  2 root root 4.0K Mar  4  2019 /tmp/.X11-unix
drwxrwxrwt  2 root root 4.0K Mar  4  2019 /tmp/.XIM-unix
drwxrwxrwt  2 root root 4.0K Oct 31  2018 /var/cache/coolkey
drwxrwxrwt  6 root root 4.0K May 22 13:00 /var/tmp

可以看到临时目录赫然在列。

case:perm_group_fuse.sh

这个脚本单纯验证以下权限组熔断规则:uperm > gperm > operm,当访问被归类到某一级别的权限组后,就不再向低级别权限组探查。先来看探查权限的脚本:

代码语言:javascript复制
 1 #! /bin/sh
 2 if [ $# -lt 1 ]; then 
 3     echo "Usage: probe_file_perm.sh file_to_test"
 4     exit 1
 5 fi
 6 
 7 filename=$1
 8 if [ -e "$filename" ]; then 
 9     echo "exist"
10 else
11     echo "not exist"
12     exit 1
13 fi
14 
15 if [ -r "$filename" ]; then 
16     echo "can read"
17 else
18     echo "can NOT read"
19 fi
20 
21 if [ -w "$filename" ]; then 
22     echo "can write"
23 else
24     echo "can NOT write"
25 fi
26 
27 if [ -x "$filename" ]; then 
28     echo "can execute"
29 else
30     echo "can NOT exeucte"
31 fi
32 
33 exit 0

其实就是 shell 版的 access,通过 strace 来观察这段脚本的运行,发现其底层调用的 api 和 access 是一致的,所以这里所有的结论也适用于 access。下面来看调用点:

代码语言:javascript复制
 1 #! /bin/sh
 2 echo "switch to user $(whoami)"
 3 # ensure new user can create file
 4 cd /tmp
 5 
 6 echo "checking this_is_a_test_file"
 7 ./probe_file_perm.sh "this_is_a_test_file"
 8 echo ""
 9 
10 echo "checking this_is_a_demo_file"
11 ./probe_file_perm.sh "this_is_a_demo_file"
12 echo ""

对两个 demo 文件分别进行权限测试,这两个文件的创建请看这段脚本:

代码语言:javascript复制
 1 #! /bin/sh
 2 echo "switch to user $(whoami)"
 3 # ensure new user can create file
 4 cd /tmp
 5 
 6 if [ ! -e this_is_a_test_file ]; then 
 7     touch this_is_a_test_file
 8     # '--- r-x -w-'
 9     chmod 0052 this_is_a_test_file
10     ls -lh this_is_a_test_file
11 fi
12 
13 if [ ! -e this_is_a_demo_file ]; then 
14     touch this_is_a_demo_file
15     # '--- r-x -w-'
16     chmod 0052 this_is_a_demo_file
17     ls -lh this_is_a_demo_file
18 fi
19 
20 echo "prepare testing file ok"

当文件不存在时,创建对应的文件并设置权限。注意这里权限比较特殊,0052 对应的权限位是 "--- r-x -w-",即 uperm 没有任何权限、gperm 可读可执行、operm 可写,奇葩是够奇葩的,不过这样能很容易的根据最终访问权限来确定命中了哪组权限位。最后上框架脚本中的驱动代码:

代码语言:javascript复制
 1     # case: permission group fusing
 2     cp ./probe_file_perm.sh /tmp/
 3     cp ./perm_group_fuse_1.sh /tmp/
 4     cp ./perm_group_fuse_2.sh /tmp/
 5     rm /tmp/this_is_a_test_file 2>/dev/null
 6     su - steven -s /tmp/perm_group_fuse_1.sh
 7     rm /tmp/this_is_a_demo_file 2>/dev/null
 8     su - caveman -s /tmp/perm_group_fuse_1.sh
 9     # start access test
10     su - steven -s /tmp/perm_group_fuse_2.sh
11     su - caveman -s /tmp/perm_group_fuse_2.sh
12     su - paperman -s /tmp/perm_group_fuse_2.sh
13     su - lippman -s /tmp/perm_group_fuse_2.sh

下面做个简单说明:

  • line 2-4:准备测试脚本;
  • line 5-8:生成测试文件,注意这里为了让两个不同用户使用同样的脚本生成不同的文件,通过文件是否存在来控制文件的生成,最终 steven 用户生成的是 this_is_a_test_file; caveman 用户生成的是 this_is_a_demo_file;
  • line 10-13:让各种用户去测试这两个文件的访问权限。

ok,我们知道框架脚本中对用户组的设定是这样的:

  • men:caveman / paperman / lippman
  • share:steven / lippman
  • lippman:lippman

所以很容易就可以弄明白以下关系:

  • line 10:通过 steven 测试 test 文件的 uperm 与 demo 文件的 operm;
  • line 11:通过 caveman 测试 test 文件的 operm 与 demo 文件的 uperm;
  • line 12:通过 paperman 测试 test 文件的 operm 与 demo 文件的 gperm;
  • line 13:通过 lippman 测试 test 文件的 gperm 与 demo 文件的 gperm;

所有权限组基本上是都覆盖到了,由于文件权限位的独特性,可以得到 uperm 无权限;gperm 有读和执行权限;operm 有写权限,下面通过输出来验证一下:

代码语言:javascript复制
$ sudo ./user_init.sh 
create group ok
create user ok
lippman : lippman men share
steven : share
caveman : men
paperman : men
show user and their group ok
switch to user steven
----r-x-w- 1 steven share 0 Jun  1 19:58 this_is_a_test_file
prepare testing file ok
switch to user caveman
----r-x-w- 1 caveman men 0 Jun  1 19:58 this_is_a_demo_file
prepare testing file ok
Last login: Tue Jun  1 19:58:01 CST 2021 on pts/1
switch to user steven
checking this_is_a_test_file
exist
can NOT read
can NOT write
can NOT exeucte

checking this_is_a_demo_file
exist
can NOT read
can write
can NOT exeucte

Last login: Tue Jun  1 19:58:01 CST 2021 on pts/1
switch to user caveman
checking this_is_a_test_file
exist
can NOT read
can write
can NOT exeucte

checking this_is_a_demo_file
exist
can NOT read
can NOT write
can NOT exeucte

switch to user paperman
checking this_is_a_test_file
exist
can NOT read
can write
can NOT exeucte

checking this_is_a_demo_file
exist
can read
can NOT write
can execute

switch to user lippman
checking this_is_a_test_file
exist
can read
can NOT write
can execute

checking this_is_a_demo_file
exist
can read
can NOT write
can execute

delete user ok
remve user home dir ok
delete group ok

上面的数据可以整理成表格:

this_is_a_test_file (steven)

this_is_a_demo_file (caveman)

steven

--- (uperm)

-w- (operm)

caveman

-w- (operm)

--- (uperm)

paperman

-w- (operm)

r-x (gperm)

lippman

r-x (gperm)

r-x (gperm)

表格中每行表示一个用户,每列表示一个文件,行列交叉处表示进程对文件的访问权限。可见,每个进程的最终权限与之前清单中列出的预期是一致的,说明确实是熔断了。例如以 steven 用户进程访问 test 文件为例,如果没有发生熔断,当 uperm 判定无权限后,那是不是应该退而求其次使用 gperm 判断了?如此一来最终的访问权限就变成了 r-x 而不是 ---。

 case:share_with_svtx.sh

这个测试用例用来验证 svtx 目录中,用户不能删除、重命名不属于自己的文件,除非用户拥有文件或直属目录。这个用例分为两段脚本,第一段用来创建一些测试目录和文件,第二段用来进行测试。首先看第一段脚本:

代码语言:javascript复制
 1 #! /bin/sh
 2 echo "switch to user $(whoami)"
 3 # ensure new user can create file
 4 cd /tmp
 5 
 6 if [ ! -d /tmp/share ]; then 
 7     mkdir /tmp/share
 8     # allow every to create file 
 9     chmod ugo rwx,o t /tmp/share
10     ls -lhd /tmp/share
11 fi
12 
13 touch "/tmp/share/$(whoami)"
14 # 'rwx rw- r--'
15 chmod 0731 "/tmp/share/$(whoami)"
16 ls -lh "/tmp/share/$(whoami)"
17 
18 echo "prepare testing file ok"

这个脚本会被每个用户执行一遍,因此在创建共享目录 /tmp/share 之前,需要做个检测 (line 6)。为了保证每个用户都可以在共享目录下创建文件,目录的权限被设置为了 'rwx rwx rwt',其中 t 是为了验证 svtx 位作用于目录的效果 (line 9)。之后每个用户在共享目录下创建以自己命名的文件,文件权限设置为 'rwx rw- r--',以区分不同的权限分组 (line 14-16)。下面看下第二段脚本:

代码语言:javascript复制
 1 #! /bin/sh
 2 echo "switch to user $(whoami)"
 3 # ensure new user can create file
 4 cd /tmp
 5 
 6 # $1: file to move
 7 try_move_file ()
 8 {
 9     local file="$1"
10     local file_new="$1.new"
11     mv "$file" "$file_new" 2>/dev/null
12     if [ $? -eq 0 ]; then 
13         echo "can move"
14         # move back
15         mv "$file_new" "$file"
16     else
17         echo "can NOT move"
18     fi
19 }
20 
21 echo "checking /tmp/share/steven"
22 ./probe_file_perm.sh "/tmp/share/steven"
23 try_move_file "/tmp/share/steven"
24 echo ""
25 
26 echo "checking /tmp/share/caveman"
27 ./probe_file_perm.sh "/tmp/share/caveman"
28 try_move_file "/tmp/share/caveman"
29 echo ""
30 
31 echo "checking /tmp/share/paperman"
32 ./probe_file_perm.sh "/tmp/share/paperman"
33 try_move_file "/tmp/share/paperman"
34 echo ""
35 
36 echo "checking /tmp/share/lippman"
37 ./probe_file_perm.sh "/tmp/share/lippman"
38 try_move_file "/tmp/share/lippman"
39 echo ""

这个脚本也会被每个用户分别执行,它对每个用户创建在共享目录下的文件挨个进行访问权限检查 (line 21-39),权限检查是复用之前的 probe_file_perm.sh 脚本进行的;重命名检查是通过 shell 函数 try_move_file 进行的;这里没有对删除进行测试,主要是一旦删除成功,还需要重新创建文件以便下个用户检测,比较费事。重命名成功后,也需要将重命名后的文件再重命名回来,防止下个用户找不到要检测的文件,这个体现在 try_move_file 中了 (line 15)。最后将它们集成在框架脚本中:

代码语言:javascript复制
 1     # case: share with svtx
 2     cp ./probe_file_perm.sh /tmp/
 3     cp ./share_with_svtx_1.sh /tmp/
 4     cp ./share_with_svtx_2.sh /tmp/
 5     su - steven -s /tmp/share_with_svtx_1.sh
 6     su - caveman -s /tmp/share_with_svtx_1.sh
 7     su - lippman -s /tmp/share_with_svtx_1.sh
 8     su - paperman -s /tmp/share_with_svtx_1.sh
 9     # start access test
10     su - paperman -s /tmp/share_with_svtx_2.sh
11     su - lippman -s /tmp/share_with_svtx_2.sh
12     su - caveman -s /tmp/share_with_svtx_2.sh
13     su - steven -s /tmp/share_with_svtx_2.sh
14     rm -rf /tmp/share

总体分为三步:复制脚本文件 (line 2-4);准备测试文件 (line 5-8);进行测试 (line 10-13);最后通过删除共享目录来清理测试文件。ok,看下脚本运行效果:

代码语言:javascript复制
$ sudo ./user_init.sh 
create group ok
create user ok
lippman : lippman men share
steven : share
caveman : men
paperman : men
show user and their group ok
switch to user steven
drwxrwxrwt 2 steven share 4.0K Jun  2 15:00 /tmp/share
-rwx-wx--x 1 steven share 0 Jun  2 15:00 /tmp/share/steven
prepare testing file ok
switch to user caveman
-rwx-wx--x 1 caveman men 0 Jun  2 15:00 /tmp/share/caveman
prepare testing file ok
switch to user lippman
-rwx-wx--x 1 lippman lippman 0 Jun  2 15:00 /tmp/share/lippman
prepare testing file ok
switch to user paperman
-rwx-wx--x 1 paperman men 0 Jun  2 15:00 /tmp/share/paperman
prepare testing file ok
Last login: Wed Jun  2 15:00:45 CST 2021 on pts/0
switch to user paperman
checking /tmp/share/steven
exist
can NOT read
can NOT write
can execute
can NOT move

checking /tmp/share/caveman
exist
can NOT read
can write
can execute
can NOT move

checking /tmp/share/lippman
exist
can NOT read
can NOT write
can execute
can NOT move

checking /tmp/share/paperman
exist
can read
can write
can execute
can move

Last login: Wed Jun  2 15:00:45 CST 2021 on pts/0
switch to user lippman
checking /tmp/share/steven
exist
can NOT read
can write
can execute
can NOT move

checking /tmp/share/caveman
exist
can NOT read
can write
can execute
can NOT move

checking /tmp/share/lippman
exist
can read
can write
can execute
can move

checking /tmp/share/paperman
exist
can NOT read
can write
can execute
can NOT move

Last login: Wed Jun  2 15:00:45 CST 2021 on pts/0
switch to user caveman
checking /tmp/share/steven
exist
can NOT read
can NOT write
can execute
can NOT move

checking /tmp/share/caveman
exist
can read
can write
can execute
can move

checking /tmp/share/lippman
exist
can NOT read
can NOT write
can execute
can NOT move

checking /tmp/share/paperman
exist
can NOT read
can write
can execute
can NOT move

Last login: Wed Jun  2 15:00:45 CST 2021 on pts/0
switch to user steven
checking /tmp/share/steven
exist
can read
can write
can execute
can move

checking /tmp/share/caveman
exist
can NOT read
can NOT write
can execute
can move

checking /tmp/share/lippman
exist
can NOT read
can NOT write
can execute
can move

checking /tmp/share/paperman
exist
can NOT read
can NOT write
can execute
can move

delete user ok
remve user home dir ok
delete group ok

创建的测试文件权限符合预期,第一个创建测试文件的用户为 steven,它顺便创建了共享目录,因而目录所有者就是 steven,而其它用户只是自己文件的拥有者,注意这点区分,会对接下来的分析产生影响。进行文件权限检查的输出比较多,这里通过表格来展示:

steven

caveman

lippman

paperman

paperman

--x (operm)

-wx (gperm)

--x (operm)

rwx (uperm)

lippman

-wx (gperm)

-wx (gperm)

rwx (uperm)

-wx (gperm)

caveman

--x (operm)

rwx (uperm)

--x (operm)

-wx (gperm)

steven

rwx (uperm)

--x (operm)

--x (operm)

--x (operm)

这个表和之前用例中的表大同小异——每列表示一个文件,每行表示一个用户,行列交叉处表示用户对文件的访问权限。由于文件名直观的显示了是由哪个用户创建的,所以列标题得以简化。与之前用例同理,根据最终访问权限可以倒推出命中的是什么权限分组,而这也正好映射了两个用户之间的关系,即用户访问自己的文件,是 uperm;访问同组用户的文件,是 gperm;否则是 operm。

上表中得到的结论和实际一致吗?大体是差不多的,但是可能有一些地方需要推敲一下,例如为何 paperman / caveman 访问 lippman 的文件是 operm,而 lippman 访问 paperman / caveman 的文件却是 gperm,难道不应该都是 gperm 吗?毕竟它们都属于 men 组呀。这里再看一下它们创建的文件详情:

代码语言:javascript复制
-rwx-wx--x 1 lippman lippman 0 Jun  2 14:34 /tmp/share/lippman
-rwx-wx--x 1 caveman men 0 Jun  2 14:34 /tmp/share/caveman
-rwx-wx--x 1 paperman men 0 Jun  2 14:34 /tmp/share/paperman

不看不知道,一看吓一跳,lippman 文件的 ogid 是 lippman;caveman / paperman 文件的 ogid 是 men。于是 lippman 用户访问 caveman 和 paperman 文件时,通过 supgid 中的 men 进行了匹配,所以是 gperm 权限组;反之,caveman / paperman 用户访问 lippman 文件时,没有用户组可以匹配 lippman 用户组,于是只能命中 operm,这是导致访问权限差异的根源。通过之前的章节我们知道,gid 是可以传递给文件并记录下来的 (ogid),而 supgrp 是无法传递并记录在文件中的 (只能记录在进程的 supgid),所以,虽然用户都在一个组,但是它们产生的文件可能并不在一个组。同理,lippman 访问 steven 文件和 steven 访问 lippman 文件的差异,也是由此而来。

为了清晰起见,下面单独列出用户是否可以重命名文件:

steven

caveman

lippman

paperman

paperman

no

no

no

yes

lippman

no

no

yes

no

caveman

no

yes

no

no

steven

yes

yes

yes

yes

只看前三行的话,是比较明确的——用户只能重命名自己的文件,同组的用户也不能相互重命名。再看第四行用户 steven,它可以对所有人的文件进行重命名,这是为什么呢?如果大家还记得 steven 是目录的创建者的话,就不会觉得惊讶了,这一条直接让它符合了这条权限检查规则:euid == 直属目录 ouid。另外可以在框架脚本中一行  rm -rf /tmp/share 删掉所有文件包括共享目录本身,也和另一条规则相关:euid == root。无意间将所有规则都演示了一遍,妙哇~

新建文件的权限

文件不会自己产生,它们都是由进程创建的。不论是通过命令还是 api,新建文件的权限与创建进程的属性密切相关,具体影响规则如下:

  • ouid
代码语言:txt复制
- <= euidogid mac / freebsd <= 直属目录 ogidSolaris: 直属目录有 setgid 标志位 <= 直属目录 ogid否则 <= egidlinux: 文件系统 mount 时指定了 grpid 或 bsdgroups 参数: 直属目录有 setgid 标志位 <= 直属目录 ogid直属目录没有 setgid 标志位 <= egid否则 (未指定或明确指定了 nogrpid 或 sysvgroups 参数) <= egidperm
代码语言:txt复制
- open 或 creat 时指定的 mode 参数
- umask 指定的屏蔽字 (在最终的 perm 中关闭对应的位)

对于新文件的所有权,ouid 是比较明确的,就是继承创建进程的 euid;ogid 稍微复杂一些,不过总的来说就两个途径:要么继承进程的 egid、要么继承直属目录的 ogid,具体需要按系统分情况来看。当然了,以上内容都源自 apue,作者成书较早,当时的系统版本都比较老,例如针对的 linux 平台还是 2.4 的内核,我用 CentOS 7.5 (内核 3.10) 验证的时候发现有些出入——文件系统挂载 (mount) 时并未使用 grpid 或 bsdgroups 参数,但是也遵循上面的规则。即:

  • 直属目录有 setgid:ogid <= 直属目录 ogid
  • 否则:ogid <= 创建进程 egid

本节末尾的测试用例验证了这一点,其它平台限于本文讨论的范围没做验证。可以把目录的 setgid 理解成是强制继承目录的组权限,从而保证以该目录为根节点的路径树对一组用户有一致的访问权限 (通过 ogid),而不会出现这样的情况——虽然某个用户进程属于目录 ogid 所在的组,但它是通过 supgid 加入此组的,而它自己的 egid 却不在这个组,这样它虽然可以在这个目录下创建文件,但这个组的其它用户却不能访问这些文件,从而导致这个目录变得“支离破碎”。关于同组用户却不能创建同组文件的实例,请参考 “share_with_svtx” 用例。下面使用第一节介绍的 find 参数来查找一下测试机上的 setgid 目录:

代码语言:javascript复制
# find / -type d -perm -g s 2>/dev/null | xargs ls -lhd
drwxr-sr-x  5 root systemd-journal 100 May 23 12:57 /run/log/journal
drwxr-sr-x  2 root systemd-journal  40 May 23 12:56 /run/log/journal/def
drwxr-s---  2 root systemd-journal 120 May 31 19:05 /run/log/journal/efe3d136ddc241e382a960b78ccc4718

得到的结果非常少,改变一下 ls 的选项,让它递归列出目录内容,看看这里面都有些什么:

代码语言:javascript复制
# find / -type d -perm -g s 2>/dev/null | xargs ls -lhR
/run/log/journal:
total 0
drwxr-x---  2 root root             60 Mar 31 10:41 86bac26592284276a583f8df03ff9a47
drwxr-sr-x  2 root systemd-journal  40 May 23 12:56 def
drwxr-s---  2 root systemd-journal 120 May 31 19:05 efe3d136ddc241e382a960b78ccc4718

/run/log/journal/86bac26592284276a583f8df03ff9a47:
total 8.0M
-rw-r----- 1 root root 8.0M Mar 31 10:41 system.journal

/run/log/journal/def:
total 0

/run/log/journal/efe3d136ddc241e382a960b78ccc4718:
total 168M
-rwxr-x---  1 root systemd-journal 56M Apr 16 14:40 system@6678dc3c22194c5190a67760a8fcc447-0000000000000001-0005becc0ba81976.journal
-rw-r-----  1 root systemd-journal 48M May  9 09:25 system@6678dc3c22194c5190a67760a8fcc447-000000000000d1f4-0005c0113fd51d85.journal
-rw-r-----  1 root systemd-journal 48M May 31 19:02 system@6678dc3c22194c5190a67760a8fcc447-0000000000017e6d-0005c1db87b877ab.journal
-rw-r-----  1 root systemd-journal 16M Jun  3 18:09 system.journal

/run/log/journal/def:
total 0

/run/log/journal/efe3d136ddc241e382a960b78ccc4718:
total 168M
-rwxr-x---  1 root systemd-journal 56M Apr 16 14:40 system@6678dc3c22194c5190a67760a8fcc447-0000000000000001-0005becc0ba81976.journal
-rw-r-----  1 root systemd-journal 48M May  9 09:25 system@6678dc3c22194c5190a67760a8fcc447-000000000000d1f4-0005c0113fd51d85.journal
-rw-r-----  1 root systemd-journal 48M May 31 19:02 system@6678dc3c22194c5190a67760a8fcc447-0000000000017e6d-0005c1db87b877ab.journal
-rw-r-----  1 root systemd-journal 16M Jun  3 18:09 system.journal

看起来绝大部分文件的确继承了根目录的 ogid,如果一个用户只是通过 supgid 加入了 systemd-journal 组,那么它创建的 journal 文件也将可以被同组的其它用户访问到。

关于 setgid 最后再补充一点,新建文件类型为目录时,遵循完全相同的规则,与普通文件唯一的不同是,当组 ID 继承直属目录的 ogid 时,同时也会继承它的 setgid 标志位,具体细节请参考节末用例。

最后再来说明一下 umask,在终端有一个同名的命令,可以打印当前 umask 值:

代码语言:javascript复制
$ umask
0002

也可以用更直观的方式打印:

代码语言:javascript复制
$ umask -S
u=rwx,g=rwx,o=rx

这种情况下显示的是最终保留的位。子进程一般会继承父进程的 umask 值,不过子进程修改 umask 值不会影响父进程。

case:setgid_parent_dir.sh

这个用例用来验证 setgid 作用于目录时,目录下的文件 ogid 将继承目录的 ogid 而不是创建者的 egid。这个用例只有一段脚本:

代码语言:javascript复制
 1 #! /bin/sh
 2 echo "switch to user $(whoami)"
 3 # ensure new user can create file
 4 cd /tmp
 5 
 6 if [ ! -d /tmp/share ]; then 
 7     mkdir /tmp/share
 8     # allow everyone to create file 
 9     chmod ugo rwx,g s /tmp/share
10     ls -lhd /tmp/share
11 fi
12 
13 mkdir "/tmp/share/$(whoami)_dir"
14 touch "/tmp/share/$(whoami)_file"
15 ls -lhd /tmp/share/$(whoami)_*
16 
17 echo "prepare testing file ok"

这段脚本将以用户身份运行,第一个进入的用户负责创建 setgid 共享目录 (line 6-11),目录的 ogid 将追随用户的 egid;然后每个用户在这个目录下面创建一个目录、一个普通文件,命名规则是“用户名_file | dir”,然后列出它们以验证 ogid 继承了父目录。注意 ls 的参数 (line 15) 不能用引号包围,否则会报找不到文件错误,原因是 shell 通配符只在不被引号包围的情况下才能生效。在框架脚本中加入启动代码:

代码语言:javascript复制
1     # case: setgid parent dir
2     cp ./setgid_parent_dir.sh /tmp/
3     su - lippman -s /tmp/setgid_parent_dir.sh
4     su - caveman -s /tmp/setgid_parent_dir.sh
5     su - paperman -s /tmp/setgid_parent_dir.sh
6     su - steven -s /tmp/setgid_parent_dir.sh
7     #ls -lh "/tmp/share/"
8     rm -rf /tmp/share

挨个用户执行该脚本。最终输出如下:

代码语言:javascript复制
$ sudo ./user_init.sh 
create group ok
create user ok
lippman : lippman men share
steven : share
caveman : men
paperman : men
show user and their group ok
switch to user lippman
drwxrwsrwx 2 lippman lippman 4.0K Jun  3 21:13 /tmp/share
drwxr-sr-x 2 lippman lippman 4.0K Jun  3 21:13 /tmp/share/lippman_dir
-rw-r--r-- 1 lippman lippman 0 Jun  3 21:13 /tmp/share/lippman_file
prepare testing file ok
switch to user caveman
drwxr-sr-x 2 caveman lippman 4.0K Jun  3 21:13 /tmp/share/caveman_dir
-rw-r--r-- 1 caveman lippman 0 Jun  3 21:13 /tmp/share/caveman_file
prepare testing file ok
switch to user paperman
drwxr-sr-x 2 paperman lippman 4.0K Jun  3 21:13 /tmp/share/paperman_dir
-rw-r--r-- 1 paperman lippman 0 Jun  3 21:13 /tmp/share/paperman_file
prepare testing file ok
switch to user steven
drwxr-sr-x 2 steven lippman 4.0K Jun  3 21:13 /tmp/share/steven_dir
-rw-r--r-- 1 steven lippman 0 Jun  3 21:13 /tmp/share/steven_file
prepare testing file ok
delete user ok
remve user home dir ok
delete group ok

可以看到以下现象:

  • 共享目录确实设置了 setgid 位 (rws) 且其 ogid 为创建者 egid (lippman);
  • 每个新创建的文件,不论普通文件还是目录,ogid 继承了共享目录的 ogid (lippman) 而非创建用户的 egid (men / share);
  • 每个新目录也自动继承了直属目录的 setgid 位 (r-s)。

一个用例验证了两个结论。现在回过头来看目录 setgid 的作用,它保证了这个目录下的所有文件 (一般情况而言) 都有一致的 ogid,那这个有什么用处呢,下面分几个方面来考察一下。

所有能在这个目录下创建文件的用户都能删除、重命名别人的文件吗?

是的。能创建文件说明进程有目录 wx 权限,就能删除和重命名文件,进一步的,如果有目录 r 权限,还可以列出所有文件。

所有能在这个目录下创建文件的用户都能访问目录下的文件吗?

不一定,文件虽然都有一样的 ogid,但是还要看文件创建者对 gperm 的设置,例如上面默认的设置是 r-- (目录 r-x),那么同组的人就只能读、不能写,创建者可以设置任意的 gperm 来允许或阻止同组人对自己文件的访问,所以结论是:在 setgid 目录下能创建文件的人,只能读取、写入或执行其它用户愿意让你读取、写入或执行的文件。一般情况下是会允许同组用户读和执行的,不然也没必要在共享目录中创建文件了。

考察了 setgid 目录的特性,再回到这个标志位的用处上来,前面其实已经简单说过,但是这个问题书上没有讲,网上也很少有人涉及,还比较费思量,多废一些口舌是值得的。如果仅仅是实现上面所说的功能,将需要访问目录的用户加到同一个用户组 (group id) 不就行了吗?特别是有附加用户组 (supgrp) 这种好东西,几乎可以将一个用户指派到无限的用户组;然后将目录 ogid 设置为这个组,这样当用户创建文件时,新文件的 ouid 跟随进程 euid、ogid 跟随进程的 egid 也就是目录 ogid,不也能达到一样的效果吗?哎,等等,这里好像有什么地方不对,新文件的 ogid 是用户进程的 egid 没错,但是不一定就是目录 ogid 啊,为什么呢?因为用户属于这个组可能是通过附加用户组 (supgrp),而不是初始用户组 (initgrp),这样一来,它创建的文件 ogid 将和父目录的 ogid 不同。

这段话说的有点绕,举个例子来说明,还以框架脚本中的用户为例,如果父目录 ogid 为 men,steven 不在对应的组,没有访问权限;caveman / paperman / lippman 在这个组中,都能创建文件;其中 caveman / paperman 在目录中创建文件的 ogid 跟随自己的 egid 也将为 men;而 lippman 创建的文件 ogid 将为 lippman 而非 men,从而在这个目录下面制造了一个“另类”,这个文件另类到其它同组用户可能根本没有访问权限——即使设置了 gperm 的适当权限也不行,除非放开 operm 权限,但是那样又会导致文件被任意不相干的人访问,这不是我们期望的。

所以刨根究底,目录 setgid 其实是为了填用户附加组 (supgrp) 挖的坑,让几乎不设限制的把一个用户添加到附加组这种行为,最终能得到期望的执行效果……

那么,最终要如何设置某个组的共享目录才是合理的合理的呢?以下几个是需要注意的点:

  • 目录 ogid 必需为要共享的组的 gid;
  • 目录最好设置 setgid;
  • 有额外要求的话 (禁止删除、重命名非自己创建的文件),可以设置 svtx 位。

之后,如果有哪个用户需要加入共享组,直接将这个组添加到他的附加组并重新登录即可,反之,将用户从组中删除即可。

更改文件权限

文件权限的变更主要分为两部分,一是文件所有权 (ouid / ogid) 的变更; 一是文件访问权限的变更 (perm)。下面分别说明。

变更文件所有权

文件所有权可通过 chown / chgrp 命令或基于 chown / fchown / lchown api 变更,最终能否设置新的所有权由以下规则决定:

  • euid == root
  • 或 euid == ouid
    • new owner == -1 或 new owner == ouid
    • 且 new group == -1 或 new group == ogid 或 supgid 包含 new group

即超级用户可以将任意文件更改到任意用户;而文件 owner (或 setuid 后相当于文件 owner) 进程只可以将属于自己的文件 ogid 更新到本人所属的其它组 (egid 和 supgid 组成的范围)。其中 -1 在 api 接口中表示对应字段不变更,相当于忽略对应的字段。当然并不是所有平台都是这样,sysv 系统和 freebsd 稍有不同:

  • freebsd / mac 都遵循和 linux 一样的规则:只允许 root 用户修改文件到任意用户;
  • Solaris 默认配置和 linux 一样,但是也可以通过修改配置来遵循 sysv 的规则:允许任意用户修改自己拥有的文件到其它用户。

不允许非 root 用户修改文件所有权的目的,据书上的说法是为了防止用户摆脱磁盘空间限额,这里限于文章范围,没有做验证。非超级用户更改文件所有权后,普通文件的特殊标志位也会跟着变化:

  • setuid 标志位:清除
  • setgid 标志位:清除

为什么在更新文件所有权后要清除这两个位呢?书上没有细说,感觉主要是 chown 后 setgid 代表的用户组发生了变更,防止误用。关于这一点可以参考本节末尾的用例。

下面简单了解一下 chown 与 chgrp 的用法:

代码语言:javascript复制
chown user:group file
chown user file
chown :group file
chgrp group file

chown 可同时变更文件的 ouid 与 ogid,使用冒号分隔用户名与组名。如果只修改其中一个,另一个可以留空,特别当用户组为空时,冒号可以省略;chgrp 相对“单纯”一些,只能修改文件的 ogid。详细的选项可以参考 man 手册页。

变更文件访问权限

文件访问权限可通过 chmod 命令或基于 chmod / fchmod api 变更,最终能否设置新的权限由以下规则决定:

  • euid == root
  • 或 euid == ouid

即只有超级用户、文件 owner (或 setuid 后相当于文件 owner) 进程可以更改文件的权限。新的文件权限由 mode 参数指定,这里的 mode 参数和 open / creat 一致。注意没有 lchmod,也就是说符号链接本身的权限基本是被忽略的,没有修改的必要,对此有疑问的同学可参考 man 手册页中的这段描述:

代码语言:javascript复制
       chmod  never  changes  the permissions of symbolic links; the chmod system call cannot
       change their permissions.  This is not a problem since  the  permissions  of  symbolic
       links  are  never  used.   However, for each symbolic link listed on the command line,
       chmod changes the permissions of the pointed-to file.  In contrast, chmod ignores sym‐
       bolic links encountered during recursive directory traversals.

在 mac 上符号链接的权限是可以被修改的 (通过 -h 选项),不过即使将符号链接的权限都关闭,仍可以通过它找到目标文件,仅是 readlink 出错不能读取符号链接内容而已,所以将符号链接的权限当成空气就好了。

除了可以设置权限位以外,还可以设置特殊标志位。特殊标志位除了遵循上面通用的规则,还有自己特定的规则,下面分别说明:

svtx 位

对于非超级用户设置普通文件的 svtx 标志位,不同系统有不同的策略:

  • freebsd / mac / Solaris:忽略 (只允许 root 设置)
  • linux:允许 (其实设置了也没什么效果)

限于本文范围,只在 linux 上做了验证,可参考本节末尾的用例。这个 svtx 位 (作用于普通文件) 在很早之前是为了减少频繁启动程序 (例如 vi / gcc) 的内存交换而设计的一种机制 (程序退出后仍保留在内存中以便下次快速加载),现在随着操作系统内存管理天翻地覆的改变,早已不再使用了,如果不是后来扩展出了作用于目录的用法,这个标志都不可能保留到现在。对于还支持这个标志位的系统 (例如 Solaris),它的底层机制也完全不同了,可能使用了某种高速缓存机制;不过普通文件如果没有可执行位,那么系统也不会高速缓存它们,因为这个标志位只针对普通文件中的可执行文件生效。

目录 setgid 位

在 setgid 目录中创建文件时,新文件的 ogid 会追随直属目录而非创建进程 (见“新建文件的权限”一节),上一节末尾的用例中提到当文件类型为目录时,新目录会继承父目录的 setgid 位。而在以下条件成立时,新目录的 setgid 继承会被自动关闭:

  • euid != root
  • 且新文件 ogid != egid
  • 且新文件 ogid 不包含于 supgid

不论新目录有没有 setgid 位,它的 ogid 必定是直属目录的 ogid 没错了,所以并不是直属目录的 setgid 没起作用,而是不再向下传递了而已。那为什么要设计这么一条规则呢? 书上的说法是——“防止了用户创建一个设置组 ID 文件,而该文件是由并非用户组所属的组拥有的”,听的云里雾里的,不过就规则本身来说可以这样理解:有一个 setgid 的共享目录,可以访问它的人可分为三种,第一类是 root 或 owner,一般拥有最高权限;第二类是组成员,用户的 egid 或 supgid 中的一个必定可以匹配目录的 ogid;第三类是其它用户,就是不在上面范围的用户。从规则的描述上来看,第一类人基本不受权限制约,被排除,由第二三条规则可知也不是第二类人,所以规则其实指的就是第三类人,即目录 operm 所描述的用户集合。此时如果 operm 指定的权限允许他们创建目录,则新创建的目录是不会自动继承 setgid 标志位的。这个还是比较容易验证的,以 setgid_parent_dir.sh 为例,再温习一下它的输出:

代码语言:javascript复制
$ sudo ./user_init.sh 
create group ok
create user ok
lippman : lippman men share
steven : share
caveman : men
paperman : men
show user and their group ok
switch to user lippman
drwxrwsrwx 2 lippman lippman 4.0K Jun  7 20:28 /tmp/share
drwxr-sr-x 2 lippman lippman 4.0K Jun  7 20:28 /tmp/share/lippman_dir
-rw-r--r-- 1 lippman lippman    0 Jun  7 20:28 /tmp/share/lippman_file
prepare testing file ok
switch to user caveman
drwxr-sr-x 2 caveman lippman 4.0K Jun  7 20:28 /tmp/share/caveman_dir
-rw-r--r-- 1 caveman lippman    0 Jun  7 20:28 /tmp/share/caveman_file
prepare testing file ok
switch to user paperman
drwxr-sr-x 2 paperman lippman 4.0K Jun  7 20:28 /tmp/share/paperman_dir
-rw-r--r-- 1 paperman lippman    0 Jun  7 20:28 /tmp/share/paperman_file
prepare testing file ok
switch to user steven
drwxr-sr-x 2 steven lippman 4.0K Jun  7 20:28 /tmp/share/steven_dir
-rw-r--r-- 1 steven lippman    0 Jun  7 20:28 /tmp/share/steven_file
prepare testing file ok
delete user ok
remve user home dir ok
delete group ok

共享目录 share 是由 lippman 创建的,所以它的 owner 是 lippman,组也是 lippman,而其它用户无论 ogid 还是 supgid,都不属于 lippman 组,因此符合上面所说第三类人的条件,但是输出显示它们创建的目录却都继承了 setgid 位,可见书中这里描述有误。为了验证这一点,修改 setgid_parent_dir.sh 中一行设置代码:

代码语言:javascript复制
chmod ug rwx,g s /tmp/share

去掉了 o 来关闭对其它用户的写入权限,再执行一下脚本,得到如下输出:

代码语言:javascript复制
$ sudo ./user_init.sh 
create group ok
create user ok
lippman : lippman men share
steven : share
caveman : men
paperman : men
show user and their group ok
switch to user lippman
drwxrwsr-x 2 lippman lippman 4.0K Jun  7 20:31 /tmp/share
drwxr-sr-x 2 lippman lippman 4.0K Jun  7 20:31 /tmp/share/lippman_dir
-rw-r--r-- 1 lippman lippman    0 Jun  7 20:31 /tmp/share/lippman_file
prepare testing file ok
switch to user caveman
mkdir: cannot create directory '/tmp/share/caveman_dir': Permission denied
touch: cannot touch '/tmp/share/caveman_file': Permission denied
ls: cannot access /tmp/share/caveman_*: No such file or directory
prepare testing file ok
switch to user paperman
mkdir: cannot create directory '/tmp/share/paperman_dir': Permission denied
touch: cannot touch '/tmp/share/paperman_file': Permission denied
ls: cannot access /tmp/share/paperman_*: No such file or directory
prepare testing file ok
switch to user steven
mkdir: cannot create directory '/tmp/share/steven_dir': Permission denied
touch: cannot touch '/tmp/share/steven_file': Permission denied
ls: cannot access /tmp/share/steven_*: No such file or directory
prepare testing file ok
delete user ok
remve user home dir ok
delete group ok

其它用户果然都无法创建任何文件了,可见之前的推论是没问题的。

文件 setuid / setgid  位

对于非超级用户进程写入一个文件时,以下特殊标志位会自动关闭:

  • setuid 标志位:清除
  • setgid 标志位:清除

这一策略主要是为了防止一些黑客篡改带有 setuid / setgid 位的可执行文件,让它们以普通用户身份获得超级用户权限去执行一些恶意代码来干坏事。本节末尾有一个用例演示了这一点。

如果使用 truncate / ftruncate 代替 write 来“写”文件时,得到的结果相同。

case:chgrp_clear_setgid.sh

这个用例主要用来验证变更文件 ouid 或 ogid 后,文件的 setuid 与 setgid 标志位会被清除。它由一段脚本组成:

代码语言:javascript复制
 1 #! /bin/sh
 2 echo "switch to user $(whoami)"
 3 # ensure new user can create file
 4 cd /tmp
 5 
 6 touch "/tmp/this_is_a_demo_file"
 7 chmod ug s,o t "/tmp/this_is_a_demo_file"
 8 ls -lh "/tmp/this_is_a_demo_file"
 9 
10 mkdir "/tmp/this_is_a_demo_dir"
11 chmod ug s,o t "/tmp/this_is_a_demo_dir"
12 ls -lhd "/tmp/this_is_a_demo_dir"
13 
14 #chown lippman:men "/tmp/this_is_a_demo_file"
15 #chown :men "/tmp/this_is_a_demo_file"
16 chgrp men "/tmp/this_is_a_demo_file"
17 #chgrp share "/tmp/this_is_a_demo_file"
18 #chgrp lippman "/tmp/this_is_a_demo_file"
19 ls -lh "/tmp/this_is_a_demo_file"
20 
21 chgrp share "/tmp/this_is_a_demo_dir"
22 ls -lhd "/tmp/this_is_a_demo_dir"
23 
24 echo "chgrpp clear setgid over"

主要就是创建普通文件 (line 6-8)、创建目录 (line 10-12)、变更文件所有权 (line 16,21)、检查标志位 (line 19,22),这里同时使用普通文件和目录文件作为对比。修改文件所有权的方式有很多,这里列举了四种方法 (line 14-16),选择了最直观的 chgrp 方式。此处只可将文件 ogid 设置为进程 egid supgid 范围内的用户组 (line 16-18 men / share / lippman),否则报错:

代码语言:javascript复制
chgrp: changing group of '/tmp/this_is_a_demo_file': Operation not permitted

在框架脚本中合适的位置插入代码来启动这个用例:

代码语言:javascript复制
 1     # case: change ogid clear setgid
 2     rm /tmp/this_is_a_demo_file 2>/dev/null
 3     rm -rf /tmp/this_is_a_demo_dir 2>/dev/null
 4     cp ./chgrp_clear_setgid.sh /tmp/
 5     su - lippman -s /tmp/chgrp_clear_setgid.sh
 6     chmod ug s,o t "/tmp/this_is_a_demo_file"
 7     chown caveman "/tmp/this_is_a_demo_file"
 8     ls -lh "/tmp/this_is_a_demo_file"
 9     chmod ug s,o t "/tmp/this_is_a_demo_dir"
10     chown steven "/tmp/this_is_a_demo_dir"
11     ls -lhd "/tmp/this_is_a_demo_dir"

这里使用 lippman 用户执行上面的用例,因为他同时属于三个用户组,可以在这之间做无缝切换,便于验证。

上面那个脚本只能验证变更文件 ogid,从本节前面的内容可以得知,想要变更文件 ouid,必需使用 root 权限,刚好框架脚本具有 root 权限,于是在后半部分顺便验证了文件 ouid 的变更对特殊标志位的影响 (line 6-11)。上面脚本的输出如下:

代码语言:javascript复制
 1 $ sudo ./user_init.sh 
 2 create group ok
 3 create user ok
 4 lippman : lippman men share
 5 steven : share
 6 caveman : men
 7 paperman : men
 8 show user and their group ok
 9 switch to user lippman
10 -rwSr-Sr-T 1 lippman lippman 0 Jun  7 19:12 /tmp/this_is_a_demo_file
11 drwsr-sr-t 2 lippman lippman 4.0K Jun  7 19:12 /tmp/this_is_a_demo_dir
12 -rw-r-Sr-T 1 lippman men 0 Jun  7 19:12 /tmp/this_is_a_demo_file
13 drwsr-sr-t 2 lippman share 4.0K Jun  7 19:12 /tmp/this_is_a_demo_dir
14 chgrpp clear setgid over
15 -rw-r-Sr-T 1 caveman men 0 Jun  7 19:12 /tmp/this_is_a_demo_file
16 drwsr-sr-t 2 steven share 4.0K Jun  7 19:12 /tmp/this_is_a_demo_dir
17 delete user ok
18 remve user home dir ok
19 delete group ok

由于创建的是普通文件,所以初始权限为 "rw- r-- r--",经过 chmod 处理后变为  "rwS r-S r-T",从前面章节可以知道,这里 S 分别表示 setuid 与 setgid 没有 x 权限位的组合,T 是 svtx 没有 x 权限的表示;相对的,由于目录的初始权限为 "rwx r-x r-x",经过 chmod 处理后就变为 “rws r-s r-t”,上面的输出符合预期。第一次变更普通文件 ogid,从 lippman 变更为了 men,再观察文件权限位,发现 setuid 确实被清除了,setgid 却仍然保留;第二次是变更普通文件用户 ouid,从 lippman 变更为 caveman,结果与前面相同。难道 x 权限位的缺失导致了 setgid 没有正确清除?抱着试一试的态度,将代码中 chmod 的参数修改如下:

代码语言:javascript复制
chmod ug xs,o xt "/tmp/this_is_a_demo_file"
chmod ug xs "/tmp/this_is_a_demo_dir"

为文件的所有 perm 分组加入了 x 权限,此时脚本输出如下:

代码语言:javascript复制
create group ok
create user ok
lippman : lippman men share
steven : share
caveman : men
paperman : men
show user and their group ok
switch to user lippman
-rwsr-sr-t 1 lippman lippman 0 Jun  7 19:25 /tmp/this_is_a_demo_file
drwsr-sr-t 2 lippman lippman 4.0K Jun  7 19:25 /tmp/this_is_a_demo_dir
-rwxr-xr-t 1 lippman men 0 Jun  7 19:25 /tmp/this_is_a_demo_file
drwsr-sr-t 2 lippman share 4.0K Jun  7 19:25 /tmp/this_is_a_demo_dir
chgrpp clear setgid over
-rwxr-xr-t 1 caveman men 0 Jun  7 19:25 /tmp/this_is_a_demo_file
drwsr-sr-t 2 steven share 4.0K Jun  7 19:25 /tmp/this_is_a_demo_dir
delete user ok
remve user home dir ok
delete group ok

 所有 S 变成了 s,且在变更文件 ogid / ouid 后,都能正确的将 setuid 与 setgid 位清除了。从上面的输出还可以得到以下结论:

  • 作用于普通文件的 svtx (没毛用) 不受影响;
  • 作用于目录的 svtx 不受影响;
  • 作用于目录的 setuid (没毛用) 不受影响;
  • 作用于目录的 setgid 不受影响;

case:write_clear_setuid.sh

这个用例是用来验证 write 或 truncate 后,可执行文件的 setuid 和 setgid 标志位将被清除。它由一段脚本组成:

代码语言:javascript复制
 1 #! /bin/sh
 2 echo "switch to user $(whoami)"
 3 # ensure new user can create file
 4 cd /tmp
 5 
 6 # create setuid & setgid program
 7 cp setugid setugid_demo
 8 chmod ug s,ugo wx /tmp/setugid_demo
 9 ls -lh setugid_demo
10 
11 echo "create testing setuid/setgid file ok"
12 echo "1" >> setugid_demo
13 
14 echo "write 1 bytes into executable file"
15 ls -lh setugid_demo
16 
17 chmod ug s,ugo wx /tmp/setugid_demo
18 truncate -s 8K setugid_demo
19 
20 echo "truncate executable file to 8K"
21 ls -lh setugid_demo
22 
23 rm setugid_demo
24 echo "remove testing file ok"

主要分以下几步:创建带 setuid / setgid 标志的可执行文件 (line 7-9)、在文件末尾写入一字节观察结果 (line 11-15)、重新设置标志位并截断文件再观察结果 (line 17-21)。在框架脚本中加入以下启动代码:

代码语言:javascript复制
1     # case: write clear setuid 
2     cp ./setugid /tmp/
3     cp ./write_clear_setuid.sh /tmp/
4     su - lippman -s /tmp/write_clear_setuid.sh

用 lippman 身份执行该脚本。下面是脚本输出:

代码语言:javascript复制
$ sudo ./user_init.sh 
create group ok
create user ok
lippman : lippman men share
steven : share
caveman : men
paperman : men
show user and their group ok
switch to user lippman
-rwsrwsrwx 1 lippman lippman 9.5K Jun  8 09:48 setugid_demo
create testing setuid/setgid file ok
write 1 bytes into executable file
-rwxrwxrwx 1 lippman lippman 9.5K Jun  8 09:48 setugid_demo
truncate executable file to 8K
-rwxrwxrwx 1 lippman lippman 8.0K Jun  8 09:48 setugid_demo
remove testing file ok
delete user ok
remve user home dir ok
delete group ok

初始时测试文件是带着两个标志位的 (rws rws rwx),经过末尾写入一 byte 后两个标志位消失了 (rwx rwx rwx),重新设置标志位并截断文件后 (从 9.5 K 截短到 8K),两个标志位也能消失,验证了上面的结论。

后记

写了这么多,是否已经把 linux 文件权限说尽了?非也非也。这都是几十年前的东西了,现代 linux 也推出了更灵活的基于 ACL (Access Control List) 的访问权限设置,可以针对某个用户做单独的设置,让他可以或不能访问某个特定目录或文件,这比把用户加入一个组并获得该组所有目录的访问权限要安全的多。下面是与 ACL 相关的命令:

  • setfacl
  • getfacl

当设置了 acl 后,ls 的输出也会有所不同,通常是在权限位末尾多一些特殊字符用以标记。此外还可以像 windows 一样设置文件的属性:

  • chattr
  • lsattr

选项的不同,对文件产生的影响也不同,这里罗列一些比较常用的选项:

  • i:只读属性
    • 普通文件:不允许对文件进行删除、重命名、添加数据;
    • 目录:不能创建、删除、重命名目录下的文件;
  • a:追加属性
    • 普通文件:只能向文件中追加数据,不能删除或编辑文件;
    • 目录:只能在目录中建立或修改文件,不能删除文件;
  • ……

可以实现对文件、目录行为更精细的控制。具体细节没有做深入研究,这里只是作为一个引子,推荐各位读者继续探索。不过再高深的权限控制,也是以基础知识作为根底的,例如设置 acl 时指定的权限位 rwx 就和我们在前面说明的完全一致。

行文至此,我主要想写一个关于目录权限的“突发奇想”——如果我只放开目录的 wx 权限,不放开 r 权限,那么其它用户能在这个目录下做什么呢?根据前面的知识,我们知道 ’-wx‘ 权限下用户可以创建、删除、重命名文件,也可以通过目录访问其中的文件,唯独不能列出目录内容。那么这个目录对于用户就像是一个“黑暗森林”,谁也看不到别人,甚至看不到自己,呃……好像还是蛮有用的,因为好多安全问题就是你的文件暴露在了陌生人面前,如果他都看不到的话,你的文件是不是就更安全了呢?

如果再加入 svtx 位,一个用户不能删除、重命名另一个用户的文件,这样就更有意思了:文件的创建完全凭运气,先到先占用,创建的文件失败了,说明已经有人占用了这个名称,你只能换个别的名称再试。好在有子目录,如果将所有工作都放在子目录中进行,冲突的概率应该会大大降低。唯一不方便的是时间长了可能忘记自己创建过哪些文件,所以可能需要将创建过的文件记录在一个清单中……

对于 root 或 owner 这个目录则是一览无余,拥有上帝视角,很好奇这样一个目录时间长了会发展成什么样子……哈哈,以上只是一些不着边际的想法,供大家一乐。

下载

本文相关的脚本都上传在了 git 上,可以通过以下路径访问:

https://github.com/goodpaperman/apue/tree/master/04.chapter/permission

或者直接复刻整个库到本地,再切换到对应目录即可:

代码语言:javascript复制
git clone git@github.com:goodpaperman/apue.git
cd apue/04.chapter/permission

每个用例对应一个或多个脚本,多个脚本时以数字后缀区分。在框架脚本 user_init.sh 中可以通过将条件语句修改为 true 来打开对应的用例,例如:

代码语言:javascript复制
1 if true; then 
2     # case: write clear setuid 
3     cp ./setugid /tmp/
4     cp ./write_clear_setuid.sh /tmp/
5     su - lippman -s /tmp/write_clear_setuid.sh
6 fi

你可以打开所有的用例开关,不过那样输出就会混在一起,阅读起来不是特别方便。

参考

1. Linux查看用户所属用户组

2. 一个用户最多能加入多少个组?

3. Linux的chmod与symbolic link

4. 文件的uid、gid 进程的euid 、egid 、附加组ID(如果支持) 总结

5. Linux SetGID(SGID)文件特殊权限用法详解

6. Linux下查看某个用户组下的所有用户

7. Linux修改用户所属组的方法

8. shell不能执行su 后的脚本

9. shell脚本中使用其他用户执行脚本

10. Linux, sudo with no password (免密码sudo)

11. Linux命令:修改文件权限命令chmod、chgrp、chown详解

12. 关于 Linux系统用户、组和权限管理

13. Linux用户(user)与用户组(group)管理(超详细解释)

14. 配置 Linux 的访问控制列表(ACL)

0 人点赞