为什么 root 权限也不该"想干什么就干什么"
Linux 的权限模型有个根本性的问题:它把世界分成两类人——root 和不是 root。不是 root 的人处处受限,而 root 一旦拿到,就是无所不能。可以读 /etc/shadow,可以改 /etc/passwd,可以写任何文件,可以加载内核模块,可以监听任意端口。
这个模型在单人运维的年代勉强能用,但今天的问题在于:你的服务器上有一大堆以 root 或高权限运行的进程。nginx 的 master 进程是 root(因为它要绑 80/443),php-fpm 池的主进程也常常是 root,mysql 是 mysql 用户但能读写整个数据目录。这些进程每天都在解析来自互联网的、完全不可信的输入。
一旦其中任何一个被攻破——一个 PHP 反序列化漏洞、一个 nginx 解析漏洞、一个框架的任意文件写入——攻击者拿到的就是那个进程的权限。而如果那个权限接近 root,他下一步就是写持久化后门、横向移动、把你的服务器变成矿机或者肉鸡。
SELinux 和 AppArmor 要解决的,就是把"root 无所不能"这个假设打破。它们的核心思想是:即使你已经是 root,我也只允许你做我明确写进规则里的那些事,其余一律拒绝。
先理解 DAC,才能理解 MAC 在做什么
Linux 传统的权限检查叫 DAC(Discretionary Access Control,自主访问控制)——文件名里的 rwx、属主属组,本质上是"文件的主人决定谁能访问"。关键缺陷是:root 可以无视这一切(内核检查时对 root 有专门的短路逻辑)。
而 SELinux 和 AppArmor 属于 MAC(Mandatory Access Control,强制访问控制):内核在 DAC 检查之后再走一遍 MAC 检查,而 MAC 规则由系统管理员制定,普通进程无法自己放宽,root 也不行。也就是说,一个文件能不能被访问,取决于两把锁:
DAC 检查(传统的 rwx / 属主) ─── 通过 ───▶ MAC 检查(SELinux / AppArmor) ─── 通过 ───▶ 真正访问
│ │
└── 拒绝 ──▶ EACCES └── 拒绝 ──▶ EACCES这就是它和 chmod/chown 的根本区别:chmod 是文件主人能改的,MAC 只有管理员能改。攻击者即使拿到了 root,也无法通过 chmod 777 来绕过 MAC——因为 chmod 影响的是 DAC 那一层,而 MAC 那一层的判定条件跟他改的权限位毫无关系。
SELinux 与 AppArmor:两条完全不同的路
这两个东西经常被放在一起提,但它们的设计哲学截然不同,选哪个取决于你的发行版和你的耐心。
SELinux:给每个文件、每个进程打标签
RHEL/CentOS/Fedora 系默认用 SELinux。它的模型是标签(label):每个文件、每个进程、每个端口都有一个安全上下文,形如 system_u:object_r:httpd_sys_content_t:s0。规则(policy)定义的是"哪个标签的进程可以访问哪个标签的资源"。比如 nginx 进程被标记为 httpd_t,web 目录被标记为 httpd_sys_content_t,规则就允许 httpd_t 读该目录。
SELinux 的优点是粒度极细,极其严密;缺点是学习曲线陡峭,一个标签打错就全家不能访问,而且报错信息晦涩(Permission denied 但 ls -l 权限明明是对的,这是 SELinux 最经典的坑)。
AppArmor:给每个程序一份路径白名单
Debian/Ubuntu/SUSE 默认用 AppArmor。它的模型是路径:规则直接写"这个程序可以读 /etc/nginx/**,可以写 /var/log/nginx/**",用人类可读的路径而不是抽象的标签。它的哲学是每个应用一份profile,没写profile的程序不受限制。
AppArmor 的优点是直观、好上手、排错简单(日志直接告诉你哪个路径被拒绝了);缺点是没有 SELinux 那么彻底——如果一个程序没被profile覆盖,它就完全不受约束。
怎么选
- 用 Debian / Ubuntu 做网站服务器 → AppArmor,开箱即用,几乎零成本。
- 用 RHEL / CentOS / Alibaba Cloud Linux → SELinux,默认就是 enforcing,只要不手贱关掉就行。
- 想做最严密的隔离(金融、合规场景)→ SELinux,值得花时间。
- 只想快速给"网站上跑的进程"套一层笼子 → AppArmor,一小时能见效。
下面我把两条路都讲透,你可以只挑自己需要的部分看。
AppArmor 实战:给 php-fpm 套上笼子
先看当前状态。这一步很重要,很多时候你以为 AppArmor 在保护你,其实它是 disabled 的。
apt-get install -y apparmor apparmor-utils
aa-status
# 关键看三行:
# apparmor module is loaded. ← 模块加载了
# 12 profiles are loaded. ← 有几个profile
# 10 profiles are in enforce mode. ← 有几个在强制模式(这个数字才是有效的)
# 0 processes are unconfined but have a profile defined.如果显示 apparmor module is loaded 但是 0 profiles are in enforce mode,说明装了但没启用。检查内核是否支持:
cat /sys/module/apparmor/parameters/enabled
# 期望输出 Y
# 如果内核启动参数里带了 apparmor=0 或者根本没编进去,需要改 GRUB 后重启现在给 php-fpm 生成profile。务必从 complain(投诉)模式开始,这个模式下 AppArmor 只记录违规不拦截,是最安全的起点。
# 1. 先生成 profile 骨架
aa-genprof /usr/sbin/php-fpm8.2
# 它会说 "Setting /usr/sbin/php-fpm8.2 to complain mode",
# 然后让你在另一个终端里"对应用做典型操作"。
# 2. 另一个终端里,做这些事触发所有可能的访问路径:
# - 访问网站首页、文章页、后台
# - 上传一张图片(走上传目录)
# - 触发一次缓存写入
# - 让一个 PHP 脚本发一封邮件 / 连一次 MySQL / 连一次 Redis
# - 手动重启一次 php-fpm 服务
# - 等一次 cron 跑(比如站点地图生成、日志清理)
# 3. 回到 aa-genprof 那个终端按 S(扫描)然后 F(完成)生成完的profile会被写到 /etc/apparmor.d/usr.sbin.php-fpm8.2。不要盲目相信自动生成的规则——它在学习期间记录到的一切都会放行,包括攻击者在那个时间窗内做的操作。所以自动生成之后必须人工审一遍,把明显不该有的权限删掉,并加上显式的 deny 规则。
下面是我线上 php-fpm 的profile关键片段(精简过),重点看 deny 部分:
#include <tunables/global>
/usr/sbin/php-fpm8.2 {
#include <abstractions/base>
#include <abstractions/nameservice>
# --- 必须放行的基础路径 ---
/usr/sbin/php-fpm8.2 mr,
/etc/php/** r,
/usr/lib/php/** r,
/var/lib/php/sessions/** rwk,
/run/php/** rw,
# --- 站点目录:代码只读,runtime 可写 ---
/www/wwwroot/** r,
/www/wwwroot/*/runtime/** rwk,
/www/wwwroot/*/uploads/** rwk,
/www/wwwroot/*/data/cache/** rwk, # 视框架而定
/www/wwwroot/*/logs/** rw,
# --- 网络能力:只允许连本机数据库与缓存 ---
network tcp,
network unix,
# === 关键:显式拒绝,任何情况都不放行 ===
# 1) 代码目录里的 php 文件不可被 php-fpm 写入 → 写 webshell 直接失败
deny /www/wwwroot/**/*.php w,
deny /www/wwwroot/**/*.php c,
# 2) 系统凭据一律不可读
deny /etc/shadow r,
deny /etc/gshadow r,
deny /etc/sudoers r,
deny /etc/sudoers.d/** r,
deny /root/** rwkl,
# 3) 不可写系统配置(阻止持久化)
deny /etc/passwd w,
deny /etc/crontab w,
deny /etc/cron.d/** w,
deny /etc/systemd/** w,
deny /etc/ssh/** w,
deny /root/.ssh/** rwkl,
# 4) 不允许加载内核模块 / 原始设备访问
deny /boot/** rw,
deny /lib/modules/** rw,
deny /dev/sd* rw,
}这份规则里有几条是真正的护身符,值得单独说明:
deny /www/wwwroot/**/*.php w—— 这条让 php-fpm 进程永远无法创建或修改 PHP 文件。攻击者就算通过漏洞拿到了代码执行能力,他也写不出 webshell。注意是w(write)不是r,php 当然要能读代码才能执行。deny /root/.ssh/**—— 阻止攻击者往authorized_keys里塞自己的公钥(最常见的持久化手法之一)。deny /etc/cron.d/** w—— 阻止写定时任务反弹 shell。deny /etc/shadow r—— 阻止拖取密码哈希去离线爆破。
切到强制模式:
aa-enforce /usr/sbin/php-fpm8.2
aa-status | grep -A2 "enforce mode"切换后立刻做三件事验证:访问网站首页、访问一篇具体文章、走一遍后台登录。如果任何一个白屏或者 500,马上去看日志:
dmesg | grep -i 'apparmor="DENIED"' | tail -20
journalctl -k | grep -i 'apparmor="DENIED"' | tail -20
journalctl -u php8.2-fpm -n 50 --no-pager每条 DENIED 都会明确写出 profile=...、operation=...、requested_mask=...、name=...,照着补规则即可。临时应急可以 aa-complain 切回投诉模式,但不要长期停在投诉模式——那就等于没有保护。
SELinux 实战:RHEL 系该不该关掉
如果你用 CentOS / Alibaba Cloud Linux / RHEL,第一个建议是:不要关 SELinux。网上大量教程让你 setenforce 0 + 改 /etc/selinux/config 改成 disabled,理由是"省事"。这是一笔非常糟糕的交易——你用一个下午的排查成本,换掉了一层最强的防线,而且关掉之后几乎所有加固文档都默认你开着 SELinux,你的安全基线直接塌了一半。
先看状态:
getenforce
# Enforcing / Permissive / Disabled
sestatus
# SELinux status: enabled
# SELinuxfs mount: /sys/fs/selinux
# Current mode: enforcing
# Policy: targeted
# Loaded policy name: targeted
# Mounted filesystem modes: ...
# Policy deny_unknown_statuses: on
# Max kernel policy version: 33正确做法是留在 enforcing 模式,遇到问题用工具解决。最常用的三条命令:
# 1. 看某个文件当前的安全上下文(标签)
ls -Z /www/wwwroot/index.php
# -rw-r--r--. root root system_u:object_r:httpd_sys_content_t:s0 index.php
# 2. 看某个进程的上下文
ps -eZ | grep nginx
# system_u:system_r:httpd_t:s0 1234 ? 00:00:00 nginx
# 3. 修正文件的标签(最常见的修复动作)
# 网站文件放到了非标准路径(比如 /www 而不是 /var/www),
# 标签就会是默认的 default_t,SELinux 不允许 httpd_t 读它 → 403
# 用 restorecon 按 /etc/selinux/targeted/contexts/files/file_contexts 修正
restorecon -Rv /www/wwwrootrestorecon 是解决 SELinux "莫名 403" 的第一把钥匙。典型场景:你把网站从 /var/www/html 挪到了自己的 /www/wwwroot 目录,nginx 配置检查全过、文件权限 ls -l 完全正常,但浏览器一直 403。原因就是 /www 目录下的文件标签是 default_t,而不是 httpd_sys_content_t,SELinux 直接拒绝了 nginx 的读取,而这个拒绝不会出现在 nginx 的 error.log 里(它显示为 Permission denied,跟普通的权限不足长得一模一样)。
如果 restorecon 之后标签还是不对,说明策略里没有这个路径的规则,需要手动加:
# 把 /www/wwwroot 永久标记为 web 内容目录
semanage fcontext -a -t httpd_sys_content_t "/www/wwwroot(/.*)?"
restorecon -Rv /www/wwwroot
# 上传目录需要写权限,标签要用 rw 版本
semanage fcontext -a -t httpd_sys_rw_content_t "/www/wwwroot/.*/uploads(/.*)?"
restorecon -Rv /www/wwwroot注意 httpd_sys_content_t(只读)和 httpd_sys_rw_content_t(可读可写)的区别。代码目录用只读标签,上传和缓存目录才用 rw 标签——这本身就是一层防护:即使攻击者通过 PHP 漏洞想写 webshell,他也写不进被标为只读的代码目录。
查拒绝记录一定要用 ausearch,不要只看系统日志:
# 查最近的 SELinux 拒绝事件
ausearch -m AVC,USER_AVC -ts recent
# 只看今天 web 相关的拒绝
ausearch -m AVC -ts today | grep httpd
# 把最近所有拒绝打包成可读报告(生成新策略模块的最小集)
ausearch -m AVC -ts today | audit2allow -m local_httpd
# 自动化地把拒绝都放行(生成并加载模块)
ausearch -m AVC -ts today | audit2allow -M local_httpd
semodule -i local_httpd.pp警告:audit2allow -M 是把被拒绝的操作全部放行,这是把 SELinux 的锁一个个拆掉,非常危险。正确姿势是先用 audit2allow -m(不带 -M)看看它想放行什么,逐条判断是否合理。绝大多数真实场景根本不需要这样做——restorecon 加正确的 semanage fcontext 就能解决 95% 的问题。
另外,不要用 setenforce 0 做长期方案。可以用它做临时排查(判断问题是不是 SELinux 引起的),但排查完必须立刻 setenforce 1。setenforce 0 是运行时的模式切换,重启后失效;如果你为了"一劳永逸"去改 /etc/selinux/config 改成 disabled,那是永久性降级,且重新启用要通过 relabel 整个文件系统(touch /.autorelabel && reboot),代价很大。
一个真实的效果对比:同一个漏洞,两种结局
我用一个可控的实验说明 MAC 的价值。假设站点上有一个"任意文件写入"漏洞,攻击者能让 PHP 往任意路径写内容。实验分两组。
对照组(无 MAC):php-fpm 以 www 用户运行,DAC 权限是 /www/wwwroot 属主为 www。攻击者利用漏洞往 /www/wwwroot/shell.php 写了一个 webshell → 访问它 → 执行 system("id") → 成功。接着往 /root/.ssh/authorized_keys 写公钥(如果 www 用户有 sudo 或者找到了提权路径)→ 持久化成功。整个过程服务器没有任何告警,因为每一步都是"权限允许"的正常文件操作。
实验组(AppArmor enforce,profile含 deny /www/wwwroot/**/*.php w):同样的漏洞触发 → PHP 尝试写入 shell.php → 内核在 MAC 层检查 → EACCES → 写入失败。dmesg 里出现一条明确的 DENIED 记录,我们的 FIM 脚本立刻推送告警。攻击者退而求其次,尝试写 /tmp/evil.php → deny /tmp/** w(视profile而定)→ 同样失败。最终他什么也没拿到。
关键在于:对照组里文件的权限完全正确(该只读的还是只读),但因为 php-fpm 的运行身份就是文件属主,DAC 层面天然允许自己写自己。而 MAC 不看你是什么身份,只看规则怎么写——这正是它比 chmod 强的地方。
落地节奏:别一次上全套
MAC 的配置是新系统里最容易"配完就崩"的部分,所以我给一条分阶段的落地路线:
- 第一周:只在测试机或一台边缘服务器上开启。选一个非核心服务(比如一个静态站或一个内部工具)先练手。
- 第二周:用 complain 模式给全部 Web 进程生成profile,让它自己跑满 7 天。这 7 天里要覆盖:日常访问、后台操作、文件上传、证书续期(certbot 会写
/etc/letsencrypt)、日志切割(logrotate 会mv日志文件)、备份脚本(可能要读/var/lib/mysql)、cron 任务。任何漏掉的路径在 enforce 时都会变成故障。 - 第三周:人工审查 profile,删掉不该有的权限,加上那几条关键
deny(写 php、读 shadow、写 crontab、读 /root/.ssh),然后切 enforce。 - 第四周:把 MAC 的拒绝日志接到你的告警系统。AppArmor 用
journalctl -k | grep DENIED或aa-notify;SELinux 用ausearch加定时任务。拒绝事件本身就是重要的安全信号——正常业务不该频繁触发 DENIED,一旦触发往往意味着有人在越界。
还有三条我踩过的坑,直接抄走:
- 证书续期是最大的坑。certbot 会在续期时写
/etc/letsencrypt、重启服务、可能还要 exec hook。complain 模式如果没有覆盖到续期那个时间点,enforce 之后你的证书会在某天凌晨静默续期失败,然后三个月后全站 HTTPS 崩掉。所以一定要手动certbot renew --dry-run一次来触发路径学习。 - logrotate 的
create指令需要程序能重建日志文件。如果只给了w没给c(create),日志切割之后程序就会写不进去然后报错。profile 里日志目录一般要rwkl或者至少rwc。 - 不要在 enforce 之后立刻重启服务器。有些路径只有在服务的某个特定启动顺序下才会被访问,重启会暴露一堆之前 complain 期间没学到的路径。切 enforce 之后先观察一整天再安排重启。
MAC 这种东西,配好之后是"无感"的——你不会因为它而觉得网站快了一毫秒,也不会因为它而多了一条收录。但在服务器真的被攻破的那一天,它是唯一能保证"攻击者拿到了权限却什么都做不了"的东西。root 权限不该等于上帝权限,这句话值得每个自己管服务器的人记在心里。