首页 / 帮助文档 / Debian系统使用systemd安全沙箱限制服务

Debian系统使用systemd安全沙箱限制服务

在Debian 12上跑着十几个服务,真正让我感到踏实的是给每个服务套上systemd沙箱的那一刻。这不是什么高深的安全概念,而是你立刻就能动手配置的硬防护。一台生产服务器哪怕被攻破了某个PHP应用,只要沙箱配置得当,攻击者连/tmp目录都写不进去,更别说横向移动到其他服务了。下面直接讲具体怎么配、每个指令到底限制了什么、以及踩过的坑怎么绕过去。

理解systemd沙箱的核心机制

systemd从v235版本开始,把Linux内核的namespace、cgroup、seccomp、capabilities这些隔离技术打包成了service单元文件里的一行行指令。你不用去手写复杂的seccomp BPF规则,也不用单独配置cgroup控制器,只要在[Service]段落里加上几行参数,systemd在启动进程前就会自动构建好隔离环境。Debian 12搭载的systemd 252版本,沙箱能力已经非常成熟,几乎涵盖了你会用到的所有隔离维度。

沙箱指令的执行顺序很重要:systemd先设置各种namespace隔离,然后应用文件系统只读绑定挂载,接着收紧capabilities,最后加载seccomp过滤器。这意味着如果你在沙箱里挂载了/var/lib/mysql目录,即使后续指令限制了整个文件系统为只读,这个目录依然可以正常读写,因为挂载发生在只读限制之前。理解这个顺序能帮你少走很多弯路。

文件系统隔离:让服务只看到该看的东西

最实用的文件系统隔离指令是ProtectSystem。设置为strict时,整个文件系统除了少数几个例外路径,全部变成只读。服务想往/etc下写配置文件?直接报错。配合ReadWritePaths可以精确开白名单,只让服务写入你指定的目录。比如一个Nginx+PHP-FPM的配置可以这样写:

[Service]
ProtectSystem=strict
ReadWritePaths=/var/lib/php/sessions /var/log/nginx /var/cache/nginx
ProtectHome=true
PrivateTmp=true

ProtectHome=true直接把/home、/root、/run/user这些用户目录全部屏蔽,服务进程根本感知不到这些路径的存在。PrivateTmp=true给服务分配一个私有的/tmp和/var/tmp,服务在里面创建的文件其他进程完全看不到。这两个指令能有效防止通过共享临时文件进行的提权攻击,比如某些利用/tmp目录存放恶意so库的手法,在PrivateTmp下直接失效。

再进一步,用ReadOnlyPaths和InaccessiblePaths做精细化控制。ReadOnlyPaths可以把特定目录挂载为只读,即使ProtectSystem没开strict也能用。InaccessiblePaths更狠,指定的路径直接被挂载为一个空的tmpfs,进程访问时会收到ENOENT错误,就像这个路径根本不存在一样。比如你不希望某个服务访问/sys/kernel目录,直接InaccessiblePaths=/sys/kernel就完事了。

DynamicUser是Debian 10之后我最喜欢的功能。把DynamicUser=yes加上,systemd会在服务启动时动态创建一个临时用户,UID从61184到65519之间随机分配,服务停止后这个用户就被销毁。这个临时用户没有任何特权,也不属于任何真实用户组,配合ProtectSystem=strict使用,就算服务被完全控制,攻击者拿到的也是一个无家可归的临时账户,连/etc/passwd都读不了。

网络隔离:切断不必要的通信能力

PrivateNetwork=true是最彻底的网络隔离,服务启动后只有一个lo回环接口,连物理网卡都看不到。适合那些完全不需要网络的后台任务,比如日志轮转、定时清理脚本。但大多数服务还是需要网络的,这时候用IPAddressDeny和IPAddressAllow做精细控制。这两个指令接受CIDR格式的地址段,支持IPv4和IPv6。举个例子,只允许服务访问内网10.0.0.0/8段和特定的外网API地址:

[Service]
IPAddressDeny=any
IPAddressAllow=10.0.0.0/8
IPAddressAllow=203.0.113.50
IPAddressAllow=2001:db8::/32

注意IPAddressDeny=any必须放在最前面,因为规则是按顺序匹配的,第一条匹配就生效。如果你把any放在最后,那前面所有的allow都白写了。另外RestrictAddressFamilies可以限制协议族,比如RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX只允许IPv4、IPv6和Unix域套接字,AF_NETLINK、AF_PACKET这些可能被用来进行底层网络操作的协议族全部屏蔽掉。

系统调用过滤:把攻击面压缩到最小

SystemCallFilter是seccomp的封装,用白名单方式限制进程能发起的系统调用。你不需要知道每个系统调用的编号,直接用@system-service这类预定义集合就行。systemd定义了几十个系统调用集合,@system-service包含了常规服务运行所需的基本调用,@network-io增加了网络相关调用,@file-system增加了文件操作调用。组合使用可以精确匹配服务需求:

[Service]
SystemCallFilter=@system-service @network-io @file-system
SystemCallErrorNumber=EPERM

SystemCallErrorNumber=EPERM指定了当进程调用被禁止的系统调用时,返回EPERM错误而不是默认的杀死进程。这很重要,因为有些程序会试探性地调用某些系统调用,如果直接被杀进程,服务就崩溃了。返回EPERM让程序知道这个操作不被允许,可以优雅降级。

SystemCallArchitectures=native也很关键。这个指令限制进程只能使用原生架构的系统调用,防止攻击者通过32位系统调用来绕过64位seccomp过滤器。在x86_64的Debian系统上,加上这行后进程就不能执行x32或i386架构的系统调用了。这是一个几乎零成本的防护措施,建议所有服务都加上。

权限和能力收紧:root不再为所欲为

Linux的root账户拥有所有capabilities,但实际运行的服务往往只需要其中几个。CapabilityBoundingSet可以大幅削减进程的能力集。比如一个普通Web服务,CAP_NET_BIND_SERVICE允许绑定1024以下的端口,CAP_SYS_CHROOT可能被某些进程用于chroot,其他能力基本都用不上。更激进的配置是用AmbientCapabilities只给需要的几个能力,然后用CapabilityBoundingSet清空所有能力:

[Service]
CapabilityBoundingSet=
AmbientCapabilities=CAP_NET_BIND_SERVICE

CapabilityBoundingSet=后面留空表示清空所有能力,AmbientCapabilities再精确赋予需要的能力。这样进程虽然是root启动的,但实际拥有的能力只有CAP_NET_BIND_SERVICE,连读取/etc/shadow的能力都没有。注意AmbientCapabilities需要内核支持,Debian 12的内核完全没问题。

NoNewPrivileges=true是另一个必加指令。它阻止进程通过setuid、setgid或capabilities提升权限。即使进程里有个setuid root的二进制文件,执行后也无法获得root权限。这个指令对防止权限提升攻击非常有效,而且对正常服务运行几乎没有任何影响。

设备访问和IPC隔离

PrivateDevices=true给服务创建一个私有的/dev目录,里面只有/dev/null、/dev/zero、/dev/random等少数几个基础设备节点。物理磁盘、摄像头、声卡这些设备全部不可见。如果服务需要访问特定设备,可以用DeviceAllow指定。比如需要访问/dev/dri下的GPU渲染设备:

[Service]
PrivateDevices=true
DeviceAllow=/dev/dri/renderD128 rw

DeviceAllow的权限字符是r、w、m的组合,分别对应读、写、mknod。大部分场景下rw就够了,mknod允许创建设备节点,除非服务确实需要,否则不要给。

ProtectKernelTunables=true让/proc/sys、/sys这些内核参数目录变成只读,服务无法修改内核运行参数。ProtectKernelModules=true禁止加载和卸载内核模块。ProtectKernelLogs=true禁止读取内核日志缓冲区。这三个指令组合起来,基本封死了通过内核接口进行权限提升的路径。ProtectControlGroups=true限制对cgroup文件系统的写入,防止服务修改自己的资源限制。

实战配置:一个加固的Nginx服务

下面是一个完整的Nginx服务沙箱配置,每条指令都经过验证,可以直接用在Debian 12上:

[Service]
Type=forking
PIDFile=/run/nginx.pid

# 用户隔离
DynamicUser=yes
User=nginx
Group=nginx

# 文件系统隔离
ProtectSystem=strict
ReadWritePaths=/var/log/nginx /var/cache/nginx /var/run
ProtectHome=true
PrivateTmp=true
ReadOnlyPaths=/etc/nginx /usr/share/nginx

# 网络隔离
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
IPAddressDeny=any
IPAddressAllow=0.0.0.0/0
IPAddressAllow=::/0

# 系统调用过滤
SystemCallFilter=@system-service @network-io @file-system
SystemCallErrorNumber=EPERM
SystemCallArchitectures=native

# 权限收紧
CapabilityBoundingSet=
AmbientCapabilities=CAP_NET_BIND_SERVICE CAP_SYS_CHROOT
NoNewPrivileges=true

# 设备和内核保护
PrivateDevices=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectKernelLogs=true
ProtectControlGroups=true

# 其他加固
RestrictRealtime=true
RestrictSUIDSGID=true
RemoveIPC=true
MemoryDenyWriteExecute=true
LockPersonality=true

ExecStart=/usr/sbin/nginx
ExecReload=/usr/sbin/nginx -s reload
ExecStop=/usr/sbin/nginx -s quit

MemoryDenyWriteExecute=true是个容易被忽略但很有效的指令。它禁止进程创建同时可写可执行的内存页,这直接堵死了JIT编译型攻击和shellcode注入。Nginx不需要运行时生成可执行代码,所以可以安全开启。如果你的服务依赖JIT(比如Node.js、PHP 8的JIT),这条就不能加。RestrictSUIDSGID=true阻止进程执行setuid/setgid程序,RemoveIPC=true在服务停止时清理所有属于该用户的System V IPC和POSIX消息队列。

调试沙箱配置的实用技巧

配置沙箱时服务启动失败是常事,关键是要快速定位是哪个指令导致的。systemd提供了几个调试工具。先用systemd-analyze security 服务名查看当前服务的安全评分和未启用的加固项,这个命令会列出每一项的曝光等级,从低到高分别是OK、EXPOSED、UNSAFE。看到EXPOSED的项目就是你可以加固的方向。

如果服务启动失败,journalctl -xe -u 服务名查看日志,注意seccomp相关的审计日志。systemd会把被拦截的系统调用记录到日志里,格式类似"syscall=xxx"被拦截。根据这些信息调整SystemCallFilter的白名单。另一个实用方法是先用strace -f 启动命令来追踪服务正常运行时用了哪些系统调用,然后根据追踪结果配置白名单。

临时调试时可以设置SystemCallLog=@known,这个指令不会拦截任何系统调用,但会把所有符合集合的系统调用记录到日志里。等收集够了信息再换成SystemCallFilter。LockPersonality=true这个指令也要注意,它禁止进程通过personality()系统调用改变执行域,极少程序需要这个功能,但如果你的服务启动报错提到personality,关掉这条就行。

沙箱配置的边界和局限

systemd沙箱不是虚拟机,它隔离的是操作系统层面的资源访问,不是硬件虚拟化。同一个内核的漏洞仍然可能影响所有沙箱内的服务。沙箱主要防护的是应用层漏洞被利用后的横向移动和权限提升,不能替代及时的内核补丁和应用安全更新。

另外,有些指令之间存在依赖关系。比如用了DynamicUser,就不需要手动指定User和Group,systemd会自动处理。但如果你同时指定了User,DynamicUser创建的临时用户会通过User指定的用户来获取UID映射,这可能导致意料之外的行为。建议DynamicUser和User不要混用,要么用DynamicUser享受完全动态分配,要么用传统的User配合其他沙箱指令。

ProtectSystem=full和strict的区别在于full模式下/etc目录依然是可写的,strict模式下/etc也变成只读。很多Linux服务启动时需要读取/etc下的配置,但运行时不需要写入,strict模式完全够用。如果服务确实需要写入/etc下的特定文件,用ReadWritePaths精确指定,不要降级到full模式。

最后,沙箱配置不是一劳永逸的。每次服务升级后,新的版本可能引入新的系统调用需求、新的文件访问路径。建议把沙箱配置纳入CI/CD流程,在测试环境里先用宽松的配置验证服务功能,然后逐步收紧,每次收紧后跑完整的测试套件。把systemd-analyze security的输出作为安全基线的指标,持续监控有没有新增的EXPOSED项。