
🌐 También en: English · Deutsch · Français
Deep Dive de la serie en torno a la visita a Ansible. Nivel: administración avanzada de Linux. La visita explica los roles; los deep dives explican la técnica que hay debajo: cómo funciona, por qué está aquí, qué hace exactamente esta instalación con ella, cómo se opera en el día a día y qué otras opciones había.
SELinux es la capa que la mayoría de los administradores desactivan primero y entienden al final. Es una pena, porque en estos servidores es la única capa que sigue aguantando cuando un proceso ya ha sido tomado, incluso uno que se ejecuta como root. Esta entrada explica primero cómo decide SELinux, con los detalles que importan en la práctica: tipos, transiciones, categorías y cómo se lee una denegación. Después pasa al blog y a los dos servidores de Nextcloud: qué está confinado, cómo, y las dos ocasiones en que SELinux estaba activado, en enforcing, y aun así no protegía nada, porque un único detalle en otro sitio hacía que las reglas correctas nunca se aplicaran.
🧭 Cómo decide SELinux
Discrecional y obligatorio
Los permisos clásicos de Unix son discrecionales: el propietario de un archivo decide quién puede leerlo, y root lo puede todo. SELinux añade encima una capa obligatoria (mandatory). En cada operación relevante – abrir un archivo, conectar un socket, enviar una señal, ejecutar un programa – el kernel consulta una política central, y la respuesta no depende de quién sea el propietario del archivo ni de si el proceso es root. Las dos comprobaciones tienen que decir que sí. Un servidor web que se ejecuta como root sigue sin poder leer /etc/shadow si la política no permite a su tipo leer el tipo de ese archivo.
Etiquetas: cuatro campos, y uno hace casi todo el trabajo
Cada proceso y cada objeto – archivo, directorio, socket, puerto, incluso las estructuras del propio kernel – lleva un contexto de seguridad con la forma user:role:type:level. En un servidor con la política targeted que trae AlmaLinux, tres de los cuatro campos apenas importan: el usuario casi siempre es system_u o unconfined_u, el rol system_r u object_r. Las decisiones las toma el tipo. En un proceso, el tipo se llama su dominio.
$ ps -eZ | grep -E ' (mariadbd|redis-server|php-fpm)$'
system_u:system_r:mysqld_t:s0 ... mariadbd
system_u:system_r:redis_t:s0 ... redis-server
system_u:system_r:container_t:s0:c628,c864 ... php-fpm
$ ls -Zd /var/lib/mysql /var/lib/redis
unconfined_u:object_r:mysqld_db_t:s0 /var/lib/mysql
system_u:object_r:redis_var_lib_t:s0 /var/lib/redisLas etiquetas de los archivos se guardan como atributos extendidos (security.selinux) en el sistema de archivos. De dónde sale la etiqueta de un archivo es el origen de la mayoría de los problemas con SELinux, así que conviene conocer los tres caminos: un archivo nuevo hereda el tipo de su directorio; una regla de type transition puede decir «los archivos que el dominio X crea en un directorio de tipo Y reciben el tipo Z»; y restorecon pone las etiquetas según la base de datos de file contexts, reglas como /var/lib/mysql(/.*)? → mysqld_db_t que trae la política y que semanage fcontext amplía. Mover un archivo conserva su etiqueta; copiarlo le da una nueva. El clásico: un archivo de configuración movido desde un directorio personal a /etc que luego su servicio no puede leer.
Type Enforcement
En esencia, la política es un gran conjunto de reglas de permiso entre tipos: allow mysqld_t mysqld_db_t:file { read write open … };, es decir, un proceso del dominio mysqld_t puede leer y escribir archivos de tipo mysqld_db_t. Lo que no está permitido se deniega. La política targeted de estos hosts tiene bastante más de cien mil reglas así, compiladas en un binario que carga el kernel; 439 módulos forman el conjunto, de apache a zabbix. El sentido del nombre «targeted»: solo se confinan los servicios que tienen su propio módulo. Todo lo demás se ejecuta en un dominio sin confinar al que se le permite casi todo, y justo ahí, como muestra el segundo caso, algo puede colarse sin que nadie lo note.
Transiciones de dominio, y por qué NoNewPrivileges puede impedirlas
Un proceso obtiene su dominio en execve(). La regla type_transition init_t mysqld_exec_t:process mysqld_t dice: cuando algo en init_t (systemd) ejecuta un archivo de tipo mysqld_exec_t, el nuevo proceso se ejecuta en mysqld_t. Por eso la etiqueta de un ejecutable importa tanto como la de los datos: un binario mariadbd etiquetado como bin_t nunca entraría en su dominio.
Hay una interacción sutil con una opción de systemd que, por lo demás, es una ganancia pura de endurecimiento. NoNewPrivileges=yes activa el indicador del kernel PR_SET_NO_NEW_PRIVS: a partir de ahí, execve() nunca puede ganar privilegios, ni setuid ni capacidades de archivo. SELinux también trata una transición de dominio como un posible cambio de privilegios. Bajo NNP solo permite una transición si el dominio de destino es un subconjunto del de origen (bounded) o si la política concede expresamente nnp_transition para ese par. Si no se cumple ninguna de las dos, la transición se rechaza y el programa sigue ejecutándose en el dominio de su proceso padre. Varias otras opciones de aislamiento de systemd implican NNP sin decirlo. Conviene recordarlo: es el primer caso de abajo.
MCS: el campo de nivel que mantiene separados los contenedores
El cuarto campo, el nivel, suele ser s0. Con Multi-Category Security puede llevar categorías: s0:c628,c864. Un proceso solo puede acceder a un objeto si sus categorías dominan las del objeto: un proceso con c628,c864 puede leer un archivo con exactamente esas categorías, o uno sin ninguna (s0), pero no uno etiquetado c53,c213. Los runtimes de contenedores lo usan para separar contenedores que comparten todos un mismo tipo: cada pod recibe su propio par aleatorio. Los 46 procesos de contenedor de un host de Nextcloud se ejecutan como container_t, y aun así Collabora (c53,c213) no puede tocar los archivos del contenedor de PHP (c628,c864).
Hay una consecuencia fácil de pasar por alto: un directorio del host montado en pods y etiquetado container_file_t:s0, es decir, sin categorías, es accesible para todos los contenedores. MCS aísla los sistemas de archivos propios de los contenedores, no los volúmenes compartidos. En estos servidores cada directorio HostPath pertenece a una sola aplicación, así que no hay problema; en un nodo multiinquilino sí lo habría.
Leer una denegación
Cuando la política dice que no, el kernel escribe un mensaje AVC (access vector cache) en el registro de auditoría. Este procede de la comprobación de la herramienta de integridad de archivos y contiene todo lo necesario para entenderlo:
type=AVC msg=audit(...): avc: denied { getattr } for pid=... comm="aide"
name="/" dev="vda2" scontext=system_u:system_r:aide_t:s0
tcontext=system_u:object_r:dosfs_t:s0 tclass=filesystem permissive=0Léelo como una frase: el proceso aide, en el dominio aide_t, quería hacer getattr sobre un objeto de clase filesystem de tipo dosfs_t – la partición EFI en vfat – y se le denegó (permissive=0). ausearch -m AVC -ts today encuentra estos mensajes, audit2why explica qué regla falta o qué booleano lo permitiría. audit2allow -M escribiría un módulo que permite exactamente lo denegado, y es el comando más peligroso de la administración de SELinux, porque convierte cada síntoma en un permiso sin preguntarse si el acceso era legítimo.
🛡️ Por qué está activado en todas partes
Los tres servidores usan la política targeted en modo enforcing, y cada rol lo da por hecho. La razón es un modelo de amenazas, no una casilla marcada. El ataque realista contra un blog es un plugin vulnerable de WordPress; contra Nextcloud, un documento manipulado que explota la suite ofimática. En ambos casos, el atacante acaba ejecutando código dentro de un proceso de PHP o de Collabora. Todo lo que viene después – leer directamente los archivos de la base de datos, robar claves SSH, dejar un binario en el host, atacar la otra aplicación – depende de lo que ese proceso pueda hacer. Los permisos de Unix ayudan poco; dentro de un contenedor el proceso suele ser root de todos modos. SELinux es la capa que dice «no» con independencia de los identificadores de usuario.
- Un worker de PHP-FPM en
container_tsolo puede escribircontainer_file_t, su propio volumen. La política no contiene ninguna regla que permita acontainer_tleershadow_t, así que/etc/shadowqueda fuera de su alcance incluso para un proceso root que haya escapado de su namespace de montaje. - La MariaDB del host en
mysqld_tpuede leer y escribir su propiomysqld_db_t, abrir su puerto y poco más. Un servidor de base de datos comprometido no puede leer los datos de los usuarios de Nextcloud en/data, que soncontainer_file_t. - Redis en
redis_t, igual: su propioredis_var_lib_t, su puerto, su socket. - Módulos del kernel, dispositivos en bruto, ptrace sobre otros dominios: denegados por defecto a todos ellos.
El coste es pequeño una vez que las etiquetas son correctas. Casi todos los problemas de SELinux que han tenido estos hosts hasta ahora fueron una etiqueta que faltaba o era incorrecta, no una regla que faltaba, y la política para bases de datos, Redis y contenedores viene lista con la distribución y container-selinux.
🔧 Qué hace exactamente este repositorio
common_selinux: enforcing y las etiquetas correctas
El rol pone el modo enforcing con la política targeted y etiqueta los directorios del host que montan los pods. Cuáles son depende del stack: el playbook define la variable stack y un diccionario elige el perfil – raíz web y directorio de la base de datos en el blog, raíz web y datos de los usuarios en Nextcloud. Para cada directorio añade una regla de file context persistente y la aplica:
semanage fcontext -a -t container_file_t "/srv/wordpress-www(/.*)?"
restorecon -Rv /srv/wordpress-wwwLa regla vive en el almacén de la política, así que sobrevive a un reetiquetado completo; restorecon la aplica a lo que ya existe. El rol lee primero las reglas locales (semanage fcontext -l -C) y solo añade lo que falta: las versiones nuevas de semanage ya no fallan con una regla existente, sino que escriben un mensaje en stdout y terminan con 0, lo que en su día hizo que la tarea informara de «changed» en cada ejecución.
Caso 1: una base de datos en enforcing… y sin confinar
En septiembre, un drop-in de systemd endureció la MariaDB de los servidores de Nextcloud: sin capacidades, sistema de archivos de solo lectura salvo su directorio de datos, /tmp privado, un filtro de llamadas al sistema y, de forma explícita e implícita, NoNewPrivileges. MariaDB arrancaba, Nextcloud funcionaba, el drop-in parecía una mejora clara. Cinco semanas después, un vistazo a los procesos en marcha mostró esto:
$ ps -eZ | grep mariadbd
system_u:system_r:initrc_t:s0 ... mariadbdinitrc_t en lugar de mysqld_t. La unidad de la distribución no arranca mariadbd directamente, sino a través de /bin/sh -c, para ejecutar antes una utilidad de recuperación de Galera. El shell se ejecuta en initrc_t; cuando ejecuta mariadbd, debería producirse la transición a mysqld_t, y bajo NNP la política no la permite desde initrc_t. El registro de auditoría lo decía en cada arranque, en una línea que nadie había leído: avc: denied { nnp_transition } … scontext=…initrc_t… tcontext=…mysqld_t… tclass=process2. La base de datos siguió ejecutándose en el dominio del shell, mucho menos restringido. Del 11 de septiembre al 16 de octubre, la MariaDB del host prácticamente no estuvo confinada por SELinux.
La solución es una línea en el drop-in: arrancar el binario directamente, sin el shell (aquí no se usa Galera, así que la utilidad no tiene nada que hacer):
ExecStart=
ExecStart=/usr/sbin/mariadbd $MYSQLD_OPTS $_WSREP_NEW_CLUSTEREjecutado directamente por systemd, la transición desde init_t está permitida y ps -eZ vuelve a mostrar mysqld_t. Se probó antes con systemd-run y las mismas propiedades, así que nunca hubo que ensayar el cambio en la base de datos de producción.
Caso 2: pods que nunca se ejecutaron como container_t
El segundo caso es mayor. SELinux estaba en enforcing en los tres servidores, los directorios del host llevaban container_file_t, container-selinux estaba instalado y, el 19 de octubre, mientras se escribía esta serie, ps -eZ mostró que ni un solo pod se ejecutaba como container_t. En el blog eran spc_t («super privileged container», prácticamente sin confinar); en los servidores de Nextcloud, unconfined_service_t.
Faltaban dos cosas. K3s solo arranca su containerd integrado con soporte de SELinux si su configuración dice selinux: true; si no, enable_selinux vale false en containerd, que nunca pide a la política una etiqueta de contenedor. Y K3s necesita su propio paquete de política, k3s-selinux, que etiqueta el binario como container_runtime_exec_t para que se ejecute como container_runtime_t. En el blog, el script de instalación había añadido ese paquete una vez; en los servidores de Nextcloud el binario era simplemente bin_t, así que systemd lo arrancaba como unconfined_service_t y cada hijo lo heredaba. Todo el cuidadoso etiquetado de los volúmenes era la preparación de una comprobación que nunca se hacía.
common_k3s hace ahora cuatro cosas:
- añade el repositorio de paquetes firmado de Rancher e instala
k3s-selinux, - pone
selinux: trueen/etc/rancher/k3s/config.yaml, - ejecuta
restorecon -v /usr/local/bin/k3sdespués de cada descarga del binario:get_urlsustituye el archivo mediante un archivo temporal en el mismo directorio, lo que da al nuevo binario elbin_tdel directorio y desharía en silencio la política en la siguiente actualización, - y recrea una vez todas las cargas de trabajo mientras ningún contenedor pause se ejecute aún como
container_t. Los pods conservan el dominio con el que arrancaron; un cambio de configuración por sí solo no confina nada que ya esté en marcha.
Después, el panorama en un servidor de Nextcloud: 46 procesos en container_t, cada pod con su propio par de categorías, solo los pods privilegiados en spc_t y ni un solo AVC, ni de Nextcloud, ni de Collabora con sus namespaces de montaje, ni de notify_push. Collabora era el componente con más posibilidades de dar problemas, porque construye para cada documento una cárcel con montajes bind; container_t permite las operaciones de namespace de montaje que necesita.
Caso 3, pequeño: una regla para AIDE
La herramienta de integridad AIDE se ejecuta como aide_t y recorre todo el sistema de archivos. La partición EFI es vfat (dosfs_t), y la política no deja que aide_t mire dentro. Excluir /boot/efi de la comprobación impedía que AIDE leyera los archivos, pero no que llamara a statfs() sobre el punto de montaje: 13 denegaciones por ejecución, cada noche. La solución más limpia es un módulo local con exactamente una regla, construido en el host y cargado solo cuando cambia su fuente:
module aide_dosfs 1.0;
require { type aide_t; type dosfs_t; class filesystem getattr; }
allow aide_t dosfs_t:filesystem getattr;
checkmodule -M -m -o aide_dosfs.mod aide_dosfs.te
semodule_package -o aide_dosfs.pp -m aide_dosfs.mod
semodule -i aide_dosfs.ppEs el contraejemplo de audit2allow: la regla se escribió a mano, después de decidir que leer los metadatos del sistema de archivos es legítimo y que no hace falta nada más. Desde entonces, los tres servidores no registran ningún AVC, lo que también es la condición para darse cuenta del siguiente de verdad.
La pila de monitorización: dominios propios, todavía en aprendizaje
Hasta el 19 de octubre, dieciocho procesos de un servidor de Nextcloud seguían ejecutándose como unconfined_service_t: Prometheus, los exportadores, Grafana con sus plugins y Monit. Están instalados como binarios sueltos en /usr/local/bin (bin_t) o desde el repositorio de un fabricante, y la política targeted no tiene módulo para ellos. Desde ese día tienen dominios propios: pequeños módulos locales homelab_prometheus, homelab_node_exporter, homelab_mysqld_exporter y homelab_grafana, construidos con las macros estándar de la política de selinux-policy-devel, con tipos para sus datos y registros. Un detalle estuvo a punto de repetir el caso 1: las unidades se ejecutan con NoNewPrivileges, así que cada módulo necesita además init_nnp_daemon_domain(); sin ello, systemd veía rechazada la transición, Grafana se quedaba en init_t, no podía leer su propia base de datos y estuvo caído cinco minutos en el blog. Los dominios nuevos arrancan en modo permisivo: son dominios reales, pero las denegaciones solo se registran (permissive=1). Tras unos días de funcionamiento normal, los accesos registrados se convierten en reglas de permiso revisadas, escritas a mano, y los dominios pasan a enforcing. Monit no recibe dominio a propósito – ejecuta como root comprobaciones arbitrarias y reinicia servicios –, sino un aislamiento de systemd que bajó su puntuación de exposición de 9.6 a 7.1.
🧰 Administración en el día a día
| Pregunta | Comando |
|---|---|
| Modo y política | sestatus, getenforce |
| ¿En qué dominio se ejecuta de verdad un servicio? | ps -eZ | grep <nombre>, la comprobación que encontró los dos casos |
| Etiqueta de un archivo | ls -Z; la esperada: matchpathcon <ruta> |
| Denegaciones desde el arranque / hoy | ausearch -m AVC -ts boot (o today) |
| ¿Por qué se denegó? | ausearch -m AVC -ts recent | audit2why |
| Reglas de file context locales | semanage fcontext -l -C |
| Corregir etiquetas | restorecon -Rv <ruta>, nunca chcon, que un reetiquetado deshace |
| Booleanos | getsebool -a | grep container, setsebool -P |
| Módulos locales | semodule -l, semodule -r <nombre> |
| Consultar la propia política | sesearch -A -s container_t -t container_file_t (paquete setools-console) |
Algunas costumbres que han demostrado su valor:
- Después de cualquier cambio en una unidad de servicio, comprobar su dominio, no solo su estado. Un servicio «active (running)» en el dominio equivocado parece completamente sano.
- Tratar cada AVC como un hallazgo. En un sistema tranquilo son lo bastante raros como para leerlos todos; en cuanto se acepta un ruido de fondo de denegaciones conocidas, la importante se pierde en él.
- ¿Un directorio nuevo del host para un pod? Añadirlo al perfil del stack en
common_selinux, nunca etiquetarlo a mano. El directorio de copias de la tarea de actualización del blog faltaba en el perfil hasta el 19 de octubre; funcionaba solo porque los pods aún no estaban confinados. - El modo permisivo para un solo dominio (
semanage permissive -a <dominio>) es una herramienta de diagnóstico para minutos, no una solución.setenforce 0en un host de producción no es una opción en absoluto. - Tras una actualización de la política,
semodule -lsigue listando los módulos locales; si un módulo deja de compilar contra una política nueva,semodule -ifalla de forma visible durante la ejecución del playbook, no en silencio durante el funcionamiento.
🔭 Lo que viene
- Alertas sobre AVC nuevos con Loki, para que la próxima denegación silenciosa, como el
nnp_transitionque se registró en cada arranque de MariaDB durante cinco semanas, genere un correo la primera vez que aparezca. - Pasar los dominios de monitorización de permisivo a enforcing una vez revisados los accesos registrados, previsto para el final de la primera semana.
- Políticas por carga de trabajo con
udica, que genera una política a partir de los montajes y puertos de un contenedor, más ajustada que elcontainer_tgenérico. - Una comprobación en el control final del playbook de que cada pod de aplicación se ejecuta como
container_t. El caso 2 duró meses porque nadie lo preguntó nunca.
⚖️ Alternativas, y por qué SELinux
| Opción | Puntos fuertes | Por qué no aquí |
|---|---|---|
| AppArmor | Perfiles basados en rutas, más fáciles de leer y escribir; predeterminado en Debian y Ubuntu | No es la opción nativa en AlmaLinux; las reglas por rutas son más débiles frente a enlaces duros y archivos renombrados; sin separación tipo MCS entre contenedores idénticos |
| Solo seccomp | Filtra llamadas al sistema, barato, ya lo usan el runtime de contenedores y las unidades de systemd | No dice nada sobre qué archivos o sockets: complemento, no sustituto |
| Landlock | Aislamiento sin privilegios desde la propia aplicación | La aplicación tiene que usarlo; ninguna de estas lo hace |
| gVisor / Kata Containers | Un segundo kernel o una frontera de VM por pod | Mucho más pesado, y excesivo para un único operador de confianza en un servidor pequeño |
| Sin MAC | Nada que aprender | El primer proceso comprometido tiene todo lo que tiene root |
SELinux encaja porque ya está ahí: AlmaLinux trae una política targeted madura, container-selinux se integra con containerd, K3s aporta su propio paquete de política y de los administradores de la familia RHEL se espera que lo conozcan. Su coste real no es la complejidad sino el silencio: como muestran los dos casos, puede estar activado y no hacer nada sin que nadie se dé cuenta. La respuesta no es desactivarlo, sino comprobar qué hace de verdad.


