权限位之外的第二道门:为什么需要强制访问控制
Unix 的权限模型是自主访问控制(DAC):文件属主说了算,root 几乎全能。这套模型在「程序可能被打穿」的时代有明显缺口:一个以 root 跑的 web 服务被命令注入,攻击者拿到的就是完整 root;一个只该读 /var/www 的 nginx 进程被内存漏洞劫持,它可以读 /etc/shadow、连接任意端口、改任何它权限内的文件。强制访问控制(MAC)要补的就是这层:不管进程的 UID 是什么、文件的属主是谁,每个操作都要过一遍系统级策略的裁决——策略不允许的,root 也不行。Linux 内核为这类机制提供了统一的插入框架 LSM(Linux Security Modules):在系统调用的关键路径上预置了数百个钩子点(打开文件、建立连接、绑定端口、fork 进程……),安全模块在钩子里做额外裁决。SELinux 和 AppArmor 是这台机器上的两个主力实现——RHEL/CentOS/Fedora 默认 SELinux,Debian/Ubuntu/SUSE 默认 AppArmor。对应用工程师来说,这两套系统是「应用在新环境跑不起来」的高频元凶:换端口、换目录、容器里读宿主机文件,都可能被它们的策略拦下,而拦截的表现往往只是一句没有上下文的 "Permission denied"。
flowchart TD
A["进程发起系统调用br/open / connect / bind / execve ..."] --> B["常规 DAC 检查br/权限位 / 属主 / capability"]
B --> C["LSM 钩子点br/内核安全路径上的数百个插入点"]
C --> D["主安全模块裁决br/SELinux:安全标签规则匹配br/AppArmor:路径画像规则匹配"]
D -- "策略允许" --> E["系统调用继续执行"]
D -- "策略拒绝" --> F["返回 EACCES/EPERMbr/并写入一条 AVC/DENIED 审计记录"]
SELinux:万物有标签的世界
SELinux 的世界观是「给一切打标签,用标签间的规则定义允许的关系」。每个进程、每个文件、每个端口、每个套接字都有一个安全上下文,格式是 user:role:type:level 四段,其中起决定作用的是 type(类型):
$ ls -Z /var/www/html/index.html
unconfined_u:object_r:httpd_sys_content_t:s0 /var/www/html/index.html
$ ps -ZC nginx
LABEL PID COMMAND
system_u:system_r:httpd_t:s0 3231 nginx
$ ss -tlnpZ | grep :80
LISTEN 0 511 0.0.0.0:80 ... system_u:system_r:httpd_t:s0 nginx
文件是 httpd_sys_content_t 类型,nginx 进程运行在 httpd_t 类型域,80 端口带 http_port_t 标签。策略里写明「httpd_t 域可以对 httpd_sys_content_t 类型的文件做 read/getattr」「httpd_t 可以 bind http_port_t」——没有规则允许的操作一律拒绝(默认拒绝)。这套机制叫类型强制(Type Enforcement),是 SELinux 的核心。SELinux 有三种工作模式:
$ getenforce
Enforcing
$ sestatus | head -5
SELinux status: enabled
Loaded policy name: targeted
Current mode: enforcing
Mode from config file: enforcing
Policy MLS status: enabled
Enforcing 是真拦截;Permissive 只记审计不拦截(排错神器);Disabled 彻底关闭(重启用)。RHEL 默认的 targeted 策略只约束网络服务(httpd、sshd、mysqld 等),普通用户进程基本不受限——这个取舍让 SELinux 在服务器上「开着的代价」可控。对应用工程师,SELinux 最经典的翻车三连:换端口(nginx 监听 8443,被拒,因为 8443 不在 http_port_t 的端口集合里)、换目录(把 web 根从 /var/www/html 挪到 /data/www,被拒,因为新目录没有 httpd_sys_content_t 标签)、改默认运行路径(pidfile/socket 文件写到非标准位置,被拒)。三者的修复分别是 semanage port -a -t http_port_t -p tcp 8443、semanage fcontext -a -t httpd_sys_content_t "/data/www(/.*)?" && restorecon -Rv /data/www、以及对应类型的 fcontext 重打标。
AppArmor:给程序画一张路径画像
AppArmor 的思路与 SELinux 完全不同:它不做全局标签,而是按可执行文件路径维护「画像(profile)」,画像里写清这个程序允许访问哪些路径、允许哪些操作。画像长这样:
# /etc/apparmor.d/usr.sbin.nginx(节选)
#include tunables/global
/usr/sbin/nginx flags=(attach_disconnected) {
#include abstractions/base
#include abstractions/nginx
capability dac_override,
capability setgid,
/proc/*/environ r,
/etc/nginx/** r,
/var/log/nginx/*.log rw,
/run/nginx.pid rw,
/var/lib/nginx/** rw,
}
每个画像有 Enforce(真拦截)和 Complain(只记录)两种模式。AppArmor 的优势在「人类可读、路径直观」——看画像就知道程序被允许干什么,改策略就是改文本文件;代价是路径匹配的天生弱点:硬链接、改名、mount 逃逸都可能绕过路径语义。看一台 Ubuntu 机器的 AppArmor 状态:
$ sudo aa-status | head -12
apparmor module is loaded.
43 profiles are loaded.
41 profiles are in enforce mode.
/usr/bin/man
/usr/sbin/nginx
...
2 profiles are in complain mode.
0 processes have profiles defined.
AppArmor 拒绝时的痕迹在内核日志里,格式比 SELinux 的 AVC 更直白:
$ sudo dmesg | grep -i 'apparmor="DENIED"' | tail -2
audit: type=1400 audit(1772930000.1:50): apparmor="DENIED" operation="open"
profile="docker-default" name="/proc/9981/mem" pid=3231 comm="mytool"
requested_mask="r" denied_mask="r"
一条日志读出全部要素:谁(pid/comm)、在哪个画像里(profile)、想做什么操作(operation + requested_mask)、对哪个路径、被拒了什么。Ubuntu 上程序「莫名打不开文件」时先 grep 这一行,往往一步就破案。
容器:两个模块的现代化身
容器运行时是这两个安全模块如今最常见的载体。Docker 启动容器时,默认给容器进程套 AppArmor 画像 docker-default(限制 mount、ptrace、写 /proc、capable 检查等危险面),SELinux 主机上则默认打 container_file_t 标签并启用 svirt 隔离:
$ docker inspect myapp --format '{{.HostConfig.SecurityOpt}}'
[apparmor=docker-default]
$ docker run --rm -it --security-opt apparmor=unconfined ubuntu bash # 危险操作:裸奔
容器里的很多「玄学故障」就是画像或标签拦的:容器内 gdb/strace 不可用(docker-default 画像拒绝 ptrace,这与容器默认不给 CAP_SYS_PTRACE 是两道独立的锁);容器内读 /proc/kcore 或宿主机路径被拒;Kubernetes 上 kubectl debug 之外的自定义调试容器进程被拒。K8s 侧对应的字段是 securityContext.seLinuxOptions 与 1.30 起 GA 的 appArmorProfile:
securityContext:
seLinuxOptions:
type: spc_t # Super Privileged Container 场景
level: "s0:c123,c456"
appArmorProfile:
type: Localhost
localhostProfile: k8s-nginx-profile
值得强调的是 SELinux/AppArmor 与 seccomp 是三个正交层:seccomp 过滤「容器能用哪些系统调用」,AppArmor/SELinux 裁决「系统调用操作的对象是否在策略允许内」,capability 控制「进程能否执行特权操作」。生产容器三道锁都在工作,排错时按「seccomp(看报错是否 ENOSYS/EPERM on syscall)→ LSM(看 DENIED/AVC 日志)→ capability(看 cap 效位)」顺序排查。
排错实战:从一条拒绝日志到一次修复
把 SELinux 排错流程完整走一遍。症状:改了 nginx 监听端口为 8443,reload 后日志报 bind failed (13: Permission denied),但 ss 显示端口空闲、root 权限齐全、防火墙未拦。第一步确认有 MAC 层在拦:getenforce 为 Enforcing。第二步找审计记录:
$ sudo ausearch -m avc -ts recent | tail -8
----
type=AVC msg=audit(11/05/2026 11:40:22.510:1043): avc: denied { name_bind }
for pid=3231 comm="nginx" scontext=system_u:system_r:httpd_t:s0
tcontext=system_u:object_r:unlabeled_port_t:s0 tclass=tcp_socket
{ name_bind } 对 tcp_socket、目标上下文是 unlabeled_port_t——nginx(httpd_t 域)试图绑定一个没被划给任何服务类型的端口,策略默认拒绝,正是翻车三连的第一种。第三步选择修复:规范做法是把 8443 划入 httpd 可绑定端口集合 sudo semanage port -a -t http_port_t -p tcp 8443(若端口已有其他类型标签,用 -m 修改);临时排错则 setenforce 0 切 Permissive 验证判断(拒绝消失即坐实)。同类问题自动生成策略补丁的工具是 audit2allow(从 AVC 日志生成 allow 规则),但要慎用——它生成「放行一切被拒操作」的宽规则,正确姿势是先读懂拒绝原因再用类型化的规则修复。AppArmor 侧对应的工具是 aa-logprof:交互式回放 DENIED 日志,逐条决定放行/拒绝/通配,自动更新画像文件,apparmor_parser -r 重载。两个流程的共同心法:拒绝日志就是完整的案情报告,修复动作必须对齐业务语义(这个操作该不该允许),而不是无条件放行。
为什么你的应用会受这个影响
落到日常工作,有五条直接可用的准则。其一,迁移应用跨发行版时先问「MAC 开没开、开的是哪家」:getenforce 与 aa-status 是部署清单上的第一项检查,镜像在 Ubuntu 开发机上好好的、到 RHEL 生产机上起不来,八成是 SELinux 上下文问题——构建镜像时用标准路径(/var/www、/etc/nginx)、需要自定义路径时把 fcontext/semanage 命令写进部署脚本。其二,把「改端口/改目录前查标签」变成习惯:semanage port -l | grep 8443、ls -Z 各花三秒,能省掉一整轮「为什么 Permission denied 但权限明明没问题」的排查。其三,容器里关 MAC 等于拆保险,特例要走白名单:apparmor=unconfined 与 label:disable 只该出现在理解后果的调试场景;确需放宽(比如容器内需要 ptrace),用自定义画像放行特定操作,而不是整画像卸载。其四,Permissive 模式是上线前的体检工具:新服务先在 Permissive 跑一天,收集 AVC/ DENIED 日志,按业务语义修画像或打标,再切 Enforcing——这比线上撞墙后临时救火优雅得多。其五,别把 MAC 当累赘:一次真实的容器逃逸拦截、一次被 SELinux 挡住的 webshell 改文件,就是这套系统全年运维成本的全部回报;它拦的绝大多数不是你,而是打穿你的程序的攻击者。
SELinux 与 AppArmor 的策略编写实战
SELinux 的 Type Enforcement 核心:类型 = 进程域 + 文件类型——allow 规则的语法 allow httpd_t httpd_log_t:file { create write }——域的类型决定进程能做什么——策略模块的编译与加载(checkmodule → semodule_package → semodule -i)——audit2allow 工具从 audit 日志自动生成策略——audit2allow 的局限(生成的策略过于宽松——手工审阅是必须的。
AppArmor 的策略编写:路径-based 的规则(与 SELinux 的 label-based 不同——/usr/sbin/nginx r, /var/log/nginx/ rw,——profile 模式:enforce(强制)与 complain(仅记录违规——aa-complain / aa-enforce 切换——策略的生成(aa-genprof 交互式生成——AppArmor 的简洁:基于路径、无需重新打标签、Ubuntu 默认启用——AppArmor 的局限:路径可变(符号链接——路径不稳定是 AppArmor 的弱点。
容器的 SELinux/AppArmor
container-selinux 策略(container_runtime_t 域——容器进程的 SELinux 上下文(system_u:system_r:container_t:s0:c1,c2——MCS 分隔(Multi-Category Security——c1,c2 类别对实现容器间隔离——Docker/K8s 的 SELinux 集成(--security-opt label=disable 关闭——K8s 的 seccomp + AppArmor 注解——Pod Security Standards 中的 AppArmor 字段。
排查的实操
SELinux 拒绝事件的排查(ausearch -m avc 查找拒绝日志——setroubleshoot 工具(sealert -l 给出人类可读的解释——常见误报(NFS 上的文件没有 SELinux 标签——setsebool 布尔值的调整(httpd_can_network_connect 等常见布尔值——布尔值是 SELinux 策略的「开关」——无需修改策略模块即可调整行为。
审计与排障的实操
SELinux 的拒绝事件排查:ausearch -m avc 查找拒绝日志——sealert -l 事件ID 给出人类可读的解释——常见误报(NFS 上的文件没有 SELinux 标签——setsebool 布尔值的调整(httpd_can_network_connect 等常见布尔值——布尔值是 SELinux 策略的「开关」——无需修改策略模块即可调整行为。
AppArmor 的拒绝事件排查:dmesg | grep apparmor="DENIED"——journalctl 的 apparmor 模块——aa-logprof 交互式生成规则更新——「从日志到规则」的闭环。
生产环境的最佳实践
SELinux 的最佳实践:不要用 setenforce 0 关闭——用 permissive 模式调试——逐步收紧——audit2allow 生成的策略要手工审阅——最小化策略原则——只放行必要的权限——定期 audit 策略与实际的匹配度。
AppArmor 的最佳实践:所有长期运行的进程都应有 profile——新进程先用 complain 模式收集——稳定后切换到 enforce——定期 aa-logprof 更新规则——与 Docker 的 AppArmor profile 配合——使用 Docker 默认的 docker-default profile 或自定义——不要用 unconfined 运行容器。
SELinux 与 AppArmor 的选择取决于发行版——RHEL/CentOS/Fedora 默认 SELinux——Ubuntu/Debian 默认 AppArmor——但两者可以共存(不同进程使用不同 LSM)——关键是选择一个并坚持使用——不要因为复杂而关闭——「关闭安全模块」等于把内核的安全层拆掉——攻击面立即扩大。
小结
LSM 框架把强制访问控制做成了内核的插件位,SELinux 与 AppArmor 是这台机器上最重要的两个插件:前者以「万物标签 + 默认拒绝的类型强制」提供强隔离,后者以「按程序路径的画像文件」提供可读性优先的白名单,两者互斥地占据「主安全模块」位置,与 seccomp、capability 构成容器的三道正交防线。应用工程师需要带走的不是策略语法,而是三个排查反射:遇到说不清的 Permission denied,先确认 MAC 层状态,再读 AVC/DENIED 日志;改端口、改目录、容器内特调前,先查标签与画像;修复动作对齐业务语义,不用 audit2allow/宽通配图省事。安全模块的全部价值,在于让「程序被攻破后能做什么」的上限远低于程序本来的权限——理解它、顺应它,你的应用才是这台机器上一个守规矩的公民。
读者留言
COMMENTS 暂无还没有留言,来说第一句?