权限大概是我讲过最多、也踩坑最多的那部分 Linux。rwx 这三个字母看着简单,直到某个服务读不了一个明明“属于它”的文件、某个共享目录里新文件老是落在错误的组里,或者一次 chmod -R 777 的“修复”把网站根目录变成了谁都能写的靶子。这篇是一本 Linux 权限手册:先讲心智模型,再讲真正重要的四个工具——chmod、chown/chgrp、umask、setfacl——而且每条结论都配我自己机器上的真实输出。单用户示例以普通账号在宿主机上运行;多用户示例(setuid、setgid、sticky、ACL 实际生效)放在一个用完即弃的容器里跑,所以那些 Permission denied 是真报错,不是我编的。如果你要加固一台机器而不只是排查它,那篇服务器加固清单和systemd 沙箱笔记是本文的配套。
Linux 权限模型:文件和目录不是一回事
每个文件都带三组权限位:属主(owner)、属组(group)、其他人(other)。stat 会把符号和八进制两种写法都打出来:
$ stat -c "%A %a %n" file.txt dir .
-rw-r--r-- 644 file.txt
drwxr-xr-x 755 dir
drwxr-xr-x 755 .
%A 是符号形式,%a 是八进制。644 就是 rw- r-- r--:属主可读可写,属组和其他人只能读。755 就是 rwx r-x r-x。真正的坑在于:r、w、x 在目录上的含义完全不同:
x 是最容易被忽略的那一个——没有它,r 只给你一堆用不了的名字。对文件,x 表示“可以执行”。对目录,x 表示“可以穿越进去”——按已知名字打开、stat 某个条目,或者 cd 进去。这条区别解释了绝大多数权限 bug,也顺带给出那条迟早都得记住的规则:
删除或重命名一个文件,取决于目录的写位和执行位,而不是文件自身的权限。
只要你对父目录有 wx,就能删掉一个只读文件;反过来,父目录不允许时,一个“所有人可写”的文件也删不掉。
目录上的 x 到底意味着什么
下面是一个 400(r--)的目录——能读,但不能穿越:
$ chmod 400 d1
$ ls d1 # r 让我们读到名字列表
file
$ ls -l d1 # -l 需要对每个条目 stat(),那需要 x
ls: cannot access 'd1/file': Permission denied
$ cat d1/file # 要够到文件,需要 x 穿越 d1
cat: d1/file: Permission denied
$ cd d1
cd: d1: Permission denied
ls 打出了名字,因为读目录只是列出名字;但每个条目都显示成 ?????????,既打不开也进不去。再换成 100(--x)——能穿越,不能读:
$ chmod 100 d1
$ ls d1 # x 并不让你列出内容
ls: cannot open directory 'd1': Permission denied
$ cat d1/file # 但一个你已经知道的名字是够得到的
topsecret
$ cd d1 && ls
ls: cannot open directory '.': Permission denied
所以:只有 r 没有 x,你拿到的是用不了的名字;只有 x 没有 r,你能访问那些你本来就发现不了的路径。几乎每个 Web 服务器都这么工作:nginx 对通往某个文件的各级目录只需要 x,不需要 r。
umask:默认权限从哪来
新建文件和目录并不是从 777 起步。内核从一个基准值开始——文件是 666,目录是 777——再减去 umask:
$ umask
0022
$ umask -S
u=rwx,g=rx,o=rx
022 清掉了属组写位和其他人写位,所以 touch 出来是 644、mkdir 出来是 755。改了 umask,默认值随之改变:
$ (umask 027; touch f; mkdir d; stat -c '%A %a %n' f d)
-rw-r----- 640 f
drwxr-x--- 750 d
$ (umask 077; touch f; mkdir d; stat -c '%A %a %n' f d)
-rw------- 600 f
drwx------ 700 d
$ (umask 002; touch f; mkdir d; stat -c '%A %a %n' f d)
-rw-rw-r-- 664 f
drwxrwxr-x 775 d
022 是桌面默认。027(640/750)是我在服务器上设的值,因为文件仍可被同组读取,但对外人关闭。077 用于任何私密内容。002 在一些发行版里是刻意设置的,方便共享组协作,但它同时意味着“其他人”也能读。把 umask 写进 /etc/profile 或 systemd 单元里(UMask=0027),别只在一个你会忘记的交互式 shell 里设。
chmod、chown 与 chgrp 实战
chmod 支持符号形式和八进制形式;符号形式更好审查,因为它把意图说清楚了:
$ chmod u+x report.sh; stat -c '%A %a %n' report.sh
-rwxr--r-- 744 report.sh
$ chmod g-w,o-rwx report.sh; stat -c '%A %a %n' report.sh
-rwxr----- 740 report.sh
$ chmod a+r report.sh; stat -c '%A %a %n' report.sh
-rwxr--r-- 744 report.sh
$ chmod 640 report.sh; stat -c '%A %a %n' report.sh
-rw-r----- 640 report.sh
属主/属组和权限模式是两回事。chgrp 可以把文件移到一个你所属的组;而改属主只有 root 能做:
$ chgrp video shared.txt
$ stat -c '%A %a %U:%G %n' shared.txt
-rw-r--r-- 644 awinx:video shared.txt
$ chown nobody shared.txt
chown: changing ownership of 'shared.txt': Operation not permitted
在容器里以 root 运行时,同样的 chown 会成功——这正是 chown 是一条权限边界的原因,也是 CAP_CHOWN 值得挡在容器之外的原因。chmod --reference=other 和 find . -type d -exec chmod 755 {} + 是我最常用的两个变体。
特殊位:setuid、setgid 与 sticky bit
在常规的九位之上还有三位,每一位都在特定场景下改变行为。本节所有内容都以 root 在一个用完即弃的容器里运行,用户是 alice(1001)、bob(1002)、carol(1003)。
setuid(4) 加在一个可执行文件上,会让它以文件属主的身份运行。一个三行 C 程序就能证明:
$ chmod 4755 /vol/work/who
-rwsr-xr-x 4755 root:root /vol/work/who
$ su alice -c /vol/work/who
real uid=1001 effective uid=0
$ chmod 0755 /vol/work/who
$ su alice -c /vol/work/who
real uid=1001 effective uid=1001
ls 里显示成 rws——本该是属主 x 的位置变成了 s。passwd、sudo、mount 就是这么干活的;也正因如此,它是需要审计的那一位:一个你没装过的 setuid 程序,就是一个有完美不在场证明的后门。在目录上,Linux 会忽略 setuid。
setgid(2) 加在目录上,正好解决经典的共享目录问题:里面新建的文件会继承目录的属组,而不是创建者的属组:
$ chmod 2775 /vol/work/proj
drwxrwsr-x 2775 carol:dev /vol/work/proj
$ su alice -c "touch /vol/work/proj/alice.txt"
$ stat -c '%A %a %U:%G %n' /vol/work/proj/alice.txt
-rw-r--r-- 644 alice:dev /vol/work/proj/alice.txt
alice 的文件落在 dev 组里,而不是 alice,因为 setgid 目录提供了这个组。没有 setgid 时,同样的命令会得到 alice:alice——这就是为什么共享目录里的两个人会突然看不到彼此的文件。加在可执行文件上时,setgid 对属组做的是 setuid 对属主做的那件事。
sticky bit(1) 加在一个所有人可写的目录上,会限制谁能删除条目:只有文件属主、目录属主或 root:
$ stat -c '%A %a %U:%G %n' /vol/work/shared
drwxrwxrwt 1777 carol:carol /vol/work/shared
$ su bob -c "echo tampered >> /vol/work/shared/carol.txt" # 改内容:允许
$ su bob -c "rm /vol/work/shared/carol.txt" # 删除:禁止
rm: cannot remove '/vol/work/shared/carol.txt': Operation not permitted
注意 sticky 不做什么:它并不阻止 bob 改写一个 666 文件的内容,只阻止他删除或重命名这个条目。这也是 /tmp 是 1777 的原因,也是为什么一台普通机器上的 setuid 程序值得列一列:
$ ls -ld /tmp
drwxrwxrwt 33 root root 2980 Oct 12 18:51 /tmp
$ find /usr/bin /bin -maxdepth 1 -perm -4000 -printf '%M %p\n' | head -4
-rwsr-xr-x /usr/bin/chsh
-rwsr-xr-x /usr/bin/umount
-rwsr-xr-x /usr/bin/passwd
-rwsr-xr-x /usr/bin/pkexec
setfacl:当一个组不够用时(ACL)
Unix 给一个文件只能有一个属主和一个属组。当你需要“video 组以及用户 1001 都能读”时,就需要 ACL 了。setfacl 添加条目,getfacl 查看条目:
$ stat -c '%A %a %U:%G %n' deploy.conf
-rw-r----- 640 awinx:video deploy.conf
$ setfacl -m u:1001:rw deploy.conf
$ getfacl deploy.conf
# file: deploy.conf
# owner: awinx
# group: video
user::rw-
user:1001:rw-
group::r--
mask::rw-
other::---
$ ls -l deploy.conf
-rw-rw----+ 1 awinx video 8 Oct 12 18:51 deploy.conf
ls -l 末尾那个 + 就是“存在 ACL”的信号。mask:: 那行是大家最容易忽略的:它一次性限制所有具名用户、具名组以及属组。
$ setfacl -m m::r deploy.conf
$ getfacl -e deploy.conf
user::rw-
user:1001:rw- #effective:r--
group::r-- #effective:r--
mask::r--
other::---
-e 打印经过 mask 之后的有效权限。普通的 chmod 也会改写 mask(而不是具名条目),这就是“我明明配了 ACL,后来又不生效了”的常见来源。在容器里,ACL 确实能给一个不属于任何共享组的用户开通访问:
$ getfacl -c /vol/work/acl_demo
user::rw-
user:alice:rw-
group::r--
mask::rw-
other::---
$ su alice -c "echo hi-from-alice >> /vol/work/acl_demo"
$ su bob -c "echo hi-from-bob >> /vol/work/acl_demo"
sh: 1: cannot create /vol/work/acl_demo: Permission denied
默认 ACL(setfacl -d)让目录里新建的条目自动继承这些权限——这是搭一个“投放箱”目录的干净做法:
$ setfacl -d -m u:1001:rwx -m g:video:rx shared
$ getfacl -p shared
user::rwx
group::r-x
other::r-x
default:user::rwx
default:user:1001:rwx
default:group::r-x
default:group:video:r-x
default:mask::rwx
default:other::r-x
$ touch shared/newfile
$ getfacl -p shared/newfile
user::rw-
user:1001:rwx #effective:rw-
group::r-x #effective:r--
mask::rw-
other::r--
default: 那一块会被复制到新文件上;#effective: 注释则显示 mask 把每个条目裁剪成创建程序真正请求的样子。默认 ACL 让你无需逐个碰文件,就能把一整棵树授权给某个组——也正因为它们存在扩展属性里,所以特别容易丢。
那些经典错误
chmod -R 777。 这是最典型的错误“修复”。递归下去,它让每个文件都可执行、每个目录都可写:
$ chmod -R 777 app
drwxrwxrwx 777 app
-rwxrwxrwx 777 app/run.sh
-rwxrwxrwx 777 app/notes.txt
$ find app -perm -o+w -printf '%M %p\n'
drwxrwxrwx app
-rwxrwxrwx app/notes.txt
-rwxrwxrwx app/run.sh
现在每个普通文件都成了一个谁都能改的脚本。去修真正的原因——属主错了、组缺了、某个目录少了个 x——而不是这样一刀切。
所有人可写的文件与目录。 find / -xdev -type f -perm -0002 会列出任何人都能改写的文件;正常情况下只该出现极少数刻意为之的结果。对目录再用 -o+w 跑一遍,你就有了真正重要的那份审计。
目录上少了个 x。 这是上一个 bug 的反面:文件明明是 644、可读,某个服务却仍然拿到 Permission denied,因为某个父目录缺了 x。用 namei -l /path/to/file 一次把整条路径检查完。
复制时悄悄丢掉的 ACL。 ACL 存在 system.posix_acl_access 扩展属性里,而普通的 cp 不会保留它:
$ getfacl -c src.conf
user::rw-
user:1001:r--
group::r--
mask::r--
other::r--
$ cp src.conf dst.conf; getfacl -c dst.conf
user::rw-
group::r--
other::r--
$ cp -a src.conf dst_a.conf; getfacl -c dst_a.conf
user::rw-
user:1001:r--
group::r--
mask::r--
other::r--
用 cp -a(或 --preserve=all)、rsync -A 和 tar --acls。一次“恢复成功”但把 ACL 丢掉的备份,下周会以同样的方式再挂一次。
我真正会跑的权限检查清单
- 服务器 umask 设成
027(单元里写UMask=0027),别用022。 - 进程要穿越某个目录,就给它
x;永远不要靠加777去“修”一个读取错误。 - 共享目录用
setgid(2775)让属组固定下来;很多人往里写时再加 sticky bit(1777)。 - 只为一个文件授权时,优先
setfacl,而不是把所有人都加进某个组。 - 用
find / -xdev -perm -4000 -type f审计 setuid,用find / -xdev -type f -perm -0002审计所有人可写的文件。 - 复制和备份时保留权限与 ACL:
cp -a、rsync -A、tar --acls。 - 甩锅给文件之前,先用
namei -l把整条路径复查一遍。
权限不是装饰,它是所有上层东西下面的那层访问控制。把模型弄对——目录上的 r/w/x、一个组加上 ACL、三个特殊位——剩下的系统都会跟着简单起来。下一步自然是把它们用到一台加固过的服务上,那正是服务器加固清单和systemd 沙箱笔记接手的地方。