SELinux 与 AppArmor 强制访问控制实战:让 root 也不能随便改你的网站文件

为什么 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 deniedls -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/wwwroot

restorecon 是解决 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 1setenforce 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.phpdeny /tmp/** w(视profile而定)→ 同样失败。最终他什么也没拿到。

关键在于:对照组里文件的权限完全正确(该只读的还是只读),但因为 php-fpm 的运行身份就是文件属主,DAC 层面天然允许自己写自己。而 MAC 不看你是什么身份,只看规则怎么写——这正是它比 chmod 强的地方。

落地节奏:别一次上全套

MAC 的配置是新系统里最容易"配完就崩"的部分,所以我给一条分阶段的落地路线:

  1. 第一周:只在测试机或一台边缘服务器上开启。选一个非核心服务(比如一个静态站或一个内部工具)先练手。
  2. 第二周:用 complain 模式给全部 Web 进程生成profile,让它自己跑满 7 天。这 7 天里要覆盖:日常访问、后台操作、文件上传、证书续期(certbot 会写 /etc/letsencrypt)、日志切割(logrotate 会 mv 日志文件)、备份脚本(可能要读 /var/lib/mysql)、cron 任务。任何漏掉的路径在 enforce 时都会变成故障。
  3. 第三周:人工审查 profile,删掉不该有的权限,加上那几条关键 deny(写 php、读 shadow、写 crontab、读 /root/.ssh),然后切 enforce。
  4. 第四周:把 MAC 的拒绝日志接到你的告警系统。AppArmor 用 journalctl -k | grep DENIEDaa-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 权限不该等于上帝权限,这句话值得每个自己管服务器的人记在心里。

Last modification:September 21st, 2026 at 07:25 pm

Leave a Comment