Укрепване на systemd unit файлове 2026: пълно ръководство за sandboxing и защита

systemd sandboxing позволява да превърнете всеки systemd unit в изолиран процес с минимален blast radius. Разглеждам директивите ProtectSystem, PrivateTmp, NoNewPrivileges, CapabilityBoundingSet, SystemCallFilter и DynamicUser, оценката с systemd-analyze security и практически hardening на nginx.service.

Укрепване на systemd unit 2026

Обновена: 27 юли 2026 г.

Укрепването на systemd unit файлове означава да превърнете всяка услуга в самостоятелна пясъчница (sandbox), в която процесът вижда само файловете, syscall-ите, capabilities и мрежата, които наистина му трябват. Ако някога бъде компрометирана, blast radius-ът остава ограничен до самата услуга, а не до цялата машина. През 2026 г. systemd вече изпраща над 40 директиви за sandboxing, а вграденият инструмент systemd-analyze security дава числова оценка (0.0–10.0) на всеки unit. С други думи, процесът вече не е упражнение по проба и грешка, а е измерим и повторяем.

  • Всяка съвременна дистрибуция (systemd ≥ 253, налична в RHEL 9, Ubuntu 24.04 LTS, Debian 13 и Fedora 40+) поддържа поне 40 sandboxing директиви. Те работят на ниво ядро чрез namespaces, capabilities и seccomp-bpf, без нужда от SELinux или AppArmor.
  • systemd-analyze security <unit> връща експозиционна оценка от 0.0 (безопасно) до 10.0 (unsafe). Целете < 3.0 за системни услуги с мрежов достъп.
  • Най-евтините 80% защита идват от седем директиви: NoNewPrivileges, ProtectSystem=strict, ProtectHome=true, PrivateTmp=true, PrivateDevices=true, ProtectKernelTunables=true и CapabilityBoundingSet=.
  • DynamicUser=yes генерира временен UID/GID само за времето на работа на процеса, така че атакуващият няма постоянен акаунт, който да компрометира.
  • Sandboxing допълва (а не заменя) SELinux/AppArmor: LSM защитава от неоторизиран достъп, systemd намалява какво изобщо процесът може да поиска.
  • Внимавайте с SystemCallFilter=@system-service: той е добър default, но услуги с eBPF, io_uring или CRIU ще се счупят и трябва изрично да разширите филтъра.

Какво представлява systemd sandboxing и защо е важен

Честно казано, най-лесно мисля за systemd sandboxing като за списък от "отказвания", вписан директно в unit файла: „тази услуга няма да записва в /etc“, „няма да вижда /home“, „няма да има CAP_SYS_ADMIN“, „няма да прави ptrace()“. Ядрото прилага тези отказвания чрез mount namespaces, PID namespaces, capabilities bitmask и seccomp-bpf филтри. Дори ако процесът бъде превзет чрез RCE експлойт, атакуващият се озовава в клетка, а не в root shell.

Моят threat model върху всеки production Linux хост започва с въпроса „какво е първото нещо, което ще счупи атакуващият, ако компрометира тази конкретна услуга?“. Отговорът обикновено е: mounts, /dev/mem, /proc/kcore, kernel modules и SUID binaries. Всяка от тези атакни повърхности има директно съответствие в systemd (PrivateDevices=, ProtectKernelModules=, NoNewPrivileges=). За разлика от MAC системи като SELinux и AppArmor, тук няма отделни политики за писане и разбиране. Правилата са в unit файла, до ExecStart=, и остават версионирани заедно с услугата.

Другата причина да ме интересува е defense in depth. SELinux е чудесен, докато някой не пусне setenforce 0 „за да отстрани бъг“. Sandboxing директивите не могат да бъдат изключени по време на работа. Единственият начин да се премахнат е да се редактира unit файлът и да се направи daemon-reload, което оставя следа в git и в journalctl.

Как да проверя оценката за сигурност на systemd unit

Инструментът systemd-analyze security е първото нещо, което пускам върху всяка нова машина. Той анализира всеки активен unit и връща числена експозиционна оценка (Exposure Level) от 0.0 (изолирано като контейнер) до 10.0 (unsafe, може да компрометира хоста). Официалната документация на systemd-analyze обяснява точния алгоритъм. Накратко, всяка липсваща защитна директива добавя тежест.

# Оценка на всички стартирани услуги, сортирано по риск
systemd-analyze security --no-pager | sort -k2 -n -r

# Подробен разбор за конкретен unit
systemd-analyze security nginx.service

# Пример за нискорисков unit
# => Overall exposure level for nginx.service: 2.3 OK

# Пример за високорисков unit
# => Overall exposure level for legacy-app.service: 9.6 UNSAFE

Изходът е таблица с около 70 реда, като всеки ред е директива (например PrivateTmp=), нейната стойност и цена в "expose points" (0.0–0.9). Първата стъпка при hardening е да сортирате по цена и да се насочите към редовете с най-висока стойност. Обикновено това са CapabilityBoundingSet=, SystemCallFilter= и ProtectSystem=.

Защита на файловата система с ProtectSystem и PrivateTmp

Първият слой на sandbox-а е файловата система. Ядрото използва mount namespaces, за да покаже на процеса „изкривена“ версия на дървото: само за четене там, където услугата няма нужда да пише, и напълно скрито там, където изобщо няма нужда да гледа.

[Service]
# /usr, /boot и /efi стават read-only; /etc също (при strict: вкл. всичко под /)
ProtectSystem=strict

# /home, /root, /run/user стават невидими (bind mount към празна tmpfs)
ProtectHome=true

# Уникален /tmp и /var/tmp за този процес, не вижда чужди файлове
PrivateTmp=true

# Затваря /dev до минимум (null, zero, full, random, urandom, tty)
PrivateDevices=true

# Точки за писане, ако услугата има state
ReadWritePaths=/var/lib/myapp /var/log/myapp

# Точки за четене (default read-only тук е излишен при ProtectSystem=strict,
# но е полезен за услуги, които не могат да ползват strict)
ReadOnlyPaths=/etc/myapp

# Пълно скриване на директории
InaccessiblePaths=/mnt /media /srv

ProtectSystem=strict е най-агресивният режим. Цялата файлова система е read-only с изключение на /dev, /proc и /sys. Ако услугата има нужда да пише в лог или state директория, я декларирайте изрично чрез ReadWritePaths=. Това е allowlist по подразбиране, което е точно правилният модел за defense in depth.

PrivateTmp=true решава цял клас атаки, при които две услуги си оставят предвидими файлове в /tmp (класическата symlink race). Всяка услуга получава собствен tmpfs, който изчезва при спиране. Комбинирано с PrivateDevices=true, атакуващият губи достъп до /dev/mem, /dev/kmem и суровите блокови устройства. Това са три от най-често използваните ескалационни пътеки в CVE-та от последните пет години.

Ограничаване на capabilities и NoNewPrivileges

Linux capabilities разбиват стария бинарен модел „root или не“ на 40+ отделни привилегии. Мрежов daemon като nginx обикновено има нужда само от CAP_NET_BIND_SERVICE (за bind на порт < 1024), а не от CAP_SYS_ADMIN, CAP_SYS_MODULE или CAP_DAC_OVERRIDE.

[Service]
# Позволете САМО тези capabilities, всичко останало е забранено
CapabilityBoundingSet=CAP_NET_BIND_SERVICE

# Дори процесът да срещне SUID binary или fs xattr, не може да получи
# повече привилегии, отколкото има в момента
NoNewPrivileges=true

# Ambient set: capabilities, автоматично налични на child процесите
AmbientCapabilities=CAP_NET_BIND_SERVICE

NoNewPrivileges=true е директивата, която препоръчвам винаги. Цената е нула, а ефектът е драматичен: неутрализира цял клас експлойти, разчитащи на SUID бинарни файлове (pkexec, sudo, mount). Ако услугата няма легитимна нужда от sudo при работа (99% нямат), този флаг трябва да е включен още на ден 1.

CapabilityBoundingSet= с празна стойност премахва всички capabilities. Това е правилният default за unprivileged услуги. За системни услуги подавайте allowlist, а не blocklist. Проверете реалните нужди с getpcaps <pid>. Често се оказва, че процесът притежава 25 capabilities, а използва две.

Seccomp филтри чрез SystemCallFilter

Ядрото на Linux 6.x има около 440 системни повиквания. Типична web услуга ползва около 60. Всеки допълнителен syscall е потенциален expose surface (спомнете си Dirty COW, Dirty Pipe и io_uring уязвимостите от последните няколко години). SystemCallFilter= компилира seccomp-bpf програма, която ядрото прилага преди всеки syscall.

[Service]
# Позволи разумен набор syscalls за системни услуги
# @system-service е предефинирана група в systemd (~200 syscalls)
SystemCallFilter=@system-service

# Изрично забрани опасни групи (дори да са в @system-service)
SystemCallFilter=~@debug @mount @cpu-emulation @obsolete @privileged @reboot @swap @raw-io

# Върни ENOSYS вместо SIGSYS при забранен syscall (default е KILL)
SystemCallErrorNumber=EPERM

# Ограничи архитектурите за syscalls: блокира 32-bit x86 syscall shim в 64-bit процес
SystemCallArchitectures=native

Групите (@system-service, @network-io, @file-system и т.н.) са дефинирани в systemd source и описани в systemd.exec(5). Използвам @system-service като baseline и после изваждам @mount, @debug, @privileged с префикс ~. Това е allowlist с локални deny изключения.

SystemCallArchitectures=native е дребна, но важна директива. Исторически 32-bit compat syscall слоят в 64-bit ядра беше източник на бъгове (например CVE-2010-3301). Ако услугата ви е 64-bit, няма причина да й давате достъп до 32-bit ABI. За контейнер-native workloads тази концепция се препокрива с seccomp профилите на Docker и Podman, но systemd го прави още преди контейнерът да е стартиран.

Мрежова изолация с RestrictAddressFamilies и IPAddressDeny

Много услуги имат нужда само от TCP над IPv4/IPv6, а не от AF_NETLINK, AF_PACKET или AF_XDP. Свиването на позволените address families ограничава какво атакуващият може да прави с ниво-2 достъп към ядрото.

[Service]
# Само IPv4, IPv6 и UNIX сокети
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX

# Използвай собствен network namespace: виждаш само loopback
# (внимание: услугата няма да може да достъпи външна мрежа!)
# PrivateNetwork=true

# Package filter на ниво услуга (виж man systemd.resource-control)
IPAddressDeny=any
IPAddressAllow=127.0.0.0/8 10.0.0.0/8 ::1/128

# Забрана за създаване на нови namespaces (спира contained ескалации)
RestrictNamespaces=true

# Забрана за setuid/setgid към други потребители
RestrictSUIDSGID=true

IPAddressDeny= и IPAddressAllow= използват BPF cgroup филтри. Работят независимо от iptables/nftables и не могат да бъдат заобиколени от процеса чрез iptables -F. Това е сериозен инструмент за zero-trust мрежова сегментация: услугата може да говори само с изрично одобрени CIDR блокове, дори ако firewall правилата на хоста са неправилни. За допълнителна информация как това се вписва в цялостна стратегия за укрепване на ядрото, вижте моята статия за Linux kernel hardening 2026.

Защита на ядрото: ProtectKernelTunables, Modules, Logs

Атаките към ядрото рядко тръгват от нула. Обикновено разчитат на записване в /proc/sys/, зареждане на злонамерен модул или четене на dmesg, за да разберат KASLR offset-ите. systemd предлага три едрокалибрени защити срещу точно това:

[Service]
# /proc/sys, /sys, /proc/sysrq-trigger стават read-only
ProtectKernelTunables=true

# Забрана за init_module/finit_module: процесът не може да зарежда модули
ProtectKernelModules=true

# /proc/kmsg и /dev/kmsg не са достъпни: атакуващият не може да чете dmesg
ProtectKernelLogs=true

# /proc е ограничен до PID-овете на текущия namespace
ProtectProc=invisible

# /proc/<pid>/ виждаш само собствените си (не тези на root)
ProcSubset=pid

# Забрана за смяна на hostname, domain, часовник от процеса
ProtectHostname=true
ProtectClock=true

# Забрана за писане в executable memory pages (спира JIT злоупотреби и code injection)
MemoryDenyWriteExecute=true

# Забрана за смяна на process personality: блокира execve на ELF от друга архитектура
LockPersonality=true

MemoryDenyWriteExecute=true е W^X политика на ниво процес. Забранява mmap(PROT_WRITE|PROT_EXEC) и mprotect() преходи, които добавят PROT_EXEC. Това чупи JIT (Node.js, JVM, .NET Core), затова го тествайте. За класически daemons на C/Go/Rust е безплатна защита срещу shellcode injection.

ProtectProc=invisible е директива, добавена в systemd 247 (2020), която монтира /proc с флаг hidepid=invisible. Процесът вече не вижда PID-овете на други потребители. Това затваря цял клас информационни изтичания, използвани от локални privilege escalation експлойти.

DynamicUser и минимизиране на attack surface

Класическият pattern е: създайте system user (useradd -r), назначете му права по файлове и оставете услугата да работи под него. Проблемът? Този user съществува вечно. Ако някой открадне hash-а му, ако spawn-не login shell, ако извърши chown на файл под него, всичко това остава между рестарти.

DynamicUser=yes решава проблема радикално: systemd генерира временен UID/GID от private range (61184–65519) само за живота на процеса. При systemctl stop потребителят просто не съществува повече. Няма home директория, няма login shell, няма persistent cred.

[Service]
DynamicUser=yes

# Автоматично създай /var/lib/myapp с правилния owner
# и го експонирай на процеса
StateDirectory=myapp
CacheDirectory=myapp
LogsDirectory=myapp
RuntimeDirectory=myapp

# Достъпно за писане (не се комбинира с ProtectSystem=strict без ReadWritePaths)
# Всъщност StateDirectory/CacheDirectory/LogsDirectory автоматично се вписват като RW

Комбинирайте с PrivateUsers=yes за user namespace изолация. Процесът мисли, че е UID 0 (root), но извън namespace-а е обикновен потребител без capabilities върху хоста. Това е близко до модела на rootless контейнерите.

Практически пример: hardening на nginx.service

Ето един от моите production drop-in overrides за nginx. Създайте го с systemctl edit nginx.service. Това записва в /etc/systemd/system/nginx.service.d/hardening.conf и оцелява при пакетни ъпгрейди.

[Service]
# --- Filesystem ---
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
ReadWritePaths=/var/log/nginx /var/lib/nginx /run/nginx.pid
InaccessiblePaths=/media /mnt /srv /opt

# --- Kernel ---
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectKernelLogs=true
ProtectClock=true
ProtectHostname=true
ProtectProc=invisible
ProcSubset=pid

# --- Capabilities ---
CapabilityBoundingSet=CAP_NET_BIND_SERVICE CAP_CHOWN CAP_DAC_OVERRIDE CAP_SETUID CAP_SETGID
AmbientCapabilities=CAP_NET_BIND_SERVICE
NoNewPrivileges=true

# --- Namespaces ---
PrivateUsers=false
RestrictNamespaces=true
RestrictSUIDSGID=true
LockPersonality=true
MemoryDenyWriteExecute=true

# --- Syscalls ---
SystemCallFilter=@system-service
SystemCallFilter=~@debug @mount @cpu-emulation @obsolete @privileged @reboot @swap @raw-io
SystemCallArchitectures=native
SystemCallErrorNumber=EPERM

# --- Network ---
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
IPAddressDeny=any
IPAddressAllow=0.0.0.0/0 ::/0
# (не ограничаваме IP, това е публичен web сървър; за backend може да свиете)

# --- Umask и environment ---
UMask=0027
KeyringMode=private
RemoveIPC=true

След systemctl daemon-reload и systemctl restart nginx, пуснете:

$ systemd-analyze security nginx.service
  => Overall exposure level for nginx.service: 1.9 OK

От default-ната оценка около 6.5 стигаме до 1.9, с 30 реда конфигурация и нула промени в самия nginx. Не е зле за половин час работа.

Често срещани грешки и как да ги избегнем

За последните три години съм отстранил същите пет проблема поне 20 пъти:

  1. Забравя се daemon-reload. Drop-in промените в /etc/systemd/system/*.d/*.conf не се прилагат автоматично. Ако systemd-analyze security показва старата оценка, това е първата причина.
  2. ProtectSystem=strict без ReadWritePaths=. Услугата стартира, но крашва при първия log write. Винаги пуснете journalctl -u <unit> -f след hardening.
  3. SystemCallFilter=@system-service върху приложение с io_uring. Модерни бази данни (ScyllaDB, някои конфигурации на PostgreSQL 16+) използват io_uring и ще получат EPERM. Добавете @io-event и io_uring_setup io_uring_enter io_uring_register.
  4. MemoryDenyWriteExecute=true върху JIT runtime. Node.js, JVM и .NET Core няма да стартират. Изключете за тези workloads. Не си струва да ги пренаписвате в AOT само за MDWE.
  5. DynamicUser=yes върху услуга с fixed UID във файлова система. Ако сте направили chown 998:998 /var/lib/app предварително, dynamic UID няма да съвпадне и приложението няма да може да чете собствените си файлове. Използвайте StateDirectory=, което systemd chown-ва към новия UID при всяко стартиране.

За екипи, които вече използват MAC системи, sandboxing директивите не са заместител. Те са ортогонален слой. Първо ограничавате какво процесът може да поиска от ядрото (systemd), после какво има право да прави с това (SELinux/AppArmor). Ако още сравнявате двата LSM, започнете със статията за SELinux срещу AppArmor 2026.

Често задавани въпроси

Заменя ли systemd sandboxing SELinux или AppArmor?

Не. systemd sandboxing работи чрез namespaces, capabilities и seccomp. Той намалява какво процесът изобщо може да поиска от ядрото. SELinux и AppArmor са Mandatory Access Control системи, които решават дали позволено искане ще бъде уважено. Двете се допълват: sandboxing свива атакната повърхност, MAC налага политики върху останалата.

Как да проверя коя systemd директива счупи услугата ми?

Пуснете journalctl -u <unit> -b и потърсете Permission denied, Operation not permitted или ENOSYS. Ако използвате SystemCallFilter=, добавете временно SystemCallLog=@debug. Това ще запише в journal всеки блокиран syscall, без да го спира. Изключете SystemCallLog преди production deploy.

Каква оценка за сигурност трябва да имам от systemd-analyze?

За стандартни услуги без нужда от привилегии, целете < 3.0 (OK). Мрежови даемони (nginx, sshd) обикновено могат да достигнат 1.5–2.5 след пълен hardening. Оценка над 5.0 (MEDIUM) е приемлива само за системни услуги като systemd-udevd, които наистина имат нужда от широки права.

Работи ли systemd sandboxing вътре в Docker или Kubernetes?

Частично. Docker/containerd вече прилагат seccomp и capabilities филтри, така че някои systemd директиви са излишни или дори конфликтни. Ако услугата ви работи като PID 1 в контейнер (без systemd вътре), тя не използва systemd. Ако имате systemd вътре в контейнер (systemd-in-Docker), директивите работят нормално, но помнете, че кернел namespace-ите вече са едно ниво по-надолу.

Мога ли да укрепя услуга, която не съм написал аз?

Да, това е една от основните ползи. Използвайте systemctl edit <unit>, за да създадете drop-in override, който не пипа оригиналния unit файл на пакета. Промените ви оцеляват при apt upgrade или dnf update. За тестване стартирайте с systemd-run --unit=test --property="ProtectSystem=strict" /usr/bin/mycmd и проверете дали крашва преди да commit-нете overrides.

Aisha Okonkwo
За Автора Aisha Okonkwo

Infrastructure security architect at a hyperscaler. Spends her days on Zero Trust, secrets management, and yelling at unencrypted backups.