Hardening de Servicios systemd: Sandboxing con ProtectSystem, NoNewPrivileges y systemd-analyze security
Aprende a aplicar sandboxing declarativo a tus unidades systemd con ProtectSystem, NoNewPrivileges, SystemCallFilter y DynamicUser. Mide el impacto con systemd-analyze security y endurece un servicio real de 9.6 (UNSAFE) a 1.4 (SAFE).
El hardening de servicios systemd consiste en aplicar directivas de sandboxing dentro de los archivos .service (como ProtectSystem=strict, NoNewPrivileges=yes, SystemCallFilter=@system-service y DynamicUser=yes) para reducir la superficie de ataque del proceso sin recurrir a contenedores externos. Desde systemd v256 (2024) y consolidado en v257 (2025), la herramienta systemd-analyze security califica cada unidad en una escala de 0 (perfecto) a 10 (desastroso), lo que te permite medir el efecto de cada cambio. En esta guía endurecemos unidades reales hasta llevarlas por debajo de 2.0.
systemd-analyze security <unit> entrega una puntuación numérica y un desglose por directiva; úsala como métrica objetiva del hardening.
ProtectSystem=strict con ProtectHome=yes deja el rootfs en solo lectura excepto /dev, /proc y /sys, eliminando la mayoría de escrituras maliciosas.
NoNewPrivileges=yes activa el bit PR_SET_NO_NEW_PRIVS del kernel (Linux 3.5+), bloqueando setuid y file capabilities incluso ante un exploit.
SystemCallFilter=@system-service aplica seccomp-bpf con la lista blanca recomendada y SystemCallArchitectures=native bloquea las ABIs de 32-bit en x86_64.
DynamicUser=yes crea un UID/GID transitorio por arranque y se integra con StateDirectory= para persistencia, sin gestionar usuarios del sistema.
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 y IPAddressDeny=any permiten cerrar la red a servicios que no la requieran.
Por qué el sandbox de systemd reemplaza scripts y wrappers
Durante años, aislar un servicio Linux significaba envolverlo en chroot, firejail o un contenedor completo. systemd integra ese mismo aislamiento como directivas declarativas dentro de la propia unidad, y las aplica desde PID 1 antes de ejecutar el binario. Eso importa, porque la superficie no-confinada es el principal vector de escalada local: un servidor web comprometido sin sandbox puede escribir en /etc, leer claves SSH o cargar módulos del kernel. Con las directivas adecuadas, todo ese acceso desaparece sin tocar el código de la aplicación.
Honestamente, después de pasarme un trimestre entero hardenizando servicios para un cliente PCI-DSS, el cambio mental clave fue tratar la unidad .service como una política de seguridad, no como una receta de arranque. Cada directiva de sandbox se mapea directamente a un mecanismo del kernel (no_new_privs, mount namespaces, seccomp-bpf, capabilities, cgroups v2) y systemd las orquesta sin que el binario tenga que ser capability-aware. La especificación oficial systemd.exec(5) documenta más de 70 directivas relevantes para sandboxing; este artículo cubre las que más reducen la puntuación de systemd-analyze security.
Si vienes de aplicar SELinux y AppArmor como controles MAC, el sandbox de systemd es complementario: actúa como una capa DAC reforzada que se ejecuta antes de cualquier política LSM, y reduce el conjunto de syscalls y rutas antes incluso de que SELinux las evalúe.
Cómo usar systemd-analyze security para medir el hardening
systemd-analyze security existe desde la v240 (2018), pero su utilidad real llegó con la v246, cuando se añadió el desglose por directiva y la puntuación ponderada. En 2026 es la única métrica objetiva ampliamente aceptada para auditar unidades, e incluso el script de hardening de CIS Benchmarks v3.0.0 para Ubuntu 24.04 LTS la referencia como criterio de validación.
Para comprobar el estado actual de los servicios en un host:
# Listado ordenado por puntuación (peor -> mejor)
systemd-analyze security --no-pager
# Análisis detallado de una unidad concreta
systemd-analyze security nginx.service
# Solo el resumen (útil en pipelines CI)
systemd-analyze security nginx.service --json=short | jq '.[0].overall_exposure_level'
El comando devuelve filas con la directiva, el valor actual, una descripción y la exposure (peso) que aporta al total. A una unidad con puntuación 9.6 (UNSAFE) le falta básicamente todo: namespaces, seccomp, capabilities y restricciones de red. Por debajo de 3.0 se considera OK, y por debajo de 2.0, SAFE. Mi objetivo en producción es ≤2.5 para servicios de red expuestos.
Aislamiento del sistema de archivos: ProtectSystem, ProtectHome y *Paths
El kernel Linux ofrece mount namespaces desde la 2.4.19, y systemd los usa para construir una vista privada del filesystem por servicio. Las cuatro directivas con mayor impacto son ProtectSystem, ProtectHome, PrivateTmp y ProtectKernelModules.
[Service]
# Rootfs en solo lectura, /usr y /boot inmutables incluso para root
ProtectSystem=strict
# /home, /root y /run/user invisibles para el servicio
ProtectHome=yes
# /tmp y /var/tmp privados (tmpfs propio del namespace)
PrivateTmp=yes
# Bloquea load_module(), delete_module() y /sys/module
ProtectKernelModules=yes
# Solo lectura para /sys, /proc/sys, /proc/sysrq-trigger, etc.
ProtectKernelTunables=yes
# Sin acceso a /sys/fs/cgroup ni a la jerarquía cgroup
ProtectControlGroups=yes
# Rutas explícitamente escribibles (lista blanca)
ReadWritePaths=/var/lib/myapp /var/log/myapp
# Rutas explícitamente bloqueadas (lista negra adicional)
InaccessiblePaths=/etc/shadow /root /home
Con ProtectSystem=strict, todo el rootfs queda en solo lectura excepto /dev, /proc y /sys; cualquier ruta que tu servicio necesite escribir debe declararse de manera explícita vía ReadWritePaths= o StateDirectory=. Eso convierte el principio de mínimo privilegio en una política declarativa, ejecutable por el kernel.
Diferencia entre strict, full y true
ProtectSystem=true: /usr y /boot en solo lectura. Compatible con casi todo.
ProtectSystem=full: añade /etc. Rompe servicios que escriben config dinámica.
ProtectSystem=strict: todo el rootfs solo lectura excepto /dev /proc /sys. El objetivo en hardening serio.
Namespaces, capacidades y NoNewPrivileges
Linux define siete tipos de namespaces (mount, UTS, IPC, PID, network, user, cgroup). systemd los expone como directivas individuales. La combinación canónica para un servicio no-privilegiado es:
[Service]
# El proceso no puede adquirir privilegios via setuid/file caps
NoNewPrivileges=yes
# PID namespace privado: ps no ve otros procesos
PrivateUsers=yes
# Network namespace propio (solo loopback); úsalo si no necesitas red
# PrivateNetwork=yes
# /dev privado con solo /dev/null, /dev/zero, /dev/random, /dev/urandom, /dev/tty
PrivateDevices=yes
# Sin acceso a logs del kernel
ProtectKernelLogs=yes
# Sin acceso al reloj del sistema (no settimeofday, no adjtimex)
ProtectClock=yes
# Capabilities permitidas (vacío = ninguna). Lista blanca explícita.
CapabilityBoundingSet=
AmbientCapabilities=
# Sin acceso a /proc de otros procesos
ProtectProc=invisible
ProcSubset=pid
# Sin /home, /root, /run/user
ProtectHostname=yes
NoNewPrivileges=yes es la directiva más barata y con mayor retorno. Activa el bit PR_SET_NO_NEW_PRIVS introducido en Linux 3.5 (2012), que impide al proceso y a todos sus hijos ganar privilegios via setuid, setgid o file capabilities. Sin esto, una vulnerabilidad RCE puede ejecutar /usr/bin/sudo o un binario SUID que olvidaste. Con esto, el ejecutable simplemente falla con EPERM.
Si tu servicio no necesita ninguna capability (y la mayoría no la necesitan después de bindear el puerto), declara CapabilityBoundingSet= vacío. Para servicios que requieren un puerto privilegiado, prefiere AmbientCapabilities=CAP_NET_BIND_SERVICE con User= no-root en lugar de arrancar como root y soltar privilegios manualmente.
Filtros seccomp: SystemCallFilter y SystemCallArchitectures
seccomp-bpf, disponible desde Linux 3.5, permite filtrar syscalls a nivel de kernel con un programa BPF. systemd ofrece system call sets predefinidos que agrupan llamadas relacionadas, así no tienes que mantener listas blancas a mano.
[Service]
# Lista blanca con el set recomendado para servicios de sistema
SystemCallFilter=@system-service
# Excluye explícitamente sets peligrosos aunque @system-service los incluya
SystemCallFilter=~@debug @mount @cpu-emulation @obsolete @privileged @reboot @resources @swap
# Solo la ABI nativa (bloquea x86_32 en x86_64, donde viven exploits viejos)
SystemCallArchitectures=native
# Si el filtro falla, mata el proceso con SIGSYS en lugar de retornar EPERM
SystemCallErrorNumber=EPERM
@system-service: superset razonable para la mayoría de daemons.
@privileged: syscalls que requieren CAP_SYS_ADMIN u otras capabilities root. Casi nunca necesarias.
@mount: mount, umount, pivot_root. Solo containers reales.
@debug: ptrace, process_vm_readv. Vector clásico de credential dumping.
@obsolete: uselib, create_module. Sin uso legítimo en 2026.
El patrón @system-service + exclusiones es lo que recomienda Lennart Poettering desde 2019, y lo que aplican por defecto las unidades empaquetadas en Debian 13 y Fedora 41. Para servicios escritos en Go o Rust suele funcionar de inmediato. En Python, a veces hay que añadir @resources si la app ajusta RLIMIT_*.
Restricciones de red: RestrictAddressFamilies e IPAddressDeny
Un servicio que solo habla HTTP no debería poder abrir un socket Netlink ni un socket Bluetooth. RestrictAddressFamilies= aplica un filtro seccomp adicional sobre la syscall socket(), y IPAddressDeny=/IPAddressAllow= usan eBPF en cgroups v2 para filtrar conexiones salientes sin tocar netfilter.
[Service]
# Solo TCP/UDP IPv4, IPv6 y Unix sockets. Bloquea Netlink, AF_PACKET, etc.
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
# eBPF en cgroup: bloquea TODO el tráfico de salida...
IPAddressDeny=any
# ...excepto localhost y la red de servicios internos
IPAddressAllow=localhost 10.0.0.0/8 fd00::/8
# Bloquea bind() a puertos arbitrarios; útil para servicios que solo aceptan conexiones
# SocketBindDeny=any
# SocketBindAllow=tcp:8080
# Sin acceso a información del namespace de red
RestrictNetworkInterfaces=eth0
Combinado con un firewall correctamente configurado a nivel de host (cubrimos esto en detalle en hardening de nftables para producción) obtienes defensa en profundidad: aunque la regla nftables falle, el cgroup eBPF sigue activo, y aunque ambos fallen, el filtro de address families bloquea el socket antes de que llegue al stack TCP/IP.
DynamicUser, StateDirectory y credenciales efímeras
Antes de systemd v235 (2017), añadir un servicio implicaba crear un usuario del sistema con useradd --system, gestionar su UID en el LDAP corporativo o documentar el rango libre. DynamicUser=yes elimina ese problema. systemd asigna un UID/GID transitorio en el rango 61184-65519, lo registra en NSS solo durante la vida del servicio, y lo libera al detenerse.
[Service]
DynamicUser=yes
# Directorios persistentes con propietario = el usuario dinámico
StateDirectory=myapp
LogsDirectory=myapp
CacheDirectory=myapp
RuntimeDirectory=myapp
ConfigurationDirectory=myapp
# Disponibles como /var/lib/myapp, /var/log/myapp, etc. desde el sandbox
# El servicio los ve en las rutas anteriores; systemd hace el bind mount.
DynamicUser=yes implica automáticamente RemoveIPC=yes, PrivateTmp=yes, ProtectSystem=strict (variante implícita) y ProtectHome=read-only. Para servicios stateless como un sidecar de métricas o un job de procesamiento, es el camino más rápido a una puntuación <2.0.
Ejemplo completo: endurecer una unidad real de 9.6 a 1.4
Partamos de un servicio Python típico que escucha en el puerto 8080. La unidad inicial, sin hardening:
Para validar de manera reproducible que ningún despliegue regresa la puntuación, integra el check en CI. Cubrimos este patrón en el artículo de pipelines DevSecOps con GitHub Actions; basta con añadir un job que ejecute systemd-analyze security contra unidades en un contenedor de prueba y falle si la puntuación supera un umbral.
Errores comunes y cómo depurarlos
Tres fallos cubren el 90% de los tickets que he visto aplicando este patrón. Te los dejo en orden de frecuencia.
1. El servicio arranca pero no puede escribir logs
Causa: ProtectSystem=strict sin LogsDirectory= ni ReadWritePaths= para la ruta de log. Solución: declara LogsDirectory=myapp y usa /var/log/myapp en la configuración de la app. Te pasa una vez y nunca más se te olvida.
2. Operation not permitted al hacer bind() a puerto <1024
Causa: CapabilityBoundingSet= vacío sin AmbientCapabilities=CAP_NET_BIND_SERVICE. Solución: o bien usas un puerto >1024 (lo recomendado) o añades esa capability concreta. Nunca devuelvas al servicio capabilities completas.
3. El proceso muere con SIGSYS sin mensaje claro
Causa: una syscall bloqueada por SystemCallFilter=. Para identificarla:
# Audit log del kernel muestra la syscall bloqueada
sudo journalctl -k -g "seccomp" --since "5 minutes ago"
# O instrumenta con strace para reproducir
sudo systemd-run --uid=999 --property="SystemCallFilter=@system-service" \
strace -f -e trace=all /opt/myapp/server
Una vez identificada, evalúa si pertenece a un set excluido (relaja la exclusión) o si es legítima (añádela con SystemCallFilter=@system-service nombre_syscall).
Preguntas frecuentes
¿Qué puntuación de systemd-analyze security se considera aceptable?
systemd clasifica las puntuaciones como SAFE (≤2.0), OK (≤3.0), MEDIUM (≤4.0), EXPOSED (≤6.0) y UNSAFE (>6.0). Para servicios de red expuestos a Internet apunta a ≤2.5; para daemons internos sin red abierta, ≤4.0 es razonable. Servicios privilegiados (PID 1, agentes de monitorización con CAP_SYS_ADMIN) inevitablemente quedarán por encima de 6.0.
¿NoNewPrivileges es lo mismo que SELinux o AppArmor?
No. NoNewPrivileges activa el bit del kernel no_new_privs que bloquea la elevación de privilegios via setuid o file capabilities; es un control DAC. SELinux y AppArmor son LSMs que aplican políticas MAC sobre objetos del kernel. Son complementarios: actívalos los tres siempre que sea posible.
¿Puedo usar DynamicUser con una base de datos que requiere usuario persistente?
No es ideal. DynamicUser=yes reasigna el UID en cada arranque dentro del rango 61184-65519, y aunque StateDirectory= reajusta el ownership automáticamente, una base de datos que almacena UIDs en sus tablas (como PostgreSQL con peer auth) puede fallar. Para esos casos crea un usuario dedicado con User=myapp y aplica el resto de directivas de sandbox.
¿Cómo aplico systemd hardening a unidades empaquetadas sin editar /lib/systemd/system?
Usa drop-ins: ejecuta sudo systemctl edit nginx.service y systemd crea /etc/systemd/system/nginx.service.d/override.conf. Cualquier directiva añadida ahí sobrescribe el archivo del paquete sin tocarlo, sobrevive a actualizaciones de la distribución y es lo que aplican los benchmarks CIS.
¿El sandbox de systemd reemplaza a los contenedores?
No reemplaza, complementa. Un contenedor con runtime Docker o Podman añade su propio aislamiento (cgroups, namespaces, capabilities, seccomp), pero ese aislamiento aplica al PID 1 del contenedor, no a los demás servicios del host. El hardening de systemd protege servicios bare-metal y, dentro de contenedores, puede aplicarse a los daemons que el contenedor lance internamente. Para contenedores en producción ver hardening de Docker y Podman.
Guía práctica de hardening del kernel Linux en 2026: parámetros sysctl, KASLR, KPTI, Lockdown LSM en modo confidentiality, Secure Boot con claves propias y restricción de io_uring y user namespaces, con auditoría CIS.
Guía práctica de cifrado de disco con LUKS2 en Linux 2026: Argon2id como KDF, desbloqueo automático con TPM 2.0 y systemd-cryptenroll, NBDE con Clevis/Tang, rotación de claves y backups del header LUKS.
Guía práctica de SELinux y AppArmor para servidores Linux en 2026. Aprende a configurar políticas MAC personalizadas, diagnosticar denegaciones y automatizar con Ansible — con ejemplos reales para Nginx, PostgreSQL y contenedores.