OpenVPN 中 chroot 失败:AppArmor 规则还是 capability?
在 AppArmor 配置文件里添加路径永远不会授予 CAP_SYS_CHROOT,而 chroot 被拒绝可能来自三个不同的层。判断该改哪一层。
守护进程记录了一次 chroot 失败,而配置文件看上去已经很宽松,于是诱人的做法是放宽某条路径规则然后重启。这通常什么也改变不了,因为 chroot 受一项配置文件从来无法交出的特权所限制。要判断该动哪一层,需要三项检查,而且都很便宜:进程被允许持有什么、策略允许什么,以及启动器在两者都还没起作用之前拿掉了什么。
两道彼此独立的闸门,只有一道属于 AppArmor
chroot 需要进程所在用户命名空间中的 CAP_SYS_CHROOT。这是 Linux 的常规特权,内核在调用对路径做任何事之前就会检查它。与之分离的是,处于 enforcing 模式的 AppArmor 配置文件即使在进程持有该 capability 时也可以拒绝它,因为强制策略是在凭据之外另行查问的,而不是取代凭据。这一区分在实践中很重要:两层位于不同的文件里,通常也由不同的人维护。
这两道闸门以同一个 errno 失败。两者都产生 EPERM,两者在守护进程日志里都表现为笼统的权限抱怨,这正是猜测配置文件内容会浪费那么多时间的原因。你原本要添加的路径规则控制的是 chroot 成功之后的文件访问,与这项操作是否被允许毫无关系。
第三道闸门位于两者之上。如果服务管理器或某个包装脚本启动进程时,其边界集合里没有这项 capability,那么任何 AppArmor 改动都无法把它放回去,而在配置文件里添加 capability 只是记录了一个内核不会兑现的意图。
先读取特权状态,再修改策略
从正在运行的进程出发,而不是从你以为生效的配置出发。状态文件会展示五个 capability 掩码和 no-new-privileges 标志,它们回答的是不同的问题:
grep -E '^(CapInh|CapPrm|CapEff|CapBnd|CapAmb|NoNewPrivs):' /proc/$(pidof openvpn)/status
sudo aa-status | grep -i openvpn
CapEff 才是对调用有意义的集合:一项 capability 出现在 CapBnd 里却不在 CapEff 里,意味着它被上限允许、但当前并未被持有。当二进制不是常规 setuid 意义上的特权程序时,CapAmb 才重要,因为环境 capability 正是服务在没有文件 capability 的情况下跨 exec 保住特权的途径。CapInh 保存的是可能跨 exec 继承的位,但只有在被执行文件的继承集合允许时才成立,所以那里的某一位本身什么也不授予。
接着确认究竟挂载了哪个配置文件。进程可能被你没预料到的配置文件名所限制,尤其当某个发行版把自己的 unit 与自己的配置文件一起提供时,而读取 /proc/PID/attr/current 一句话就能定论。拿你编辑过的文件与内核报告的配置文件作比较,就是修好原因与编辑一个没人加载的文件之间的区别。
只有当审计轨迹这么说时,才添加 capability 规则
内核与审计记录会指明拒绝的是哪一层。寻找一条带有 capable 操作和 sys_chroot 名称、并附上产生它的配置文件的拒绝记录。如果那条记录存在,说明进程本来持有该 capability 而 AppArmor 拒绝了它,针对性的改动就是在真正的配置文件里加一条 capability 规则:
capability sys_chroot,
把它作为片段留在真正被挂载的配置文件里,并到apparmor.d 配置文件参考核对关键字,而不是从无关教程里照抄一条规则。把它丢进共享 include,就等于把它授予每一个加载该文件的受限程序;而把配置文件切到 complain 模式,则是在掩盖下一次拒绝,而不是解决这一次。配置文件里的规则移除的是一次策略拒绝;它并不制造特权,所以一个有效集合里从来就没有 CAP_SYS_CHROOT 的进程,即使规则就位也仍然会失败。
如果审计记录里根本没有 AppArmor 拒绝,就停止编辑配置文件。你面对的其实是凭据问题,剩下的工作在于 unit 文件或父进程,而不在策略语言里。
服务管理器有自己的上限
systemd 会为每个服务设置一个 capability 边界集合,默认情况下这个集合很窄。凡是在那里被排除的,无论配置文件怎么说,进程都拿不到,这就是加固过的 unit 在手工启动守护进程时本可成功的操作上失败的原因。相关指令在 unit 里很容易读到,而当错误由应用自己报出时又很容易被忽略。发行版软件包附带的 drop-in 常常就是那个没人记得配置过的上限的来源。
抬高上限与授予 capability 是两项改动,不是一项。边界集合里的条目只说明进程可以持有该 capability;它并不会把它加进有效集合,而且即使其他一切都对得上,NoNewPrivs 也可能让之后的提权无法成功。只添加这项操作需要的东西,保持其余的加固不变,并且抵制把全部 capability 都授予出去以便让一次调用通过的捷径。
顺序在这里同样重要。先改 unit 的上限,确认 CapEff 现在包含那一位,然后才判断 AppArmor 是否仍在拒绝。同时做这两项改动,一旦失败持续,你又会陷入猜测。
拒绝消失之后,检查牢笼本身
成功的 chroot 会移动根目录,所以此后守护进程触碰的每一条路径都在新树里解析。缺失的配置、不存在的设备节点,或没有拷进去的证书目录,现在都会表现为文件错误,而那个错误是布局问题,不是 capability 问题。去读下一个事件,而不是再加一条宽泛的权限。这次移动还改变了相对路径的含义,所以加固指南坚持在守护进程配置里使用绝对路径。
记住 chroot 不做什么也有帮助。它是一条路径边界,不是沙箱:保住了特权的进程可以逃出去,所以这项 capability 通常在调用之后就被丢弃,也所以限制应当一直留在守护进程周围。把 AppArmor 配置文件、capability 边界集合与文件系统限制一起保留下来,才是这项练习的意义,而不是服务启动后就可以拆掉的残留。
判断规则很短。如果审计记录指名 AppArmor,就在被挂载的配置文件里加一条 capability 规则。如果有效集合是空的,就修正 unit 的上限与降权顺序。如果配置文件不是你编辑的那个,就停止编辑,去找真正的那个。