
🌐 También en: English · Deutsch · Français
Misma noche, otra capa del stack. 🥱➡️🤓 Después del artículo sobre kube-bench me llegó una pregunta de seguimiento muy razonable en la sección de comentarios que solo existe en mi cabeza: «Vale, pero ¿qué pasa con la máquina Linux de verdad que hay debajo de todo este tinglado de Kubernetes?» Buena observación. kube-bench solo mira el clúster: flags del API server, RBAC, seguridad de los pods. El host AlmaLinux 9 sobre el que corre todo nunca recibió el mismo tratamiento. Así que otro desvío nocturno más. 🐧🔍🧐 OpenSCAP y el CIS AlmaLinux 9 Benchmark, en pocas palabras
OpenSCAP es, para las distribuciones Linux, el equivalente de lo que kube-bench hace con Kubernetes, solo que más antiguo, más asentado y respaldado por un estándar oficial de verdad (SCAP, Security Content Automation Protocol) en lugar de la checklist con opiniones muy marcadas de un único fabricante. Red Hat distribuye un paquete llamadoscap-security-guide que agrupa CIS, DISA STIG, PCI-DSS, HIPAA y un montón de perfiles de cumplimiento más como contenido XCCDF legible por máquina, y oscap xccdf eval recorre cada regla del perfil elegido y te dice PASS, FAIL o NOTAPPLICABLE. Mismo espíritu que kube-bench: cientos de comprobaciones quisquillosas y concretas (permisos de archivos, parámetros del kernel, configuración de PAM, opciones de montaje). No es un escáner de vulnerabilidades, sino un auditor de configuración. 🛠️ Lo ejecuté contra el perfil CIS AlmaLinux OS 9 Benchmark, Level 1 – Server, primero en modo solo lectura (--profile, sin --remediate), porque, a diferencia de una VM de pruebas desechable, esta máquina está sirviendo ahora mismo el blog en el que estás leyendo esto, y quería ver el panorama completo antes de tocar nada en producción. 🙏🚦 Primera ronda: 151 aprobados, 113 suspensos
293 comprobaciones en total: ✅ 151 PASS, ❌ 113 FAIL, ⚪ 27 N/A, ⚠️ 2 ERROR. Una proporción bastante peor que en la capa de Kubernetes, y tiene sentido: nadie había apuntado nunca un CIS Benchmark a este host en concreto, mientras que la parte de k3s ya había pasado por una revisión completa. 113 son muchos para clasificar a mano, así que aquí va el recorrido: qué se arregló, qué recibió un no rotundo y razonado, y (porque al parecer así son mis noches ahora) los dos momentos realmente interesantes de «espera, ¿por qué me acaba de mentir esto?» por el camino. 🕵️1️⃣ 🗃️ AIDE: la monitorización de integridad de archivos que, no sé cómo, nunca había tenido
Tengo rkhunter para los rootkits y ClamAV para el malware, pero nada que se diera cuenta si alguien editara a escondidas/etc/passwd o cambiara un binario del sistema. AIDE (Advanced Intrusion Detection Environment) cubre justo ese hueco: toma una vez la huella del sistema de archivos, luego compara periódicamente con esa referencia y da la voz de alarma si algo ha cambiado de forma inesperada. Instalado, base de datos inicial creada (un recorrido completo del sistema de archivos, ~30 segundos, ~51.000 archivos registrados) y una comprobación diaria programada con cron. 📅 Pequeño tropiezo al crear una referencia limpia: mis dos primeros intentos informaron ambos de 9.279 entradas eliminadas, todas bajo el directorio de módulos de una versión antigua concreta del kernel, a pesar de que ese directorio seguía existiendo perfectamente en el disco. La explicación, una vez que tiré del hilo: esta sesión SSH tiene ProtectKernelModules=yes, aplicado por el propio bastionado systemd de sshd (sí, el mismo bastionado que documenté para este mismo blog hace meses), lo que hace que /usr/lib/modules parezca completamente vacío desde dentro de una sesión SSH interactiva: una ilusión privada y aislada en su sandbox, no la realidad. Había creado la base de datos correctamente (escapando de la sandbox con systemd-run), pero luego le eché un vistazo con un simple aide --check ejecutado directamente en mi sesión SSH, que vio la falsa versión vacía y entró en pánico por archivos «desaparecidos» que nunca habían desaparecido de verdad. 🙈 Cuando repetí la comprobación de la misma forma escapada que la inicialización, salió limpia: cero diferencias. La lección va mucho más allá de AIDE: si alguna vez una comprobación ejecutada por SSH te dice algo rarísimo sobre los módulos del kernel o /proc/sys, pregúntate si no estás viendo la idea que tiene la sandbox del sistema de archivos antes de creértelo.2️⃣ 🧱 Bastionado de red a nivel de kernel, y el sysctl que NO toqué a propósito
Añadí la batería estándar: rechazar redirecciones ICMP, rechazar paquetes con enrutamiento de origen, registrar paquetes «marcianos» con direcciones de origen imposibles, ignorar ecos ICMP de broadcast (mitigación de ataques smurf), activar las SYN cookies de TCP, restringirptrace a los descendientes del propio proceso y ASLR completo. Todo aburrido, todo seguro, todo desplegado. ✅ Lo que no toqué, a propósito y tras comprobarlo antes: net.ipv4.ip_forward y el filtrado de ruta inversa (rp_filter). CIS quiere el forwarding desactivado y un filtrado de ruta inversa estricto. Mi clúster necesita exactamente lo contrario: Flannel y Calico necesitan el reenvío IP para poder enrutar el tráfico de los pods, sin más, y me encontré el rp_filter de este host ya en 0 en lugar del valor por defecto de la distribución, 1, casi seguro porque el filtrado de ruta inversa estricto es una forma bien documentada de conseguir que un nodo de Kubernetes descarte en silencio tráfico legítimo de los pods. Cambiar cualquiera de los dos «por cumplimiento» habría sido la forma más rápida de tumbar todo el clúster por una casilla. Dejé los dos en paz, y quiero decir en paz probada y verificada: después de aplicar el resto del lote de sysctl, un ciclo completo de sysctl --system volvió a poner brevemente a 1 los valores de rp_filter por interfaz de las interfaces CNI de todos modos (un archivo por defecto de la distribución reafirmándose), así que lancé de inmediato un viaje de ida y vuelta real pod → pod → base de datos a través de un CronJob en marcha para comprobar que no se había roto nada. Salió limpio. Lo seguí vigilando; no tuve que revertir nada, pero quería tener las pruebas antes de escribir esta frase, no solo la teoría. 🧪3️⃣ 🔒 Blindando /tmp, /var/tmp y /dev/shm, y el script de instalación que se rompió por el camino
Remonté los tres con bind ynodev,nosuid,noexec: nada debería ejecutar binarios desde un directorio temporal en el que todo el mundo puede escribir. Antes de darle al interruptor me puse a buscar en mi propio código de Ansible cualquier cosa que ejecute un archivo ubicado en /tmp, porque eso es exactamente lo que noexec rompe sin avisar. Encontré uno: el bootstrap del instalador de k3s descarga el script de instalación de get.k3s.io en /tmp/k3s-install.sh y luego lo ejecuta directamente por su ruta. La ejecución directa necesita el bit de ejecución en el punto de montaje; leer el mismo archivo como argumento de sh, no. Arreglo de una línea (sh /tmp/k3s-install.sh en lugar de /tmp/k3s-install.sh) antes de activar noexec, para que una futura instalación desde cero con este mismo playbook no falle en silencio en una máquina que ni siquiera estoy mirando. Verificado tras el cambio: leer y escribir en /tmp sigue funcionando, kubectl apply -f /tmp/whatever.yml sigue funcionando (lee el YAML como datos, no lo ejecuta) y un script de prueba ejecutable colocado a propósito recibió un «Permission denied» limpio y correcto. 🎯4️⃣ 🪵 journald, core dumps, permisos de cron/at, bastionado de sudo, Bluetooth, rsync
El lote poco glamuroso pero fácil: journald ahora guarda en disco de forma persistente (comprimido y limitado a 500MB, para que «persistente» no se convierta sin avisar en «llena el disco»), los core dumps están desactivados en todos los sitios donde podrían generarse (un core dump no es más que una instantánea de la memoria, y en una máquina con una base de datos y un login de administración, es una instantánea que podría contener una contraseña en texto plano en pleno vuelo; no es algo que quiera tener por ahí tirado, ni siquiera en local), los directorios de cron/at tienen permisos más estrictos y una lista de permitidos explícita, y sudo ahora registra en su propio archivo, exige un pty y vuelve a pedir autenticación cada vez en lugar de guardarla en caché. Bluetooth quedó enmascarado (un VPS no tiene hardware Bluetooth, el servicio simplemente estaba funcionando sin motivo) yrsync se desinstaló después de comprobarlo de verdad: ni un solo script, tarea de cron o tarea de Ansible de este host hace referencia a él. Si algún día lo vuelvo a necesitar, está a un dnf install de distancia. 🧹5️⃣ 🎭 Los hallazgos de SSH que ya se cumplían, y el que systemd-run no sobrevivió
Aquí viene lo divertido. Varias comprobaciones de SSH salieron FAIL en ajustes que estaba bastante seguro de tener ya bien: restricciones de inicio de sesión de root, autenticación basada en host, opciones de entorno. Comprobé dos y hasta tres veces la configuración real en vivo de sshd consshd -T (el comando que muestra lo que sshd aplicaría de verdad, no solo lo que está escrito), tanto desde una sesión SSH normal como desde una escapada con systemd-run, para descartar otra ilusión de sandbox como la de AIDE de más arriba. Resultados idénticos y correctos las dos veces. Así que no era una mentira de la sandbox, sino un hueco real de otro tipo: OpenSSH ya usa por defecto el comportamiento seguro en algunos de estos puntos, pero yo nunca había escrito la directiva de forma explícita, y un escáner de cumplimiento estricto revisa el archivo de configuración al pie de la letra, no un «bueno, el valor por defecto resulta estar bien». Añadí directamente las tres líneas que faltaban: cinturón y tirantes, y así una futura actualización de OpenSSH que cambie un valor por defecto no alteraría mi postura de seguridad sin que me diera cuenta. Uno lo dejé deliberadamente tal cual: CIS quiere PermitRootLogin no, es decir, el acceso SSH como root bloqueado por completo y punto. Yo uso PermitRootLogin without-password: solo con clave, pero sigue siendo root. En esta máquina no hay una cuenta de administración aparte con sudo; Ansible se conecta directamente como root. Desactivar del todo el login de root sin crear antes un usuario de administración dedicado habría sido una forma extremadamente eficaz de quedarme fuera del único acceso a un servidor que solo administro yo. Anotado en el código y dejado en paz. Y la nota a pie de página realmente técnica para quien intente reproducir este flujo de trabajo: ejecutar el escaneo completo de OpenSCAP envuelto en systemd-run --wait --pipe (para esquivar la sandbox de SSH, el mismo truco que en el arreglo de AIDE) fallaba sistemáticamente con «Remote peer disconnected» en cuanto mandaba a segundo plano el comando SSH que lo había lanzado. Mi mejor hipótesis: la sesión DBus de systemd-run acababa ligada a la sesión de login que PAM había creado para esa conexión SSH, y en cuanto me desvinculaba de ella (incluso con un simple nohup + disown a nivel de shell), el enlace DBus moría con ella. Por lo visto, los trabajos largos con systemd-run y el «lo lanzo por SSH y me voy» no se llevan bien. Lo esquivé comprobando una a una el puñado de reglas afectadas con llamadas cortas a systemd-run, en lugar de forzar todo el escaneo de varios minutos a pasar por ahí.🤷 Lo que dejé en paz a propósito
Mismo principio que en el artículo de kube-bench: un resultado en rojo no es automáticamente una tarea pendiente, y nada de lo siguiente le da a un atacante algo que no tuviera ya:- 🔐 PAM / authselect / calidad de contraseñas / política de bloqueo de cuentas: el mayor bloque de FAIL restantes, y el que de forma más deliberada todavía no toco.
authselectinformó «no existing configuration detected» en este host, lo que significa que PAM nunca ha estado bajo su gestión aquí. Activarlo ahora implica reescribir a fondo/etc/pam.d/system-authypassword-auth; si me equivoco, podría quedarme sin ningún login local, sinsuy sinsudoen un servidor para el que no tengo confirmado ningún acceso a una consola de rescate. SSH ya funciona solo con clave, así que la ganancia real en seguridad es modesta frente a un riesgo verdaderamente catastrófico. Esto tendrá su propia ventana de mantenimiento cuidadosamente probada, no un apaño rápido un jueves por la noche. - 🔑 Contraseña del gestor de arranque GRUB2: un control contra el acceso físico a la consola, que en su mayor parte no aplica a un VPS alquilado y que podría chocar con las propias herramientas de consola de rescate del proveedor.
- 🔏 Política criptográfica personalizada a nivel de sistema para CIS: cambia qué cifrados TLS/SSH acepta toda la máquina. Un riesgo real de romper la compatibilidad con algo (un cliente antiguo que visita el blog, mis propias herramientas) sin pruebas específicas antes. Tampoco es para un apaño rápido un jueves por la noche.
- 📡 systemd-journal-remote: CIS quiere que esté instalado para el envío centralizado de logs. No tengo nada configurado para recibir esos logs. Instalar un demonio que nadie usa no bastiona nada; solo amplía la superficie de ataque sin ningún beneficio.




