本文要点
- 服务能跑起来只是及格线:在 Ubuntu 22.04 上随手写一个
Type=oneshot单元,systemd-analyze security给的评分就是 9.6 UNSAFE——和cron、dbus、dmesg这些系统自带服务同一个档次 - 加固不需要装任何东西:
ProtectSystem=strict、PrivateTmp=yes、NoNewPrivileges=yes、CapabilityBoundingSet=全是内核机制(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=是这套加固里唯一的「开口」:全盘只读之后,服务要写的那一个目录必须显式列进去,否则日志、缓存、上传目录全会写失败- 加固有代价:
SystemCallFilter、MemoryDenyWriteExecute、ProtectHome最容易让服务起不来(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.shRemainAfterExit=yes 是为了让一次性脚本跑完后单元保持 active 状态,便于后续反复用 systemctl show 查它的生效参数。启动之后,输出直接进 journal:
systemctl daemon-reload
systemctl start demo-loose.service
journalctl -u demo-loose.service -n 6 --no-pager -o cat
四项全部 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
三行 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 root | FAILED |
这张表里有三个容易踩的认知坑。
第一,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 security 里 CapabilityBoundingSet 相关的 ✗ 条目占了长长一串的原因:默认状态下 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/'
(命令末尾那串 sed 只是为了截图排版:去掉行尾填充空格和字体渲染不出来的表情符号,不影响判断。)
每一行的最后一列是该条目的权重(0.1~0.5),总分基本就是这些权重之和,分数越低越好。9.6 这个数字不是本文的服务特别糟糕,它是默认写法的普遍水平——不带单元名直接跑一遍,就能拿到全机的体检榜单:
systemd-analyze security --no-pager | head -8UNIT 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 ReadWritePathsUMask=0077
CapabilityBoundingSet=
ReadWritePaths=/opt/demo-svc
PrivateTmp=yes
ProtectHome=yes
ProtectSystem=strict
NoNewPrivileges=yesCapabilityBoundingSet= 后面是空的,表示确实被清空了(如果没生效,这里会列出几十项 cap_*)。systemctl show 读的是 systemd 解析后的最终值,比翻单元文件更可信。
落地时的顺序与代价
先加低风险项,验证通过再加下一组。 推荐的分组顺序是:
- 几乎无副作用:
NoNewPrivileges=yes、PrivateTmp=yes、ProtectSystem=strict+ReadWritePaths=、ProtectHome=yes、UMask=; - 需要确认服务行为:
PrivateDevices=yes、ProtectKernelTunables/Modules/ControlGroups/Clock/Hostname、RestrictNamespaces=yes、RestrictSUIDSGID=yes、LockPersonality=yes、RestrictAddressFamilies=; - 容易把服务打挂:
CapabilityBoundingSet=、SystemCallFilter=@system-service、MemoryDenyWriteExecute=yes、SystemCallArchitectures=native、ProcSubset=pid。
第三组里的坑各有各的来路:MemoryDenyWriteExecute=yes 会禁掉可写可执行内存映射,带 JIT 的运行时(Java、.NET、Node 的某些模块、V8 系)会直接崩;SystemCallFilter 用白名单拦系统调用,自编译的二进制、用了冷门 syscall 的老程序容易被拦(排查时看 journalctl -u <单元> | grep -i seccomp);RestrictAddressFamilies=AF_UNIX 只放开本地 socket,任何要联网的服务都不能这么写——本文的探测脚本不联网所以能过,真实服务该写 AF_UNIX AF_INET AF_INET6;ProtectHome=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 ExecMainStatusactive
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 <单元>找denied、seccomp、Permission 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 定位慢服务。
评论 (0)
暂无评论,快来抢沙发吧!