Linux 强制访问控制实战:SELinux 与 AppArmor 到底拦了你什么,怎么查怎么放行

你 chmod 755 了、属主也对了、防火墙也开了,服务就是起不来、Nginx 就是读不到文件、PHP 就是写不进去。这时候十有八九是强制访问控制(MAC)在背后拦你。SELinux(RedHat 系)和 AppArmor(Debian/Ubuntu 系)平时沉默寡言,一旦你已经给了权限却依然被拒,它们就是头号嫌疑人。这篇文章讲清它们到底在管什么、怎么查是谁拦的、以及怎么安全地放行——而不是一遇到问题就 setenforce 0 或 aa-complain。

一、DAC 与 MAC:两层完全不同的权限
先建立正确的心理模型。传统的 chmod/chown 属于 DAC(自主访问控制):文件属主自己决定谁能访问。它的漏洞是「属主说了算」——一旦某个进程被拿下,它就能访问该用户能碰的一切。
MAC(强制访问控制)在 DAC 之上再加一层:即使 root 也逃不过策略的约束。SELinux 的核心思想是给每个进程和每个文件打标签,只有标签匹配、策略允许的访问才放行。这就是为什么会出现「root 也读不了」的诡异现象——它拦的不是用户身份,而是进程的域(domain)能不能访问某类标签的资源。

二、SELinux:三种模式与标签体系
查看当前状态:
getenforce

Enforcing / Permissive / Disabled

sestatus

Enforcing:真拦,拒绝即失败。
Permissive:只记日志不拦。这是排错的神器——你不会破坏生产,但能看到「如果开 Enforcing 会拦什么」。
Disabled:完全关闭。注意从 Disabled 切回 Enforcing 会对整个文件系统做 relabel,大站点可能耗时很久,别在业务高峰干这事。

标签长这样:system_u:object_r:httpd_sys_content_t:s0。关键的第三段是 type,策略就是按 type 判断的。查看文件标签:
ls -Z /var/www/html/index.html
ps -eZ | grep nginx

进程有域(如 httpd_t),文件有类型(如 httpd_sys_content_t)。策略规定「域 httpd_t 可以读 httpd_sys_content_t」。你把网站放进 /home/site,那里文件的默认类型是 user_home_t,与 httpd_t 不匹配,于是 403。这就是最常见的「权限明明对却读不到」。

三、SELinux 排错三板斧
第一招:看日志。拒绝事件记录在 audit 日志:

关键词 avc: denied

grep "avc: denied" /var/log/audit/audit.log | tail

或者用 ausearch(更好读)

ausearch -m avc -ts recent

一条典型记录会告诉你 scontext(源域)、tcontext(目标标签)、tclass(对象类别)、以及具体 { read write } 里的哪个权限被拒。读懂了它,你就知道了「谁被谁拦了什么」。
第二招:用工具给出修复建议。别自己瞎猜命令,用:
ausearch -m avc -ts recent | audit2why
ausearch -m avc -ts recent | audit2allow -M myfix

audit2why 用人话解释原因,audit2allow 生成一条自定义策略模块。但强烈建议先别照单全收——audit2allow 的原则是「你要什么我给什么」,很容易把你引向放权过宽的歧路,正确做法经常是换标签而不是加策略。
第三招:改标签而不是关 SELinux。最常见、最安全的修法是让文件带上正确的类型:

临时改(立即生效,restorecon 后会被还原)

chcon -t httpd_sys_content_t /home/site/index.html

永久改:定义规则,再 restorecon

semanage fcontext -a -t httpd_sys_content_t "/home/site(/.*)?"
restorecon -Rv /home/site

记住这个区别:chcon 是临时动作,文件系统 relabel 时会被冲掉;semanage fcontext + restorecon 才是持久的正确姿势。这也是区分「会用」和「乱用」的关键。
如果是网络端口问题(比如让 Nginx 监听 8080),SELinux 会拦端口绑定:
semanage port -a -t http_port_t -p tcp 8080
semanage port -l | grep http_port

四、AppArmor:Debian/Ubuntu 系的另一套思路
AppArmor 不做全系统标签,而是按程序路径给单个应用套一份「profile」,profile 里列出该程序能读哪些路径、能执行什么。它更简单直观,但覆盖范围窄——只有被 profile 约束的程序才受管。Ubuntu 上 Nginx、PHP-FPM、MySQL 默认都有 profile。
查看与状态:
aa-status

或

apparmor_status

profile 文件在 /etc/apparmor.d/,比如 usr.sbin.nginx。被拒事件同样进 audit 日志,关键词是 apparmor="DENIED":
grep "apparmor="DENIED"" /var/log/syslog | tail
journalctl -k | grep apparmor | tail

日志会给出 profile、operation(如 open)、name(被访问的路径)、requested_mask。读懂它你就知道哪条规则缺了。

五、AppArmor 排错的正确顺序
和 SELinux 一样,别一上来就 aa-complain。正确顺序:

先确认是不是它。 临时把出问题的 profile 切到 complain 模式(只记录不拦),观察问题是否消失:aa-complain /etc/apparmor.d/usr.sbin.nginx。若消失,坐实是 AppArmor。
看 DENIED 日志定位缺的规则。 用 name= 和 requested_mask= 判断缺的是读、写还是执行权限。
精确加规则,别放行整棵树。 编辑 profile,或在 /etc/apparmor.d/local/ 里加局部覆盖(推荐后者,升级时更不容易冲突),例如:
/var/www/example.com/uploads/** rwk,

只放行必要的路径与权限,而不是 /** rwkl, 这种等于关掉沙箱的写法。
重载并回到 enforce。 apparmor_parser -r /etc/apparmor.d/usr.sbin.nginx,再 aa-enforce,最后 aa-status 确认回到 enforce。

六、最危险的三种「省事」做法

setenforce 0 / SELINUX=disabled:等于把一层安全边界整个拆掉。被入侵时,SELinux 能从「服务被拿下」兜到「只被困在 httpd_t 域里」;关掉后就是裸奔。
audit2allow -M 一把梭:把审计到的每一条拒绝都变成允许,久而久之策略形同虚设。
aa-complain 长期挂着:以为是「观察期」,结果是永远没开防护,profile 白装。

正确的心态是:MAC 拦截不是「麻烦」,而是「最后一道兜底」。被拦时先问「我是不是把东西放错位置了」,而正确解通常是换标签/加精确规则,而不是关掉它。

七、一份日常自查清单

新站点目录放在非标准路径(/home、/data)时,先想 SELinux 标签 / AppArmor profile 是否需要同步调整。
让服务监听非标准端口,记得 semanage port -a。
部署脚本里不要写 setenforce 0,要写 semanage fcontext + restorecon。
定期 aa-status / getenforce 抽查,确认生产机器没有被「临时关闭」后忘了恢复。
升级发行版后,AppArmor profile 可能被覆盖,/etc/apparmor.d/local/ 里的自定义规则是你的保险。

把 SELinux/AppArmor 从「我要关掉的东西」变成「我会查会放行的东西」,是每个站长从「能跑起来」走向「跑得安全」的必经一步。它也许不会让你今天少写一行代码,但会在某一天帮你把一次入侵的损失,从「整个服务器」缩小到「一个被隔离的进程」。

八、一个完整案例:让 Nginx 能读 /data 下的站点
把抽象落到具体。假设你把网站放在 /data/www/example.com,Nginx 访问时报 403,权限明明是 755、属主也是对的。排查顺序如下:

先确认是 SELinux。 临时 setenforce 0,若 403 立刻消失,就是它(记得马上 setenforce 1 切回,只在排错窗口用)。
看审计日志确认标签不匹配。 ausearch -m avc -ts recent | audit2why,通常提示 httpd_t 无法访问 default_t 或 user_home_t。
查当前标签。 ls -Z /data/www/example.com,确认第三段不是 httpd_sys_content_t。
持久地改标签。
semanage fcontext -a -t httpd_sys_content_t "/data/www/example.com(/.*)?"
restorecon -Rv /data/www/example.com
ls -Z /data/www/example.com

若还要写权限(比如上传目录),用 httpd_sys_rw_content_t 而非 httpd_sys_content_t——只读类型给了写,照样被拒。
再验一次。 重新请求页面,403 消失,audit.log 里不再新增对应 DENIED。

同一条链路在 AppArmor 上的对应做法是:查 apparmor="DENIED" 日志看到的 name=/data/www/.../index.html,然后在 /etc/apparmor.d/local/usr.sbin.nginx 里加 /data/www/** r,(需要写才加 w),重载 profile。

九、布尔值:那些「策略开关」
SELinux 有一批预置的布尔开关,用来快速调整策略行为,比手写策略方便得多。查看全部:
getsebool -a | grep httpd
semanage boolean -l | grep httpd

几个常被用到的:httpd_can_network_connect(允许 Nginx 反代到本机其他端口或外网)、httpd_read_user_content(允许读用户家目录内容)、httpd_can_sendmail(允许 PHP 发信)。临时开关用 setsebool httpd_can_network_connect on,持久化要加 -P:
setsebool -P httpd_can_network_connect on

很多「反代突然 502」的问题,最后发现就是 SELinux 默认不允许 httpd_t 发起网络连接,开一个布尔值即可,比写自定义策略干净得多。优先用布尔值,其次才考虑自定义策略。

十、AppArmor 的 profile 结构速览
一个 profile 大致长这样,理解它能让你改规则时心里有数:

include <tunables/global>

/usr/sbin/nginx {
#include <abstractions/base>
#include <abstractions/nameservice>

/etc/nginx/** r,
/var/log/nginx/*.log w,
/var/www/** r,
/usr/sbin/nginx mr,
# 网络能力
network inet stream,
}

r 读、w 写、m 可执行并映射(执行内存映射,程序本体通常需要)、k 锁、l 链接。
* 递归匹配子目录, 只匹配单层。写 /var/www/* 只放行第一层,子目录仍会被拒——这是新手最容易困惑的点。
把自定义规则放进 /etc/apparmor.d/local/ 下同名文件,升级时不会被覆盖,这是维护性最好的做法。
改完务必 apparmor_parser -r 文件 重载,并 aa-status 确认 profile 处于 enforce 而非 complain。

理解了这套结构,你就不会再「看到 DENIED 就整棵树全放行」,而是能精确地只补上真正缺的那一条。

(adsbygoogle = window.adsbygoogle || []).push({});

Last modification:October 3rd, 2026 at 07:28 pm

Leave a Comment