暗色模式

systemd 服务加固:从 ProtectSystem 到 systemd-analyze security 评分

技术教程
2026-09-20
2
0
本文要点
  • 服务能跑起来只是及格线:在 Ubuntu 22.04 上随手写一个 Type=oneshot 单元,systemd-analyze security 给的评分就是 9.6 UNSAFE——和 crondbusdmesg 这些系统自带服务同一个档次
  • 加固不需要装任何东西:ProtectSystem=strictPrivateTmp=yesNoNewPrivileges=yesCapabilityBoundingSet= 全是内核机制(mount namespace / capabilities / seccomp / cgroup),改几行配置就能生效
  • 实测对照:同一份脚本、同样以 root 身份运行,裸奔版能读 /etc/shadow、能往 /etc 里写文件、能看见宿主 /tmp 的哨兵文件;加固版三项全被挡住,评分从 9.6 掉到 1.7 OK
  • ⚠️ 别被「文件能打开」骗了:InaccessiblePaths=/etc/shadow 的真实动作是把目标遮罩成一个 ---------- root root 的空文件,root 靠 CAP_DAC_OVERRIDE 照样打得开,只是读到 0 字节;再叠加 CapabilityBoundingSet= 清空能力集,读取才真正失败
  • ReadWritePaths= 是这套加固里唯一的「开口」:全盘只读之后,服务要写的那一个目录必须显式列进去,否则日志、缓存、上传目录全会写失败
  • 加固有代价:SystemCallFilterMemoryDenyWriteExecuteProtectHome 最容易让服务起不来(JIT、自编译程序、要读 /root 的服务),建议分组递进加、每加一组验一次

systemd 服务加固:从 ProtectSystem 到 systemd-analyze security 评分

写一个 systemd 单元、systemctl enable --now 跑起来,这是大多数人对待自建服务的全部流程。但「跑起来」和「跑得安全」之间差着一整套东西:服务默认继承启动它的那个用户的全部权力,而系统服务大多是 root——于是 ExecStart 指向的那个脚本一旦被输入污染、被路径穿越、被替换成别的文件,攻击者拿到的是一个完整的 root shell。

systemd 提供了一组沙箱指令,把 mount namespace、capabilities、seccomp、cgroup 这些内核隔离能力包装成单元文件里的配置项;配套的 systemd-analyze security 则把「这个单元能干什么」量化成一个分数。本文在一台 Ubuntu 22.04 云服务器(1 核、内存 1.9G、内核 5.15.0-30-generic、systemd 249)上做了完整的对照实验:造一份探测脚本,先用「裸奔」单元跑一遍、再用加固单元跑一遍,看同一个 root 身份在两种配置下分别能做什么,最后用评分工具量化差距。文中所有输出都是真实执行结果。

先看清楚默认状态有多敞

实验用的探测脚本放在 /opt/demo-svc/probe.sh,五件事:打印自己的身份、尝试读 /etc/shadow、尝试往 /etc 写文件、看看宿主 /tmp 里的哨兵文件在不在、往自己的数据目录写一个文件。

mkdir -p /opt/demo-svc
cat > /opt/demo-svc/probe.sh <<'EOF'
#!/bin/bash
echo "[probe] run as : $(id -un) (uid=$(id -u))"
echo -n "[probe] read /etc/shadow : "
head -c 20 /etc/shadow >/dev/null 2>&1 && echo GRANTED || echo DENIED
echo -n "[probe] write /etc/evil.conf : "
touch /etc/evil.conf 2>/dev/null && echo GRANTED || echo DENIED
echo -n "[probe] see /tmp/host-marker : "
[ -e /tmp/host-marker ] && echo YES || echo NO
echo -n "[probe] write /opt/demo-svc/data.txt : "
echo hi > /opt/demo-svc/data.txt 2>/dev/null && echo GRANTED || echo DENIED
EOF
chmod 755 /opt/demo-svc/probe.sh
touch /tmp/host-marker

哨兵文件 /tmp/host-marker 是给 PrivateTmp 准备的对照组:宿主的 /tmp 里有它,服务自己的私有 /tmp 里没有。

第一版单元文件不加任何隔离配置,就是一个最朴素的写法:

[Unit]
Description=Demo service without sandbox

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/opt/demo-svc/probe.sh

RemainAfterExit=yes 是为了让一次性脚本跑完后单元保持 active 状态,便于后续反复用 systemctl show 查它的生效参数。启动之后,输出直接进 journal:

systemctl daemon-reload
systemctl start demo-loose.service
journalctl -u demo-loose.service -n 6 --no-pager -o cat

裸奔版单元的执行结果:以 root 身份运行,读 /etc/shadow 成功、写 /etc/evil.conf 成功、看得见宿主 /tmp 的哨兵文件

四项全部 GRANTED/YES。这不算漏洞——root 本来就该有这些权限——真正的含义是:服务的 ExecStart 后面藏着等价于 root 的能力面/etc/shadow 之所以能读,是因为它的属主就是 root、模式位 -rw-r----- 对属主放行,连 CAP_DAC_OVERRIDE 都不用动;/etc/evil.conf 被真的创建了出来(实验后要记得删)。

顺带说一句,这个状态的评分相当难看:systemd-analyze security 给它 9.6 分(满分 10,越高越糟)。

加固版单元:把能做的事一条条收窄

下面是加固版,指令按用途分了组。它跟裸奔版的唯一区别就是 [Service] 段多出来的这些行——ExecStart 指向的还是同一份脚本:

[Unit]
Description=Demo service with sandbox

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/opt/demo-svc/probe.sh

# 文件系统:整盘只读,只放开 /opt/demo-svc
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/opt/demo-svc

# 命名空间:私有 /tmp、隐藏设备节点与内核接口
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
ProtectProc=invisible
ProcSubset=pid

# 特权:不提权、清空能力集、禁止改 ABI/命名空间
NoNewPrivileges=yes
CapabilityBoundingSet=
RestrictNamespaces=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
LockPersonality=yes
SystemCallArchitectures=native

# 系统调用与内存:白名单式 seccomp + 禁止可写可执行映射
SystemCallFilter=@system-service
MemoryDenyWriteExecute=yes

# 杂项:只允许本地 socket、新建文件默认 0600
RestrictAddressFamilies=AF_UNIX
UMask=0077

# 把要保护的文件彻底遮罩掉
InaccessiblePaths=/etc/shadow

重新加载并启动后,同一份脚本给出的是完全不同的答案:

systemctl daemon-reload
systemctl start demo-sandbox.service
journalctl -u demo-sandbox.service -n 6 --no-pager -o cat

加固版单元的执行结果:同样是 root 身份,读 /etc/shadow 被拒、写 /etc/evil.conf 被拒、看不见宿主 /tmp 的哨兵文件,但自己的数据目录仍可写

三行 DENIED/NO 加一行 GRANTED,正好对应加固的两种手法:该挡的挡死,该放的显式放开。最后一行 write /opt/demo-svc/data.txt : GRANTED 是有意保留的——ProtectSystem=strict 会把整个文件系统挂成只读,ReadWritePaths=/opt/demo-svc 才是那个被允许写入的口子。真实服务里这个口子通常是 /var/lib/<服务名>/var/log/<服务名> 或者上传目录;忘了开,服务启动时就会在写日志/写缓存的路上失败。

注意 id -un 依然打印 root:沙箱不是把服务降权成普通用户,而是保留 root 身份、拿走 root 的能力。这是它和「用 User=nobody 跑服务」最大的区别——后者会连带着把文件属主、目录权限全搅乱一遍。

每一条指令到底挡了什么

上面对照里最值得拆开讲的是 /etc/shadow:它的 DENIED 并不是某一条指令独立完成的。把指令一条条拆开做单变量实验,结果如下(每次只改一条,跑同一份脚本):

配置服务内 ls -l /etc/shadow读取结果
不加任何隔离-rw-r----- root shadow读到 12 字节
只加 ProtectSystem=strict-rw-r----- root shadow读到 12 字节(写 /etc 已被拒)
只加 InaccessiblePaths=/etc/shadow---------- root root能打开,但读到 0 字节
InaccessiblePaths + CapabilityBoundingSet=---------- root rootFAILED

这张表里有三个容易踩的认知坑。

第一,ProtectSystem=strict 管的是「写」不是「读」。 它把 /usr/boot/etc 在内的整个文件系统用只读方式挂进服务的 mount namespace,于是 touch /etc/evil.conf 立刻失败;但 cat /etc/shadow 依旧畅通——只读不等于看不见。

第二,InaccessiblePaths= 不是「拒绝访问」,而是「换成空的」。 它的实现方式是把一个空文件 bind mount 到目标路径上,所以在服务里看到的属性变成了 ---------- root root(属主和模式位都变了)。此时进程以 root 身份运行,CAP_DAC_OVERRIDE 还在,模式位 000 拦不住它——文件照样打得开,只是内容被换成了空的。如果你的检查脚本只判断「能不能打开」,这里会直接误判成「没生效」。

第三,真正让读取失败的是能力集那一刀。 CapabilityBoundingSet=(等号后留空)把服务的全部 capability 清空,root 失去了绕过 DAC 的那把钥匙,模式位 000 才终于成了一个真正的门。这也是 systemd-analyze securityCapabilityBoundingSet 相关的 条目占了长长一串的原因:默认状态下 root 服务握着四十来项能力,绝大部分服务一项都用不上。

同一个道理也解释了 PrivateTmp=yes 的行为:它不是禁止写 /tmp,而是给服务挂一个私有的 tmpfs——服务在自己的 /tmp 里读写自如,宿主的 /tmp 里既看不到它的文件,它也看不到宿主的哨兵。截图里 write /tmp/probe.txt 打印 GRANTED 并不是没生效,只是那个 /tmp 已经不是宿主的那个了。

用评分工具量化,并找出最该动手的单元

零散地读指令容易遗漏,systemd-analyze security 会把单元能做的事情逐项列出来打分:

systemd-analyze security demo-loose.service demo-sandbox.service --no-pager | tail -n 3 | sed -E 's/ +$//; s/(SAFE|EXPOSED|MEDIUM|UNSAFE|OK) .*/\1/'

两个单元的评分对照:裸奔版 9.6 UNSAFE,加固版 1.7 OK

(命令末尾那串 sed 只是为了截图排版:去掉行尾填充空格和字体渲染不出来的表情符号,不影响判断。)

每一行的最后一列是该条目的权重(0.1~0.5),总分基本就是这些权重之和,分数越低越好。9.6 这个数字不是本文的服务特别糟糕,它是默认写法的普遍水平——不带单元名直接跑一遍,就能拿到全机的体检榜单:

systemd-analyze security --no-pager | head -8
UNIT                                  EXPOSURE PREDICATE HAPPY
ModemManager.service                       6.3 MEDIUM    😐
apport.service                             9.6 UNSAFE    😨
cloud-init-hotplugd.service                9.6 UNSAFE    😨
cron.service                               9.6 UNSAFE    😨
dbus.service                               9.5 UNSAFE    😨
dm-event.service                           9.5 UNSAFE    😨
dmesg.service                              9.6 UNSAFE    😨

榜单末尾(tail -4)同样有参考价值:uuidd.service 是 4.6 OK,user@0.service 高达 9.8 UNSAFE。分数低的服务不一定是安全的,但分数高的服务一定还没加固过——把它当成一个「该去 review 谁」的排序器最合适,而不是当成安全合格证。

加固完还要学会验证生效值,别只信自己写的配置文件:

systemctl show demo-sandbox.service -p ProtectSystem -p ProtectHome -p PrivateTmp -p NoNewPrivileges -p CapabilityBoundingSet -p UMask -p ReadWritePaths
UMask=0077
CapabilityBoundingSet=
ReadWritePaths=/opt/demo-svc
PrivateTmp=yes
ProtectHome=yes
ProtectSystem=strict
NoNewPrivileges=yes

CapabilityBoundingSet= 后面是空的,表示确实被清空了(如果没生效,这里会列出几十项 cap_*)。systemctl show 读的是 systemd 解析后的最终值,比翻单元文件更可信。

落地时的顺序与代价

先加低风险项,验证通过再加下一组。 推荐的分组顺序是:

  1. 几乎无副作用NoNewPrivileges=yesPrivateTmp=yesProtectSystem=strict + ReadWritePaths=ProtectHome=yesUMask=
  2. 需要确认服务行为PrivateDevices=yesProtectKernelTunables/Modules/ControlGroups/Clock/HostnameRestrictNamespaces=yesRestrictSUIDSGID=yesLockPersonality=yesRestrictAddressFamilies=
  3. 容易把服务打挂CapabilityBoundingSet=SystemCallFilter=@system-serviceMemoryDenyWriteExecute=yesSystemCallArchitectures=nativeProcSubset=pid

第三组里的坑各有各的来路:MemoryDenyWriteExecute=yes 会禁掉可写可执行内存映射,带 JIT 的运行时(Java、.NET、Node 的某些模块、V8 系)会直接崩SystemCallFilter 用白名单拦系统调用,自编译的二进制、用了冷门 syscall 的老程序容易被拦(排查时看 journalctl -u <单元> | grep -i seccomp);RestrictAddressFamilies=AF_UNIX 只放开本地 socket,任何要联网的服务都不能这么写——本文的探测脚本不联网所以能过,真实服务该写 AF_UNIX AF_INET AF_INET6ProtectHome=yes 会把 /home/root/run/user 一并藏起来,服务如果有配置文件放在 /root 下就起不来。

沙箱挡住的攻击面是有边界的。 它约束的是「服务进程被攻破之后还能做什么」——能不能改系统文件、能不能看见别的进程、能不能发起网络连接。它完全不检查服务本身的逻辑漏洞:SQL 注入还是注入、命令拼接还是拼接。把沙箱当成最后一道防线,而不是第一道。

另外,ProtectSystem=strict 之下所有写入都要靠 ReadWritePaths 开口,所以加固完务必完整跑一遍业务流程——只看 systemctl is-active 不够,Type=oneshot 的服务只要脚本退出码是 0 就算成功,写日志失败可能被静默吞掉。本文的加固版单元状态与退出码是干净的,可以作为「加固没有误伤功能」的参照:

systemctl is-active demo-sandbox.service
systemctl show demo-sandbox.service -p ExecMainStatus
active
ExecMainStatus=0

小结

一句话概括这套指令的分工:文件系统层面把整盘挂成只读、只留白名单;命名空间层面给服务一套自己的 /tmp、设备节点和内核视图;特权层面拿走能力集、禁止提权;系统调用层面用 seccomp 白名单收口。

落到命令上,最常用的五条是:

  • 体检排序systemd-analyze security --no-pager,先看谁的分数最高;
  • 单单元细读systemd-analyze security <单元>,逐项看 权重;
  • 查生效值systemctl show <单元> -p ProtectSystem -p PrivateTmp -p CapabilityBoundingSet -p ReadWritePaths
  • 验证功能没被误伤systemctl is-active + ExecMainStatus + 完整跑一遍业务流程;
  • 排错journalctl -u <单元>deniedseccompPermission denied

沙箱指令和用户/权限体系是互补的两层:User=Group= 决定服务以谁的身份运行,沙箱决定这个身份还能做什么——身份与权限的完整体系见 Linux 用户与组管理:从 useradd 到 sudo 权限体系;如果连文件系统都要隔离成一个独立环境,那是另一条路(Linux chroot 沙盒隔离:从根目录切换到轻量环境构建),而按能力粒度做最小授权(Linux capabilities 能力机制:从 setuid 特权到 setcap 最小授权)讲的正是本文反复清空的那个东西。资源维度的限制(CPU、内存、IO)则是另一套指令,见 Linux cgroup 资源控制:从 CPU 与内存限制到 systemd 运行单元;想做强制访问控制(给进程划线而不是划能力),对应的是 AppArmor 强制访问控制:从内核集成到自定义限制策略。加固完成后想知道服务启动到底慢在哪,可以接着看 Linux 开机启动分析:从 systemd-analyze blame 到 critical-chain 定位慢服务

发表评论

暂无评论,快来抢沙发吧!