文件权限排查五层:ACL、umask与setgid

一条 403:明明有权限为什么改不了文件

这件事发生在一台跑了三年多的老服务器上。我把网站目录的属主改成了新的部署账号 deploy,组也一起换了,ls -l 看着一切正常,然后部署脚本一跑就报 Permission denied,接下去所有上传都失败。

当时的第一反应是「权限没给够」,于是 chmod -R 777 一把梭,问题确实消失了。三个月后同类的错误又出现在另一个目录上,这次 777 也没救回来——因为那不是权限位的问题,是 ACL 的问题。

Linux 的文件权限系统其实有四套机制叠在一起:传统十二位权限(rwx + SUID/SGID/sticky)、umask、POSIX ACL、以及扩展属性(chattr)与 SELinux/AppArmor 这类强制访问控制。任何一层都能让你的「看起来有权限」变成「实际被拒」。这篇就把这四层按顺序讲清楚,重点是那些「ls -l 看不出问题」的情况。

第一层:十二位权限,被忽略的后三位

ls -l 输出里第一个字段有十个字符,但真正的权限是十二位——前三位是特殊的 setuid、setgid、sticky:

测试文件权限:
  -rwsr-xr-x   setuid(属主执行位是 s)  典型:/usr/bin/passwd
  -rwxr-sr-x   setgid(属组执行位是 s)  典型:/usr/bin/wall
  drwxrwxrwt   sticky(其他执行位是 t)  典型:/tmp

这里最值得站长注意的是 setgid 用在目录上。当一个目录被设置了 setgid(chmod g+s dir),在这个目录里新建的文件和子目录会继承该目录的属组,而不是创建者的主组。这个特性在协作部署里非常有用,也是最容易在某次权限修复中被无意改掉的东西。

我自己就遇过一次:网站目录设了 g+s,让 deploy 和 www-data 都在 www 组里,新上传的文件自动属于 www 组,PHP 能正常读。后来某次用 rsync --chmod=Dug=rwx,Fug=rw,o= 同步了一版,那个三位特殊位被 --chmod 直接清掉了,之后上传的新文件属组变成了 deploy 的主组,PHP 读不到,整站 500。

排查这种问题的命令是:

find /var/www -maxdepth 1 -type d -printf '%m %p\n' | sort -u
# 0644 目录 = 没有 s 位;2755 = 有 setgid
stat -c '%a %A %U %G %n' /var/www/site/uploads

stat -c '%a' 输出的八进制四位数(比如 2755)第一位就是特殊位,比 ls -l 看字母更不容易看错——尤其当你有几十个目录要检查的时候。

关于 SUID 还有一个必须知道的常识:SUID 对脚本文件无效。Linux 内核只会对 ELF 可执行文件应用 setuid 位,给一个 shell 脚本加 chmod 4755 不会有任何提权效果。所以如果在服务器上发现「非二进制文件带着 SUID」,那不是配置失误,而是入侵信号。

第二层:umask,管的是「新建」不是「已有」

umask 是绝大多数人搞错的一层。它的作用是在创建文件时,把「全开」的权限位减去 umask 的值。默认文件基数是 666(不含执行位),目录基数是 777。

umask 022  →  新文件 644,新目录 755(最常见的默认)
umask 002  →  新文件 664,新目录 775(协作场景,需要配合 setgid)
umask 027  →  新文件 640,新目录 750(对「其他用户」完全不开放)
umask 077  →  新文件 600,新目录 700(私有文件,如密钥、备份)

三个容易踩的点:

第一,umask 只管创建,不管修改。 你把 /etc/profile 里的 umask 改成 027,然后 chmod -R 之前已存在的文件一点没变,只有之后新建的才按新规则来。很多人改完发现没生效,就是这个原因。

第二,setgid 目录不会改变既有文件的属组。 给目录设了 g+s 之后,只有之后新建的文件才继承该目录的属组;目录里已有文件的属组原封不动。这是部署脚本里最容易漏掉的一步:换属主、换组、加上 setgid,看着都对,但历史文件还在旧组里,PHP 依然读不到。补一次 chgrp -R www /var/www/site 才彻底。顺带一个反直觉的细节:用 mv 把一个文件移进 setgid 目录,它不会继承新目录的属组(mv 只改路径不改 inode);而 cp 会继承。同一份操作,mv 和 cp 结果不同,就是因为 inode 有没有被重建。

第三,不同执行环境的 umask 完全不同。 这是最容易出事故的地方:你在 SSH 里手动跑脚本,文件是 644;同样的脚本由 cron 跑,文件可能是 640 或 600;由 systemd 服务跑,又是一套。因为 systemd 的默认 UMask 是 0022,但它可以被 /etc/systemd/system.conf 的 DefaultUMask 覆盖,也可能被 unit 里的 UMask= 覆盖。

# 查看当前 shell 的 umask
umask

# 查看某个 systemd 服务的运行环境
systemctl show nginx -p UMask

# 查询某个服务的进程实际 umask
grep -i umask /proc/$(pgrep -o php-fpm)/status

第四,install -m 和 cp -p 会绕开 umask。 cp -p 保留源文件权限,install -m 644 显式指定权限;两者都不受当前 umask 影响。所以如果你用 install 部署文件,发现权限跟预期不符,别怪 umask,去看 -m 参数。

一个我强烈建议的习惯:把敏感文件的 umask 显式写在脚本里,而不是依赖环境。mysqldump 的备份、certbot 的私钥、SSH 的 authorized_keys,这些文件「绝不能让 other 读到」。在脚本开头加一行 umask 077,比事后 chmod 可靠得多——因为事后 chmod 之前,文件已经用默认权限存在了几十毫秒,这段时间里它是可读的。

第三层:ACL,权限被拒的头号隐形原因

当 ls -l 显示的权限完全够、但访问照样被拒时,十有八九是 setfacl 设过的 ACL。判断方法很直接——看 ls -l 权限位后面多出来的那个 +:

-rw-r--r--+  1 deploy www  4096 Oct 24 10:11 index.php
           ↑
     这个加号表示该文件带有 ACL 条目

有 + 就用 getfacl 看完整规则:

getfacl -p /var/www/site/uploads
# file: var/www/site/uploads
# owner: deploy
# group: www
# flags: -s-
user::rwx
user:www-data:rwx      ← 关键:这是给单个用户的额外授权
group::rwx
mask::rwx              ← 关键:这是权限的「上限」,必须包含上面的位
other::---
default:user::rwx
default:user:www-data:rwx   ← 目录的默认 ACL,会遗传给新建文件

ACL 里最容易搞错的是 mask。mask 是「所有命名用户和命名组条目的有效权限上限」。如果 mask 是 r--,那么即使你写了 user:www-data:rwx,实际有效权限也只有 r--。这意味着有人用 setfacl -m u:www-data:rwx 加了权限,但 mask 没跟着更新,结果还是不行。更麻烦的是 chmod 会修改 mask:

# 危险:chmod 会重写 mask,把 ACL 的有效权限一起改掉
chmod 755 /var/www/site/uploads
# 此时 mask 从 rwx 变成了 r-x,命名用户的 w 位被「隐形」剥夺

# 正确:用 -n 保持 mask 不变再改传统权限位
setfacl -n -m m::rwx,m::rwx /var/www/site/uploads

我自己被这个坑过一次:给 www-data 授了 uploads 的写权限,为了修另一个问题顺手 chmod 755 了一下,ACL 还在,getfacl 显示 user:www-data:rwx,但旁边 effective 已经是 r-x 了,上传功能当天下午就挂了。查了两个小时。

ACL 在网站目录里最有用的场景是「目录递归授权」,这恰好是传统权限做不到的(传统权限里目录的 x 位和文件的 x 位含义完全不同,递归 chmod 很容易给所有文件加上执行位):

# 给 www-data 递归读写权限,同时让新建文件自动继承
setfacl -R -m u:www-data:rwX /var/www/site
setfacl -R -d -m u:www-data:rwX /var/www/site

注意这里是 rwX 大写 X,不是 rwx。大写 X 的含义是「只对目录、或已经有执行位的文件添加执行权限」,这个设计正是为了解决 chmod -R 会给所有 PHP 文件加上执行位的经典事故。

清理时用 setfacl -b(清空所有 ACL 条目,默认 ACL 也会被清),或者 -k 只清默认 ACL。建议在每次批量改权限之前先备份 ACL:

getfacl -R -p /var/www/site > /root/acl-backup-$(date +%F).txt

第四层:chattr 的 i 与 a,连 root 都挡

如果前四层都没问题、getfacl 干净、ls -l 权限也对,但还是报 Operation not permitted(注意:不是 Permission denied,这两个错误码不一样,前者是 EPERM 后者是 EACCES),那就要看扩展属性了:

lsattr /var/www/site/index.php
# ----i---------e------- /var/www/site/index.php
#     ↑ i 位 = immutable,任何进程都不能修改、删除、重命名

chattr +i 常被用来「保护重要文件不被篡改」,包括网站首页以防挂马。但它有两个副作用很容易让人事后抓狂:

一是 +i 会阻止文件被重命名和删除,所以「改完记得 chattr -i」这一步一旦漏掉,后面的所有部署都会失败,而且错误信息是 Operation not permitted,跟权限看起来完全无关。

二是给日志文件加 +i 会让 logrotate 卡住。 logrotate 的逻辑是先 rename 再新建,rename 被 +i 挡住,就会一直报 rename failed: Operation not permitted,日志永远不轮转,最后撑爆磁盘。日志文件应该用 +a(append only),不要用 +i——+a 允许追加和重命名(在部分内核上重命名仍受限),但仍然禁止修改已有内容,防篡改的效果更贴合日志的语义。

用 +i 保护网站文件的正确做法是:先 -i,改文件,再 +i,并把这个「三步」写进部署脚本,别指望人记住:

# deploy.sh 片段
lock_target="/var/www/site/index.php"
chattr -i "$lock_target" || true
cp /srv/releases/index.php "$lock_target"
chattr +i "$lock_target"

要批量找出被加了 +i 的文件(在收到「Operation not permitted」时非常有用):

find /var/www -xdev -type f -exec lsattr {} + 2>/dev/null | grep '^....i'

第五层:SELinux 与 AppArmor,Ubuntu/Debian 上的常客

在 Debian/Ubuntu 上,AppArmor 是默认启用的,它加载了对 mysqld、nginx、php-fpm 等进程的 profile。AppArmor 的拦截会以 Permission denied 的形式出现,但文件权限完全正常,这就是最迷惑人的地方。

判断方法:

# 1) 确认 AppArmor 是否启用
aa-status 2>/dev/null | head -5
# 或者
cat /sys/module/apparmor/parameters/enabled

# 2) 看内核日志里有没有 apparmor="DENIED"
dmesg | grep -i 'apparmor.*DENIED' | tail -10
# 典型输出:
# apparmor="DENIED" operation="open" profile="/usr/sbin/nginx"
#   name="/var/www/site/uploads/cache/" pid=1234 comm="nginx"

如果看到了 apparmor="DENIED",说明就是它拦的,跟 chmod 一点关系没有。处理顺序应该是:先确认这条访问是否合理,合理就改 profile 而不是关掉 AppArmor。 直接 systemctl stop apparmor 是很多人的第一反应,但这等于把整台机器的一层防护关掉了,而且下次重启又会自己起来。

正确的做法是编辑对应的 profile:

# 1) 进入投诉模式(只记日志不拦截),观察一段时间
aa-complain /usr/sbin/nginx

# 2) 看日志确认要放行的路径
journalctl -k --grep=DENIED --since='1 hour ago'

# 3) 编辑 profile 添加规则(nginx 的 profile 在 /etc/apparmor.d/usr.sbin.nginx)
#    加入:
#    /var/www/site/uploads/** rwk,

# 4) 重新加载
apparmor_parser -r /etc/apparmor.d/usr.sbin.nginx
# 或者
systemctl reload apparmor

# 5) 再切回强制模式
aa-enforce /usr/sbin/nginx

另外,Debian 上还有一个容易忽略的点:/etc/apparmor.d/local/ 目录是专门留给本地修改的,升级包不会覆盖它。把自定义规则写在那里,比直接改主 profile 更安全——否则下次 apt upgrade nginx 会把你的修改冲掉。

把「为什么被拒」变成可重复的排查

权限问题的难点不在于「不知道有什么机制」,而在于「不知道是哪一层拒的」。我现在的习惯是固定四步,每一步都能排除一层:

第一步,确认报错的进程是谁。 同一个文件,「我登录后用 cat 能读」不等于「PHP 能读」。用 sudo -u www-data 真实模拟:

# 最有效的单条命令:以目标用户身份试访问
sudo -u www-data test -r /var/www/site/uploads/1.jpg && echo "可读" || echo "不可读"
sudo -u www-data test -w /var/www/site/uploads && echo "可写" || echo "不可写"

这一步就能区分「是权限问题还是应用逻辑问题」。我见过好几回,最后发现是 PHP 的 open_basedir 限制了目录,跟文件权限毫无关系。

第二步,看完整的权限栈。 一条命令把五层全打印出来:

p=/var/www/site/uploads
echo "路径中的每个目录权限:"
namei -l "$p"
echo; echo "目标本身:"
stat -c '%a %A %U:%G %n' "$p"
echo; echo "ACL(有 + 号才有内容):"
getfacl -p "$p" 2>/dev/null
echo; echo "扩展属性(需要 i/a 位):"
lsattr -d "$p"
echo; echo "挂载选项(noexec/nosuid 会影响执行):"
findmnt -no TARGET,FSTYPE,OPTIONS -T "$p"

namei -l 是这里面最有价值的一条,它会逐级列出路径上每个目录的权限。因为「文件权限对、目录权限错」是最常见的坑:访问 /var/www/site/uploads/1.jpg 需要路径上每一级目录都有 x(进入)权限,只要中间有一级缺 x,哪怕最后的文件是 777 也读不到。这个错误只有 namei 能一眼看出来。

第三步,看内核有没有拦截。 dmesg | grep -iE 'apparmor|selinux|denied',以及 /var/log/audit/audit.log(如果开了 auditd)。

第四步,看应用自己的日志。 Nginx 的 error.log 里,Permission denied 通常紧跟一个 stat() 或 open() 失败的行,那是最后一层,前面的都该已排除。PHP 则是看 error_log 里的 failed to open stream。

网站目录的一套可抄实践的权限基线

把这些机制拼起来,给一个网站目录的标准配置。目标是:deploy 能写,www-data 能读,其他人什么都不能,新建文件自动继承,PHP 文件不能被当成脚本执行的地方不给执行位。

# 1) 属主与组
chown -R deploy:www /var/www/site

# 2) 目录 2755(带 setgid,新文件自动属于 www 组)
find /var/www/site -type d -exec chmod 2755 {} +

# 3) 普通文件 644,PHP 等不需要执行的文件一律不带 x
find /var/www/site -type f -exec chmod 644 {} +

# 4) 上传目录需要可写,但绝不能执行
chmod 2775 /var/www/site/uploads
# Nginx 侧配合(比 chmod 更彻底的防线):
#   location ~* ^/uploads/.*\.php$ { deny all; }

# 5) 敏感目录(配置、日志)收紧
chmod 750 /var/www/site/config
chmod 640 /var/www/site/config/*.php

# 6) 备份现有 ACL 后再上 ACL 授权
getfacl -R -p /var/www/site > /root/acl-$(date +%F).txt
setfacl -R -m u:www-data:rX /var/www/site
setfacl -R -d -m u:www-data:rX /var/www/site

关于这套基线,有三点需要说明:

为什么目录是 2755 而不是 2775? 因为目录的 w 位意味着「能在这个目录里创建/删除文件」,给组 w 就等于任何 www 组成员都能删文件。如果 www-data 在 www 组里(很多系统上确实如此),2775 会让 PHP 进程拥有删除整个站点文件的能力——一旦被上传马,损失会放大很多。给 deploy 写权限用 ACL 精确控制比给整组强得多。

为什么不在 uploads 上直接用 777? 777 意味着任何本地用户都能写入可执行文件,只要 Nginx 配置有一处允许执行,就是完整的上传漏洞。上面「2775 + Nginx 拒绝对 uploads 里的 PHP」这个组合,比单靠 chmod 安全一个数量级。

为什么 setgid 比 ACL 更值得优先考虑? 因为 setgid 是「新建文件自动继承属组」,属于结构设计;ACL 是「给某个特定用户额外授权」,属于补丁。先用 setgid 把组的结构搭对,剩下的个别需求才用 ACL 补。反过来做,就会积累一堆互相覆盖的 ACL 条目,最后没人搞得清哪个生效。

三条踩过坑之后的规矩

规矩一:先 namei -l,后 chmod。 我给自己定了硬规定:任何权限问题,第一条命令必须是 namei -l 加 sudo -u 模拟,绝不上来就改权限。因为权限问题的本质是「哪一层拒的」,不是「权限不够大」。chmod 777 之所以能「解决」问题,往往只是因为顺手把 ACL 的 mask 也改了、或者把某级目录的 x 位补上了,但你并不知道自己改了什么,下一个问题会更难查。

规矩二:改权限之前备份权限。 两条命令,五秒钟,能省下几个小时:

getfacl -R -p /var/www/site > /root/acl-$(date +%F-%H%M).txt
find /var/www/site -printf '%m %u:%g %p\n' > /root/perm-$(date +%F-%H%M).txt

我曾经在一个客户机器上执行了一个 chmod -R 755 之后,才发现有一批 PHP session 目录本来是 700,被改成 755 后 session 文件被同机的其他虚拟主机读到了。当时没有备份,只能凭记忆一个个恢复,最后是靠 dpkg --verify 和 git status 才把大部分找回来。

规矩三:定期检查「不该有的东西」。 权限配置是一次性的,但权限漂移是持续的——某次 cp -p、某次 rsync -a、某次 tar -x,都会把错误的东西带进来。所以我每周跑一次这个检查,输出为空才安心:

#!/bin/bash
# permcheck.sh —— 查出网站目录里的三类异常
cd /var/www/site || exit 1

echo "== 1. 世界可写的文件(888)=="
find . -type f -perm -o+w -not -path './uploads/*' | head -20

echo "== 2. 带有 SUID 或 SGID 的文件(网站目录绝不该有)=="
find . -type f -perm /6000 | head -20

echo "== 3. 设定 setuid 的目录但丢失了 setgid 的目录(应为 2755)=="
find . -type d ! -perm -2000 | head -20

echo "== 4. uploads 里出现可执行文件(重点怀疑)=="
find ./uploads -type f -perm -o+x | head -20

echo "== 5. 带 ACL 的文件数量(突变说明有人动过)=="
find . -type f -exec getfacl -R -p {} + 2>/dev/null | grep -c '^user:' 

最后一条之所以要数 ACL 条目,是因为 ACL 是「看不见的权限」。我用这套检查抓到过一次真实事故:某个插件在安装时给整个 uploads 目录加了 user:www-data:rwx 的默认 ACL,导致之后所有上传文件都对 www-data 完全开放可执行。数量突然从 0 跳到 3000+,一眼就能看出来。

权限这件事的难处,从来不是知道有几种机制,而是知道此刻是哪一种在起作用。五层机制里任何一层都能给出 Permission denied,而它们留下的痕迹完全不同:传统权限看 namei、umask 只管新建、ACL 看那个 + 号、chattr 看 lsattr、AppArmor 看 dmesg。把这五条命令按顺序跑一遍,几乎不需要猜。

Last modification:October 24th, 2026 at 01:26 pm

Leave a Comment