SSD Nodes Learn Hosting plans →
Guías Matt ConnorPor Matt Connor · Actualizado 2026-09-01

Aislar un servicio systemd con ProtectSystem

Aprende qué bloquean ProtectSystem, PrivateTmp, DynamicUser y NoNewPrivileges, qué pueden romper y cómo depurar una unidad que deja de iniciar.

Qué hace el sandboxing de systemd

El sandboxing de systemd es la segunda parte de la redacción de un archivo de unidad. Type= determina cómo se inicia un servicio, y directivas como ProtectSystem= y PrivateTmp= determinan qué puede tocar cuando ya está en ejecución. Son funciones del kernel, espacios de nombres de montaje y filtros seccomp que systemd aplica antes de que el proceso reciba el control. La aplicación no los ve y no necesita cambios en el código.

De forma predeterminada, no se aplica ningún aislamiento. Una unidad sin sandboxing se ejecuta como root, puede escribir en cualquier ubicación, leer todos los archivos del sistema y cargar un módulo del kernel. Si esa unidad es una aplicación web accesible desde Internet, un fallo de subida de archivos puede convertirla en un servidor completamente comprometido. Con la unidad siguiente, la misma aplicación obtiene un sistema de archivos de solo lectura, un /home vacío, un /tmp que ningún otro proceso puede ver y ninguna ruta para obtener root aunque encuentre un binario setuid.

Todo esto se aplica a una unidad cada vez. Reforzar un servicio no protege a los servicios vecinos, así que debe empezar por cualquier servicio que escuche en un puerto público.

El archivo de unidad completo

notes es un servicio web pequeño. Escucha en localhost, mantiene una base de datos SQLite en /var/lib/notes y se ejecuta detrás de nginx. Type=exec es adecuado porque el binario permanece en primer plano, y la diferencia entre Type=simple, exec, forking y notify determina cómo systemd supervisa el arranque. Todas las directivas siguientes se explican más abajo, junto con lo que evitan y los fallos que suelen provocar.

[Unit]
Description=Notes web service
After=network-online.target
Wants=network-online.target

[Service]
Type=exec
ExecStart=/opt/notes/bin/notes --listen 127.0.0.1:8080
DynamicUser=yes
StateDirectory=notes
Environment=NOTES_DB=/var/lib/notes/notes.db

ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectProc=invisible

NoNewPrivileges=yes
CapabilityBoundingSet=
AmbientCapabilities=
RestrictSUIDSGID=yes

ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes

RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
RestrictNamespaces=yes
LockPersonality=yes

[Install]
WantedBy=multi-user.target

Escríbalo en /etc/systemd/system/notes.service, ejecute sudo systemctl daemon-reload y después sudo systemctl restart notes.service. Si la unidad procede de un paquete, no edite el archivo del proveedor. sudo systemctl edit notes.service abre un drop-in en /etc/systemd/system/notes.service.d/override.conf que contiene únicamente sus adiciones de [Service] y se conserva durante las actualizaciones del paquete. systemctl cat notes.service muestra el resultado combinado, con el archivo del proveedor en primer lugar.

Usuario con el que se ejecuta el servicio

Sin User=, el servicio se ejecuta como root y todas las demás directivas de esta sección sólo sirven para limitar los daños. Hay dos formas de evitarlo.

Un usuario de sistema estático. Créelo con sudo useradd --system --no-create-home --shell /usr/sbin/nologin notes y establezca User=notes en la unidad. El UID se mantiene igual entre reinicios y arranques, lo que importa cuando el servicio es propietario de archivos fuera de su propio directorio de estado o cuando un trabajo de copia de seguridad tiene que leerlos. El razonamiento de asignar a cada servicio su propia cuenta sin privilegios se aplica aquí sin cambios.

DynamicUser=yes. systemd asigna un UID de un intervalo reservado cuando se inicia el servicio y lo libera cuando el servicio se detiene. No existe ninguna cuenta en disco, por lo que no queda nada que eliminar al quitar el servicio. Mientras el servicio se ejecuta, getent passwd notes resuelve el nombre mediante el módulo NSS (name service switch) de systemd. Después de detenerse, el nombre desaparece.

DynamicUser=yes también activa por usted otras cuatro opciones: RemoveIPC=yes, PrivateTmp=yes, ProtectSystem=strict y ProtectHome=read-only. Eso proporciona la mayor parte de un sandbox con una sola línea, por lo que la unidad de ejemplo sigue siendo breve.

Lo que deja de funcionar. Un UID dinámico no puede ser propietario de archivos en ubicaciones arbitrarias, porque el número se reutiliza entre servicios. Los datos persistentes deben pasar por StateDirectory=, CacheDirectory= o LogsDirectory=, que systemd crea y cuyo propietario cambia al UID actual en cada arranque. Con DynamicUser=yes, el directorio real es /var/lib/private/notes, y /var/lib/notes es un enlace simbólico que apunta a él. /var/lib/private tiene el modo 0700 y pertenece a root, por lo que una copia de seguridad que se ejecuta como un usuario normal obtiene Permission denied en una ruta que parece perfectamente legible para root. Todo lo que necesite un propietario fijo, una clave SSH, una exportación NFS o un archivo que lea un segundo servicio requiere un usuario estático.

Cómo es el sistema de archivos: ProtectSystem, ProtectHome, PrivateTmp

ProtectSystem= acepta tres valores. yes monta /usr y los directorios de arranque como de sólo lectura. full añade /etc. strict monta toda la jerarquía como de sólo lectura, excepto los directorios de la API del kernel /dev, /proc y /sys, que cubren otras directivas. Empiece por strict y abra excepciones según sea necesario, porque empezar con una configuración permisiva y endurecerla después nunca ocurre.

Las excepciones son ReadWritePaths=/srv/notes/uploads. Necesita menos de las que espera: StateDirectory=, LogsDirectory=, CacheDirectory= y RuntimeDirectory= siguen siendo escribibles con ProtectSystem=strict automáticamente. Por eso la unidad de ejemplo no tiene ninguna línea ReadWritePaths=. Con strict, /tmp también es de sólo lectura, a menos que PrivateTmp=yes proporcione a la unidad uno escribible propio.

Lo que rompe. Cualquier escritura fuera de esas rutas falla con Read-only file system. Las aplicaciones que guardan su configuración en /etc, crean un archivo PID directamente en /run o descomprimen un complemento en /opt se ven afectadas. Lea la ruta del error y añada sólo esa ruta, no su directorio padre. Una ruta incluida en ReadWritePaths= que no existe provoca un fallo al iniciar, no una advertencia. Por eso debe anteponer un guion a las rutas opcionales: ReadWritePaths=-/srv/notes/uploads.

El modo de sólo lectura no equivale a ocultar. Con ProtectSystem=strict, el servicio todavía puede leer /etc/passwd y cualquier secreto legible por todos que pertenezca a otra aplicación. InaccessiblePaths=/etc/ssh /srv/otherapp elimina completamente un subárbol de la vista de la unidad. Para los secretos propios del servicio, LoadCredential=dbpass:/etc/notes/dbpass copia el archivo en un directorio por unidad que sólo este servicio puede leer, y la aplicación lo encuentra en $CREDENTIALS_DIRECTORY.

ProtectHome=yes hace que /home, /root y /run/user aparezcan vacíos. Un servicio web no necesita acceder a un directorio personal. Esto impide que una vulnerabilidad de recorrido de rutas llegue a /root/.ssh. read-only y tmpfs son los valores menos restrictivos. Rompe cualquier aplicación cuyos datos estén realmente en un directorio personal, lo que incluye muchas aplicaciones instaladas manualmente en /home/app. Mueva los datos a /var/lib, o establezca ProtectHome=read-only y acepte una protección menor.

PrivateTmp=yes proporciona al servicio su propio /tmp y /var/tmp. Se crean cuando el servicio inicia y se eliminan cuando se detiene. Esto elimina toda una clase de condiciones de carrera con archivos temporales entre servicios. Además, un fallo ya no puede dejar secretos en un directorio que cualquier usuario del sistema pueda listar.

Lo que rompe. Cualquier servicio que use /tmp como punto de encuentro. Un servicio configurado para conectarse a MySQL mediante /tmp/mysql.sock ahora informa Can't connect to local MySQL server through socket '/tmp/mysql.sock', porque la base de datos creó su socket en el /tmp del host, mientras el servicio lo busca dentro de su propio espacio. Apúntelo a 127.0.0.1 o a la ruta real del socket bajo /run. El mismo problema aparece al depurar: los archivos que el servicio escribe en /tmp no aparecen en el /tmp de su shell. Para inspeccionarlos, entre en el espacio de nombres de montajes del servicio.

pid=$(systemctl show --property=MainPID --value notes.service)
sudo nsenter --target "$pid" --mount ls -l /tmp

PrivateDevices=yes sustituye /dev por un conjunto reducido de dispositivos ficticios, como /dev/null, /dev/zero y /dev/urandom. Los dispositivos físicos simplemente no están disponibles. Los nodos de disco, /dev/kvm, /dev/net/tun, las tarjetas de sonido y las GPU desaparecen. Por tanto, un servidor multimedia que realice transcodificación mediante hardware no puede abrir /dev/dri/renderD128 y cambia al procesamiento por software o termina. Cuando un servicio necesita realmente un nodo de dispositivo, desactive PrivateDevices= para esa unidad y nombre el nodo con DeviceAllow=/dev/dri/renderD128 rw. Esto sigue siendo mucho más restrictivo que el valor predeterminado, que permite todos los dispositivos del sistema.

En qué puede convertirse el servicio: NoNewPrivileges y capabilities

NoNewPrivileges=yes establece una marca de proceso que el kernel nunca elimina. Desde ese momento, el proceso y cada proceso hijo que inicie no pueden obtener privilegios mediante un binario setuid o una capability de archivo. Es la línea individual más importante de la unidad, porque hace que la mayoría de las cadenas de escalada de privilegios local se detengan en el primer paso.

Lo que rompe. Cualquier componente que llame a sudo desde el servicio, que ahora muestra sudo: effective uid is not 0, is /usr/bin/sudo on a file system with the 'nosuid' option set or an NFS file system without root privileges?. Las comprobaciones de contraseñas de PAM (módulos de autenticación conectables) que ejecutan unix_chkpwd también fallan de la misma forma. Lo mismo ocurre con las herramientas de contenedores rootless que necesitan newuidmap. Si el servicio depende de una de ellas, elimine la dependencia en lugar de quitar la directiva.

CapabilityBoundingSet= limita las capabilities que cualquier proceso de la unidad puede tener. Una asignación vacía las elimina todas. En un servicio que ya se ejecuta con un usuario que no es root, esto es un segundo bloqueo y no el primero, porque NoNewPrivileges=yes impide la forma habitual de obtener una capability. Mantenga ambas directivas, porque fallan de forma diferente y conviene que el problema se detecte dos veces.

La capability que un servicio web suele necesitar es CAP_NET_BIND_SERVICE, para usar un puerto inferior a 1024. Un proceso que no es root necesita que se le conceda, no sólo que se le permita, por lo que se requieren ambas líneas.

CapabilityBoundingSet=CAP_NET_BIND_SERVICE
AmbientCapabilities=CAP_NET_BIND_SERVICE

systemd aplica las capabilities ambientales antes de eliminar privilegios, por lo que esto sigue funcionando junto con NoNewPrivileges=yes. Sin esas líneas, el servicio se inicia y después termina con un error que indica el puerto, como listen tcp :443: bind: permission denied. En la mayoría de las configuraciones VPS, la opción más adecuada es enlazar 127.0.0.1:8080 y dejar que nginx o Caddy gestionen 443. Así, la capability no aparece en la unidad. Un ~ al principio invierte la lista, por lo que CapabilityBoundingSet=~CAP_SYS_ADMIN bloquea esa capability y permite las demás. Prefiera la forma de lista permitida: una lista de denegación envejece mal porque se siguen añadiendo nuevas capabilities.

Qué expone el kernel

ProtectKernelTunables=yes hace que /proc/sys, /sys y los archivos con permisos de escritura que contienen sean de solo lectura para esta unidad. Rompe cualquier servicio que establezca un sysctl durante el arranque: el script de inicio muestra sysctl: setting key "vm.max_map_count": Read-only file system y se detiene. Configure el valor en /etc/sysctl.d/, que es donde corresponde y donde se conserva tras un reinicio, y mantenga activada la directiva.

ProtectKernelModules=yes bloquea la carga de módulos. Una unidad que ejecuta modprobe obtiene modprobe: ERROR: could not insert 'nf_conntrack': Operation not permitted. Cargue el módulo durante el arranque mediante /etc/modules-load.d/ en lugar de hacerlo desde el servicio.

ProtectKernelLogs=yes elimina dmesg. ProtectControlGroups=yes hace que /sys/fs/cgroup sea de solo lectura, algo que los runtimes de contenedores y cualquier componente que administre sus propios cgroups detectan de inmediato. ProtectProc=invisible oculta los procesos de otros usuarios en /proc, por lo que un servicio comprometido no puede leer la línea de comandos de otro daemon ni la contraseña que alguien le haya pasado. Los agentes de monitorización que recorren /proc son los que necesitan desactivar esta opción.

RestrictNamespaces=yes impide que el servicio cree nuevos namespaces, algo que necesita un runtime de contenedores y que un atacante utiliza para construir una vía de escape. LockPersonality=yes bloquea el cambio del dominio de ejecución del kernel, y RestrictSUIDSGID=yes impide que el servicio cree archivos setuid. Ambas opciones tienen un coste bajo y rara vez rompen una aplicación normal.

Con qué puede comunicarse el servicio

RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 permite sockets locales y IPv4 e IPv6, y hace que socket() falle con EAFNOSUPPORT para todo lo demás. Es un filtro seccomp, por lo que funciona en x86-64 y arm64, lo que cubre cualquier VPS actual.

Qué rompe. AF_NETLINK, con mucha más frecuencia de la esperada. El getifaddrs() de glibc usa un socket netlink, al igual que las rutas de enumeración de interfaces de los entornos de ejecución Go, Java y .NET. Por eso, un servicio que sólo quería conocer su propia dirección IP termina con OSError: [Errno 97] Address family not supported by protocol o con el equivalente en su lenguaje. Cuando ocurre, la lista pasa a ser AF_UNIX AF_INET AF_INET6 AF_NETLINK, que sigue siendo mucho más restrictiva que la predeterminada. AF_PACKET es lo que necesita la captura de paquetes sin procesar, y casi nada más debería tenerlo.

IPAddressDeny=any con IPAddressAllow=localhost utiliza otro mecanismo: un filtro BPF asociado al cgroup de la unidad. Se aplica por unidad y es invisible para nft list ruleset. Esto lo hace adecuado para un servicio que sólo deba acceder a una base de datos en el mismo host, pero puede confundir a quien depure el sistema después. En un kernel sin compatibilidad con cgroup BPF, systemd registra que la unidad configura un firewall IP mientras el sistema local no admite el filtrado BPF/cgroup, y las reglas no hacen nada de forma silenciosa. Por eso, revise el journal en lugar de darlo por supuesto.

¿Por qué una unidad reforzada deja de iniciar?

Cada directiva anterior puede convertir un servicio funcional en uno que falla, y el fallo a menudo no se parece en nada a la directiva que lo provocó. El procedimiento siempre es el mismo. Lea el journal, relaje exactamente una directiva y vuelva a probar.

sudo systemctl restart notes.service
systemctl status notes.service
sudo journalctl -u notes.service -n 50 --no-pager

Las líneas que definen el sandbox tienen este aspecto:

notes.service: Failed to set up mount namespacing: No such file or directory
notes.service: Failed at step NAMESPACE spawning /opt/notes/bin/notes: No such file or directory
notes.service: Main process exited, code=exited, status=226/NAMESPACE

226/NAMESPACE significa que systemd no pudo crear la vista del sistema de archivos, por lo que el binario ni siquiera llegó a ejecutarse. La causa habitual es una ruta de ReadWritePaths=, BindPaths= o InaccessiblePaths= que no existe. 228/SECCOMP indica que SystemCallFilter= o SystemCallArchitectures= no se pudo aplicar. code=killed, status=31/SYS es diferente: el proceso se inició, después realizó una llamada al sistema que el filtro denegó y el kernel lo terminó. Mantenga abierto el índice de códigos de salida de systemd para unidades que no inician mientras trabaja en esto, porque el número es la forma más rápida de distinguir un problema de namespace de un problema de la aplicación.

Relaje una directiva cada vez, en un drop-in, para que el resultado aporte información. Ejecute sudo systemctl edit notes.service y coloque una sola línea:

[Service]
ProtectSystem=full

Reinicie. Si el servicio se inicia ahora, sabrá qué directiva debe restringir en lugar de eliminar. Restáurela a strict, añada la línea ReadWritePaths= para la única ruta que la aplicación necesita realmente y vuelva a reiniciar. Eliminar el bloque porque el servicio no iniciaba es la forma de terminar con una unidad sin protección y un comentario que nadie recuerda haber escrito.

Para probar un sandbox sin implicar ningún servicio, ejecute un shell dentro de uno:

sudo systemd-run --pty -p ProtectSystem=strict -p ProtectHome=yes -p PrivateTmp=yes /bin/bash

Dentro de ese shell, touch /etc/test devuelve Read-only file system y ls /home no muestra nada. Es la forma más rápida de averiguar qué verá una aplicación, porque puede ejecutar sus comandos manualmente y observar cuál falla.

Un modo de fallo no produce ningún error. Una directiva mal escrita sólo genera una advertencia, y el servicio se inicia sin ella:

/etc/systemd/system/notes.service:14: Unknown key name 'ProtectSytem' in section 'Service', ignoring.

La unidad se ejecuta, el sandbox no existe y no se muestran más advertencias. Dos comandos permiten detectarlo. sudo systemd-analyze verify /etc/systemd/system/notes.service muestra la misma advertencia bajo demanda, y systemctl show muestra lo que recibió realmente el servicio en ejecución:

systemctl show notes.service -p User -p ProtectSystem -p PrivateTmp -p NoNewPrivileges

Si devuelve ProtectSystem=no cuando escribió strict, la unidad no cargó lo que usted cree.

Use systemd-analyze security como lista de comprobación

sudo systemd-analyze security notes.service muestra todos los ajustes de aislamiento que puede usar una unidad, qué hace actualmente esta unidad con cada uno y una valoración por fila. Ejecútelo sin argumentos para enumerar todos los servicios cargados en el servidor y añada --offline=true con la ruta de un archivo de unidad para comprobarlo antes de instalarlo.

Lea la salida como una lista de tareas pendientes. Revise las filas que marca y responda una pregunta para cada una: ¿necesita este servicio ese acceso? La mayoría de las veces, la respuesta es no y debe añadir la línea correspondiente. A veces, la respuesta es sí. Un servidor multimedia necesita un nodo de dispositivo. Un agente de copias de seguridad necesita leer /home. Esas filas seguirán marcadas siempre. Ese es el resultado correcto, no un error.

El número del resumen final reúne los resultados de las filas anteriores. No sabe qué hace su servicio, qué datos almacena ni si la aplicación tiene un error desde el principio. Una unidad puede cumplir todas las comprobaciones de la herramienta y seguir siendo el componente más débil del servidor, porque la herramienta mide la exposición del archivo de unidad, no la exposición del software. Un gestor de contraseñas autogestionado muestra claramente esa diferencia: una unidad de Vaultwarden puede superar todas las comprobaciones, mientras que su token de administrador y su archivo de copia de seguridad siguen determinando si el almacén mantiene sus datos protegidos. Perseguir el número lleva a pegar directivas que no se entienden, y esas son precisamente las que provocan fallos después de una actualización de paquete, cuando ya no queda nadie que pueda explicar por qué estaba allí la línea.

Dónde termina el aislamiento

Estas directivas controlan a qué puede acceder un servicio. No indican cuánto puede consumir. Por tanto, una unidad completamente aislada todavía puede usar todos los núcleos y toda la memoria del equipo. Este es un conjunto de ajustes independiente, que se trata en la guía sobre límites de CPU y memoria para un servicio de systemd.

Tampoco sustituyen al control de acceso obligatorio. Un espacio de nombres se aplica por unidad y lo define quien escribe el archivo de unidad, mientras que SELinux aplica una política en todo el sistema. Ambos mecanismos funcionan juntos y ninguno elimina la necesidad del otro.

Por último, sólo cubren los procesos que systemd inicia dentro de esta unidad. Un servicio que delega el trabajo en un daemon auxiliar mediante un socket no ha aislado ese auxiliar. Escriba el mismo bloque también en esa unidad y compruebe el resultado con systemctl show en lugar de confiar en el archivo.

FAQ

¿Qué hace que ProtectSystem=strict sea de solo lectura?

Toda la jerarquía del sistema de archivos, excepto /dev, /proc y /sys, que están cubiertos por PrivateDevices=, ProtectKernelTunables= y ProtectControlGroups=, respectivamente. Esto incluye /etc, /var, /srv, /opt y /tmp. Las excepciones que systemd vuelve a añadir son los directorios que administra: StateDirectory=, CacheDirectory=, LogsDirectory= y RuntimeDirectory=. Todo lo demás en lo que el servicio deba escribir necesita una entrada ReadWritePaths= explícita. Tenga en cuenta que de solo lectura no significa ilegible. Por tanto, el servicio todavía puede abrir un archivo secreto situado en otra parte del sistema, salvo que lo incluya en InaccessiblePaths=.

¿Por qué falla mi servicio con status=226/NAMESPACE?

systemd no pudo crear el espacio de nombres de montajes, por lo que el ejecutable nunca se inició. La línea anterior del journal suele mostrar Failed to set up mount namespacing: No such file or directory. En casi todos los casos, una ruta de ReadWritePaths=, BindPaths= o InaccessiblePaths= no existe en el disco. Cree el directorio o anteponga un guion a la entrada (ReadWritePaths=-/srv/notes/uploads) para que systemd la omita si falta. Si la ruta existe, compruebe que no haya un error tipográfico en la unidad y confírmelo con systemctl cat notes.service, ya que un drop-in podría estar añadiendo una línea que no está viendo.

¿Puede un servicio con PrivateTmp seguir compartiendo archivos mediante /tmp?

No. Ese es precisamente el objetivo. El servicio obtiene un /tmp y un /var/tmp nuevos, que sólo existen mientras está en ejecución. Por tanto, no puede ver un socket o archivo que otro proceso haya creado en el /tmp del host. Un socket de base de datos en /tmp/mysql.sock es la víctima habitual. La solución consiste en conectarse mediante 127.0.0.1 o indicar al cliente el socket real situado en /run. Para inspeccionar los archivos temporales propios del servicio, obtenga su PID principal con systemctl show --property=MainPID --value y entre en su espacio de nombres de montajes mediante sudo nsenter --target <pid> --mount.

¿Cómo puede un servicio aislado escuchar en el puerto 443 sin usar root?

Conceda una capacidad en lugar de toda la cuenta root. Configure AmbientCapabilities=CAP_NET_BIND_SERVICE junto con CapabilityBoundingSet=CAP_NET_BIND_SERVICE y mantenga User= o DynamicUser=yes. Así, el proceso podrá asociarse a puertos bajos y no tendrá ninguna otra capacidad. Configurar sólo el conjunto de límites es el error habitual: la capacidad queda permitida, pero nunca se concede, y el servicio termina con un error de permisos que identifica el puerto. En un VPS que ya ejecuta un reverse proxy, la opción más limpia es asociar 127.0.0.1:8080 en el servicio y dejar que nginx mantenga el puerto 443. De este modo, la unidad no necesita ninguna capacidad.

¿Debería usar DynamicUser en lugar de crear un usuario del sistema?

Úselo cuando el servicio mantenga todos sus datos dentro de StateDirectory=, CacheDirectory= o LogsDirectory=. Esto cubre la mayoría de las aplicaciones web pequeñas autoalojadas. Obtendrá un UID que sólo existe mientras el servicio está en ejecución, además de PrivateTmp=, ProtectSystem=strict, ProtectHome=read-only y RemoveIPC= activados automáticamente. Use un usuario del sistema estático cuando el UID deba mantenerse estable: por ejemplo, para archivos propiedad de usuarios fuera de esos directorios, una clave SSH, un montaje NFS o un segundo proceso que lea los mismos datos. Recuerde que con DynamicUser=yes los datos se encuentran realmente en /var/lib/private/notes. Ese directorio tiene permisos 0700 y pertenece a root, por lo que los trabajos de copia de seguridad que no se ejecutan como root fallan.