Las cinco lecciones anteriores han añadido medidas de seguridad de una en una: red controlada, SSH con claves, cortafuegos, detección de intrusos, secretos cifrados y TLS. Cada una resolvía un problema concreto. Esta lección hace algo distinto: las convierte en una postura de seguridad, que es un conjunto coherente de decisiones justificadas por un modelo de amenazas explícito, con constancia escrita de qué está protegido, qué se ha decidido asumir y qué queda fuera del alcance de un administrador.

La diferencia no es retórica. Sin modelo de amenazas, el endurecimiento degenera en cargo cult: se aplican recetas encontradas en guías porque «suenan seguras», se rompen cosas sin entender por qué, y se acaba con un sistema frágil y una falsa sensación de control. Con modelo de amenazas, cada medida tiene un porqué y cada omisión es una decisión consciente.

Además cierras aquí el tercer incidente pendiente —los servicios que siguen usando la libssl vulnerable cargada en memoria— y saldas la deuda que dejaste en 05-01: PAM, el subsistema que decide de verdad cómo se autentica alguien en este sistema.

Advertencia previa. Dos de los apartados de esta lección —PAM y los montajes seguros— pueden dejarte sin poder iniciar sesión en tu propio servidor si te equivocas. En ambos casos se indica el procedimiento seguro. No te lo salte: el laboratorio existe precisamente para que aprendas esto donde equivocarse no cuesta nada.

Contenido

  1. Los cinco principios que ordenan todo lo anterior
  2. El modelo de amenazas de Tramontana
  3. Reducción de la superficie de ataque
  4. Endurecimiento de cuentas y política de contraseñas
  5. PAM: cómo se autentica realmente este sistema
  6. Control de acceso obligatorio con AppArmor
  7. Endurecimiento del kernel con sysctl
  8. Montajes seguros
  9. Gestión de vulnerabilidades y cierre del incidente de openssl
  10. Endurecimiento de systemd revisitado
  11. Copias como último control frente al ransomware
  12. Checklist de endurecimiento y mantenimiento de la postura

Los cinco principios que ordenan todo lo anterior

Todo lo que has hecho en el módulo responde, sin que lo hayamos dicho, a cinco principios. Nombrarlos permite aplicarlos a situaciones nuevas en lugar de repetir recetas:

Principio Qué exige Dónde lo has aplicado ya
Mínimo privilegio Cada proceso y cada persona con los permisos justos, ni uno más svc-tramontana sin shell, sudoers con comandos concretos, CapabilityBoundingSet= vacío
Superficie de ataque mínima Lo que no existe no se puede atacar Servidor sin escritorio, puerto 8080 cerrado al exterior, política de lista blanca en ufw
Defensa en profundidad Varias capas independientes; que una falle no debe bastar Firewall + SSH endurecido + AIDE + auditd + secretos cifrados
Fallo seguro Cuando algo se rompe, debe quedar cerrado, no abierto policy drop por defecto en nftables, set -euo pipefail en los scripts
Seguro por defecto El estado inicial debe ser el restrictivo umask 027, permisos 600 en los secretos, PasswordAuthentication no

El más difícil de aplicar en la práctica es el segundo, porque exige quitar cosas, y quitar cosas da miedo. El más olvidado es el cuarto: mucha configuración «segura» falla abriéndose, y eso es peor que no tenerla, porque nadie se enterará.

El modelo de amenazas de Tramontana

Un modelo de amenazas responde a tres preguntas. Media página es suficiente, y es la media página que hace que todo lo demás tenga sentido.

¿Qué protegemos? Dos cosas, en este orden:

  1. Los datos personales de los huéspedes (nombres, fechas de estancia, importes en reservas.csv y en la base de datos). Su compromiso es un daño a terceros, tiene consecuencias legales bajo el RGPD y es irreversible: una vez filtrados, no se pueden «recuperar».
  2. La disponibilidad del servicio de reservas. Su interrupción tiene un coste económico directo y acotado en el tiempo.

¿De quién? Cuatro actores realistas, ordenados por probabilidad:

Amenaza Probabilidad Capacidad Controles que le oponemos
Escaneo automatizado de Internet Constante Baja: explota lo conocido y sin parchear Firewall, fail2ban, actualizaciones de seguridad, SSH sin contraseña
Credencial filtrada o reutilizada Media Alta: entra como usuario legítimo Claves en vez de contraseñas, secretos cifrados, rotación, auditd
Error propio de configuración Alta Variable, a veces total --dry-run, copias antes de editar, netplan try, mount -a, AIDE
Abuso de acceso legítimo Baja Alta dentro de su ámbito Mínimo privilegio en sudoers, ACL, auditoría de accesos

Fíjate en la tercera fila. El actor más probable eres tú, y esa constatación explica por qué media docena de convenciones del curso —copiar antes de editar, verificar con diff -u, probar en seco, netplan try con reversión automática, mount -a antes de reiniciar— son controles de seguridad de pleno derecho, y no manías de estilo.

¿Qué asumimos fuera de alcance? Decirlo es tan importante como lo anterior, porque delimita qué no se está protegiendo:

  • Atacante con recursos estatales o cadena de suministro comprometida. Está fuera del alcance de cualquier medida que un administrador pueda tomar en un servidor de una pequeña empresa.
  • Acceso físico a la máquina. El cifrado LUKS mitiga el robo del disco, pero un atacante con acceso físico prolongado y la máquina encendida tiene el sistema.
  • Vulnerabilidad en la propia aplicación (inyección SQL, lógica de negocio). Es responsabilidad del desarrollo, no de la administración, y el endurecimiento del sistema solo limita el daño posterior.
  • Compromiso del proveedor de la nube o del hipervisor.

Y ahora el criterio de fondo: con este modelo, invertir en un NIDS aporta poco (ya lo razonaste en 06-04) e invertir en detección de cambios y en control de la autenticación aporta mucho. Eso es tomar decisiones, no seguir una lista.

Reducción de la superficie de ataque

Lo que no existe no se puede atacar, no necesita parches y no aparece en ningún CVE. Es el principio con mejor relación entre esfuerzo y resultado.

Qué escucha

$ sudo ss -tulpn
Netid State  Local Address:Port  Peer Address:Port Process
udp   UNCONN 127.0.0.54:53       0.0.0.0:*         users:(("systemd-resolve",pid=712,fd=17))
udp   UNCONN 127.0.0.53%lo:53    0.0.0.0:*         users:(("systemd-resolve",pid=712,fd=13))
tcp   LISTEN 127.0.0.53%lo:53    0.0.0.0:*         users:(("systemd-resolve",pid=712,fd=14))
tcp   LISTEN 0.0.0.0:22          0.0.0.0:*         users:(("sshd",pid=894,fd=3))
tcp   LISTEN 0.0.0.0:8080        0.0.0.0:*         users:(("tramontana",pid=4102,fd=6))
tcp   LISTEN 10.0.2.15:5432      0.0.0.0:*         users:(("postgres",pid=1105,fd=5))

Lee cada línea preguntando «¿tiene que estar ahí, y en esa dirección?»:

  • systemd-resolve en 127.0.0.53 y 127.0.0.54: local, correcto.
  • sshd en 0.0.0.0:22: necesario, y ya endurecido.
  • tramontana en 0.0.0.0:8080: escucha en todas las interfaces. El cortafuegos lo bloquea desde fuera, pero eso es defensa en una sola capa. Lo correcto es que la aplicación escuche solo en 127.0.0.1, porque cuando llegue el proxy inverso de 08-01 será quien le hable, y así el firewall pasa a ser la segunda línea en lugar de la única.
  • postgres en 10.0.2.15:5432: ya restringido a la interfaz interna. Correcto.

El primer arreglo, entonces:

$ sudo cp -p /etc/tramontana/app.conf /etc/tramontana/app.conf.bak-$(date +%F)
$ sudo chattr -i /etc/tramontana/app.conf
$ echo 'escucha=127.0.0.1' | sudo tee -a /etc/tramontana/app.conf
$ sudo chattr +i /etc/tramontana/app.conf
$ sudo systemctl restart tramontana.service
$ sudo ss -tulpn | grep 8080
tcp   LISTEN 127.0.0.1:8080  0.0.0.0:*  users:(("tramontana",pid=4318,fd=6))

Eso es defensa en profundidad aplicada: ahora hacen falta dos fallos —una regla de firewall mal puesta y una escucha demasiado amplia— para exponer el 8080. Antes bastaba uno.

Qué está habilitado

$ systemctl list-unit-files --state=enabled --type=service --no-pager
UNIT FILE                    STATE   PRESET
apparmor.service             enabled enabled
auditd.service               enabled enabled
cron.service                 enabled enabled
fail2ban.service             enabled enabled
ModemManager.service         enabled enabled
multipathd.service           enabled enabled
postgresql.service           enabled enabled
ssh.service                  enabled enabled
tramontana.service           enabled enabled
unattended-upgrades.service  enabled enabled

ModemManager gestiona módems y banda ancha móvil. En una máquina virtual de servidor no tiene ningún sentido. Y aquí conviene entender la diferencia entre las dos formas de apagar algo, porque no son equivalentes:

Operación Qué hace Puede reactivarse solo
disable Borra los enlaces de [Install]; no arranca al inicio Sí: si otra unidad lo declara en Wants=
mask Enlaza la unidad a /dev/null; es inarrancable No: ni a mano ni por dependencia
$ sudo systemctl disable --now ModemManager.service
$ sudo systemctl mask ModemManager.service
Created symlink /etc/systemd/system/ModemManager.service → /dev/null.

mask para lo que no debe arrancar nunca; disable para lo que podrías querer arrancar a mano algún día. Y el paso siguiente, cuando estés seguro, es desinstalar el paquete: un binario que no está en el disco no tiene vulnerabilidades.

$ sudo apt purge modemmanager
$ sudo apt autoremove --purge

Sobre el escritorio, para cerrar el apartado: un entorno gráfico añade cientos de paquetes, un servidor X o Wayland, un gestor de sesiones y un navegador — cada uno con su superficie y su calendario de parches. Que srv-tramontana no lo tenga no es austeridad, es la reducción de superficie más grande que se puede hacer en un servidor, y por eso el instalador de Ubuntu Server no lo ofrece por defecto.

Endurecimiento de cuentas y política de contraseñas

Tres auditorías que conviene tener guardadas en un script, porque detectan errores graves y son de una línea cada una:

# 1. Cuentas con UID 0: solo debe haber una, root
$ awk -F: '($3 == 0) {print $1}' /etc/passwd
root

# 2. Cuentas sin contraseña (segundo campo vacio en shadow): grave
$ sudo awk -F: '($2 == "") {print $1 " NO TIENE CONTRASENA"}' /etc/shadow

# 3. Cuentas de sistema (UID < 1000) con shell de inicio de sesion valida
$ awk -F: '($3 < 1000 && $7 !~ /(nologin|false|sync)$/) {print $1, $3, $7}' /etc/passwd
root 0 /bin/bash
sync 4 /bin/sync

Los tres resultados son correctos: un único UID 0, ninguna cuenta sin contraseña, y las dos únicas cuentas de sistema con shell son root —necesario para la recuperación— y sync, que es un caso histórico inofensivo. Que svc-tramontana no aparezca en la tercera lista es la confirmación de que el -s /usr/sbin/nologin de 05-01 hizo su trabajo.

La política por defecto vive en /etc/login.defs, y afecta a las cuentas que se creen a partir de ahora:

# /etc/login.defs (valores modificados)
PASS_MAX_DAYS   365     # caducidad maxima
PASS_MIN_DAYS   1       # evita que se cambie varias veces seguidas para volver a la anterior
PASS_WARN_AGE   14      # dias de aviso antes de caducar
UMASK           027     # coherente con la convencion del curso
SHA_CRYPT_MIN_ROUNDS 5000
ENCRYPT_METHOD  YESCRYPT

PASS_MIN_DAYS 1 es menos obvio que los demás: sin él, alguien a quien se le exige cambiar la contraseña puede cambiarla cinco veces seguidas para agotar el historial y volver a la original. Es un truco viejo y sigue funcionando donde no se pone.

Y para las cuentas ya existentes, chage:

$ sudo chage -M 365 -m 1 -W 14 luis
$ sudo chage -l luis
Last password change                                    : ago 04, 2026
Password expires                                        : ago 04, 2027
Password inactive                                       : never
Account expires                                         : never
Minimum number of days between password change           : 1
Maximum number of days between password change           : 365
Number of days of warning before password expires        : 14

# La cuenta del becario tiene fecha de fin de practicas: caduca automaticamente
$ sudo chage -E 2026-12-31 becario
$ sudo chage -l becario | grep 'Account expires'
Account expires                                         : dic 31, 2026

chage -E merece atención: es la forma correcta de gestionar una cuenta temporal. Caduca sola en la fecha prevista, sin depender de que alguien se acuerde de darla de baja — y las bajas de personal olvidadas son una de las vías de compromiso más comunes que existen.

PAM: cómo se autentica realmente este sistema

En 05-01 aplazamos PAM. Es el momento, porque es la pieza que decide de verdad quién entra en este sistema y con qué requisitos.

PAM (Pluggable Authentication Modules) es una capa de indirección: los programas que necesitan autenticar —login, sshd, sudo, su, passwd— no implementan la lógica, sino que preguntan a PAM. Eso permite cambiar la política (exigir contraseñas fuertes, bloquear tras fallos, añadir un segundo factor) sin modificar ni recompilar ningún programa. Es la razón por la que existe.

La estructura

Cada servicio tiene su fichero en /etc/pam.d/, y hay ficheros comunes que los demás incluyen:

$ ls /etc/pam.d/ | head -12
common-account
common-auth
common-password
common-session
cron
login
passwd
sshd
su
sudo

Cada línea tiene tres partes: tipo, control y módulo.

Los cuatro tipos, que son cuatro preguntas distintas y responden en momentos distintos:

Tipo Pregunta que responde
auth ¿Es quien dice ser? (contraseña, clave, segundo factor)
account ¿Se le permite entrar ahora? (cuenta caducada, horario, origen)
password ¿Es aceptable la contraseña nueva que quiere poner?
session Qué hay que preparar y limpiar alrededor de la sesión (límites, registro, home)

Confundir auth con account es el malentendido más habitual: una contraseña correcta en una cuenta caducada pasa auth y falla account.

Los controles determinan qué ocurre según el resultado del módulo:

Control Si el módulo falla Si tiene éxito
required Falla el conjunto, pero se siguen ejecutando los demás Continúa
requisite Falla y se detiene ahí mismo Continúa
sufficient Se ignora Éxito inmediato, si nada required anterior falló
optional Se ignora (salvo que sea el único) Continúa

required frente a requisite tiene una razón de ser que no es evidente: required sigue ejecutando la pila para no revelar en qué paso falló. Si un atacante pudiera distinguir «usuario inexistente» de «contraseña incorrecta» por el tiempo de respuesta o el mensaje, tendría un oráculo para enumerar usuarios.

La sintaxis moderna, más precisa, usa corchetes: [success=ok default=die] permite especificar la acción para cada código de retorno. Es lo que verás en los ficheros de Ubuntu.

El procedimiento para no dejarse fuera

Antes de tocar una sola línea. Un error de sintaxis en common-auth puede impedir todo inicio de sesión, incluida la consola local, y entonces la única salida es el modo de recuperación:

  1. Deja una sesión de root abierta en otra terminal, y no la cierres hasta haber terminado:
    $ sudo -i
    # (no cerrar esta sesion)
    
  2. Copia el fichero antes de editarlo, según la convención del curso:
    $ sudo cp -p /etc/pam.d/common-password /etc/pam.d/common-password.bak-$(date +%F)
    
  3. Prueba en una tercera terminal, nunca en la que tienes la sesión de root.
  4. Ten a mano la consola de la VM y sabe cómo entrar en modo de recuperación (se ve a fondo en 07-01).

Es exactamente la misma disciplina de netplan try y de la segunda sesión SSH: nunca cierres la puerta por la que estás entrando.

pam_pwquality: exigir contraseñas decentes

$ sudo apt install libpam-pwquality
# /etc/security/pwquality.conf
minlen = 12          # longitud minima
minclass = 3         # al menos 3 de: minusculas, mayusculas, digitos, simbolos
maxrepeat = 3        # no mas de 3 caracteres iguales consecutivos
dictcheck = 1        # rechaza palabras de diccionario
usercheck = 1        # rechaza contrasenas que contengan el nombre de usuario
enforce_for_root = 1 # tambien a root: sin esto, root queda exento
retry = 3
# /etc/pam.d/common-password (linea modificada)
password  requisite  pam_pwquality.so retry=3
$ passwd luis
New password: tramontana2026
Bad password: it is based on a dictionary word.
New password: Kj8-mQ2vX9pL
passwd: password updated successfully

Dos comentarios de fondo. El primero: minlen = 12 con minclass = 3 es más razonable que las políticas de «8 caracteres con mayúscula, número y símbolo» que producen Passw0rd! — la longitud aporta mucho más que la complejidad forzada, y así lo recogen las guías actuales (NIST SP 800-63B). El segundo: enforce_for_root = 1 es fácil de olvidar, y sin él la cuenta más importante del sistema es la única exenta de la política.

pam_faillock: bloquear tras intentos fallidos

Complementa a fail2ban, que actúa sobre la IP; faillock actúa sobre la cuenta, así que cubre también los intentos desde la consola o desde IP distintas.

# /etc/security/faillock.conf
deny = 5              # bloquea tras 5 fallos
fail_interval = 900   # que ocurran dentro de 15 minutos
unlock_time = 600     # desbloqueo automatico a los 10 minutos
even_deny_root        # tambien a root
root_unlock_time = 60 # pero root se desbloquea en 1 minuto: evita el bloqueo total
audit                 # registra el intento
silent                # no revela al atacante que la cuenta esta bloqueada
# /etc/pam.d/common-auth
auth  required  pam_faillock.so preauth
auth  [success=1 default=ignore]  pam_unix.so nullok
auth  [default=die]  pam_faillock.so authfail
auth  sufficient  pam_faillock.so authsucc
auth  requisite  pam_deny.so
auth  required  pam_permit.so

El orden importa y no es intuitivo: preauth comprueba si la cuenta ya está bloqueada antes de pedir credenciales, authfail cuenta el fallo, y authsucc limpia el contador tras un acceso correcto. Faltar authsucc produce un bloqueo lento pero inevitable, porque el contador nunca se reinicia.

Y la combinación de even_deny_root con root_unlock_time = 60 es deliberada: proteger root sin crear la posibilidad de un bloqueo total del sistema.

$ faillock --user luis
luis:
When                Type  Source                                           Valid
2026-08-18 14:22:11 RHOST 203.0.113.44                                         V
2026-08-18 14:22:14 RHOST 203.0.113.44                                         V

$ sudo faillock --user luis --reset     # desbloquear a mano

pam_limits: límites de recursos

# /etc/security/limits.conf
# dominio    tipo    elemento  valor
*            hard    nproc     4096      # frena una bomba de procesos
*            hard    core      0         # no generar volcados: pueden contener secretos
svc-tramontana soft  nofile    8192
svc-tramontana hard  nofile    16384

Dos de estas líneas son medidas de seguridad más que de rendimiento. nproc limita el daño de un bucle que crea procesos sin control —accidental o no—. Y core 0 evita los volcados de memoria, que contienen todo lo que el proceso tenía en RAM en ese momento: incluida la contraseña que tanto trabajo te ha costado cifrar en 06-05.

Ojo con el alcance: pam_limits se aplica a sesiones que pasan por PAM. Para un servicio de systemd, los límites se ponen en la unidad (LimitNOFILE, LimitNPROC), como viste en 05-07.

Segundo factor, brevemente

libpam-google-authenticator añade un código TOTP a la autenticación SSH. Es eficaz contra el robo de credenciales, y en un servidor con acceso por clave el beneficio marginal es menor que en uno con contraseñas. Si se implanta, hay dos cosas que no se pueden olvidar: guardar los códigos de recuperación fuera del servidor, y decidir qué pasa si el dispositivo TOTP se pierde. Sin eso, es una forma elegante de quedarse fuera.

Cerrar el hallazgo: la excepción de PasswordAuthentication

En 06-04 los registros mostraron que luis entró con password, pese a que en 06-02 lo deshabilitaste. Hora de localizarlo, empezando por la fuente autorizada:

$ sudo sshd -T | grep -i -E 'passwordauth|kbdinteractive'
passwordauthentication no
kbdinteractiveauthentication no

La configuración global es correcta. Pero sshd -T sin argumentos muestra la configuración global; los bloques Match son condicionales y no aparecen ahí. Hay que preguntar por el caso concreto:

$ sudo grep -rn -A3 'Match' /etc/ssh/sshd_config /etc/ssh/sshd_config.d/
/etc/ssh/sshd_config.d/60-tramontana.conf:12:Match User luis
/etc/ssh/sshd_config.d/60-tramontana.conf-13-    PasswordAuthentication yes

$ sudo sshd -T -C user=luis,host=portatil-luis,addr=10.0.2.44 | grep -i passwordauth
passwordauthentication yes

Ahí está. Un bloque Match User luis con la excepción, puesta «temporalmente» mientras Luis generaba su clave, y nunca retirada. Es el actor más probable del modelo de amenazas —el error propio— en su forma más característica.

$ sudo cp -p /etc/ssh/sshd_config.d/60-tramontana.conf{,.bak-$(date +%F)}
$ sudo sed -i '/^Match User luis$/,+1d' /etc/ssh/sshd_config.d/60-tramontana.conf
$ sudo sshd -t && echo "sintaxis correcta"
sintaxis correcta
$ sudo sshd -T -C user=luis,host=portatil-luis,addr=10.0.2.44 | grep -i passwordauth
passwordauthentication no
$ sudo systemctl reload ssh

Fíjate en sshd -T -C: la opción -C evalúa la configuración para un contexto concreto de usuario, host y dirección, resolviendo los bloques Match. Es la única forma fiable de saber qué configuración se aplica a alguien, y merece un sitio en tu memoria: la mitad de los problemas desconcertantes de SSH son un Match olvidado.

Antes de recargar, la disciplina de siempre: sshd -t valida, la segunda sesión está abierta, y se usa reload en lugar de restart.

Control de acceso obligatorio con AppArmor

Los permisos que conoces desde 02-07 son DAC (Discretionary Access Control): discrecionales porque el propietario de un recurso decide quién accede. Tienen un límite estructural: un proceso puede hacer todo lo que su usuario puede hacer. Si la aplicación de Tramontana se ve comprometida, el atacante puede leer cualquier cosa legible por svc-tramontana y escribir en cualquier sitio donde ese usuario escriba.

MAC (Mandatory Access Control) añade una capa que el propietario no puede relajar: una política, definida por el administrador y aplicada por el kernel, que dice qué puede hacer este programa, con independencia de quién lo ejecute.

DAC (permisos Unix) MAC (AppArmor / SELinux)
Quién decide El propietario del fichero El administrador, en la política
Unidad de control Usuario y grupo Programa (perfil)
Puede el proceso relajarlo Sí, dentro de lo de su usuario No
Qué contiene Un compromiso del proceso Un compromiso del proceso y de su usuario
$ sudo aa-status
apparmor module is loaded.
32 profiles are loaded.
28 profiles are in enforce mode.
   /usr/bin/man
   /usr/sbin/sshd
   ...
2 profiles are in complain mode.
2 processes have profiles defined.

Los dos modos son la clave del método de trabajo:

  • complain: no bloquea, solo registra lo que habría bloqueado. Es el modo de aprendizaje.
  • enforce: bloquea. Es el modo de producción.

Y el procedimiento profesional es siempre el mismo: escribir el perfil, ponerlo en complain, ejercitar la aplicación de verdad, recoger las denegaciones y ampliar el perfil, y solo entonces pasar a enforce. Empezar en enforce significa romper el servicio y descubrir los accesos que faltan de la peor manera.

Un perfil para la aplicación de Tramontana

$ sudo apt install apparmor-utils
# /etc/apparmor.d/opt.tramontana.app.tramontana
abi <abi/4.0>,
include <tunables/global>

/opt/tramontana/releases/*/tramontana {
  include <abstractions/base>
  include <abstractions/nameservice>
  include <abstractions/openssl>

  # El binario propio: leer y ejecutar
  /opt/tramontana/releases/*/tramontana        mr,
  /opt/tramontana/releases/*/**                r,
  /opt/tramontana/app/**                       r,

  # Configuracion: solo lectura. Aunque el proceso se vea comprometido,
  # no puede modificar su propia configuracion.
  /etc/tramontana/app.conf                     r,

  # Credencial entregada por systemd (tmpfs privado del servicio)
  /run/credentials/tramontana.service/*        r,

  # Registros propios: crear y anadir, nunca sobrescribir ni borrar
  /var/log/tramontana/                         r,
  /var/log/tramontana/*.log                    rw,

  # Datos subidos por los huespedes: lectura y escritura
  /opt/tramontana/shared/uploads/              r,
  /opt/tramontana/shared/uploads/**            rwk,

  # Red: solo TCP sobre IPv4/IPv6. No unix crudo, no paquetes crudos.
  network inet stream,
  network inet6 stream,

  # Nada mas esta permitido: AppArmor deniega por defecto.
  # En particular, quedan DENEGADOS de forma implicita:
  #   /etc/shadow, /home/**, /srv/tramontana/backups/**,
  #   /root/**, la ejecucion de /bin/sh y la carga de modulos.
}

Ese último comentario es lo que hace valioso el perfil. Un atacante que ejecute código en el contexto de la aplicación no puede lanzar una shell, no puede leer las copias, no puede tocar los directorios personales y no puede modificar su propia configuración — cosas que el DAC sí le permitiría hacer en parte, y que son precisamente los primeros pasos de cualquier post-explotación.

# 1. Cargar en modo aprendizaje
$ sudo apparmor_parser -r /etc/apparmor.d/opt.tramontana.app.tramontana
$ sudo aa-complain /opt/tramontana/releases/*/tramontana

# 2. Ejercitar la aplicacion de verdad: peticiones, subida de fichero, despliegue
$ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/casas
200
$ ~/scripts/revision_salud.sh; echo "estado: $?"
estado: 0

# 3. Recoger las denegaciones registradas
$ sudo journalctl -k --since "10 min ago" | grep 'apparmor="ALLOWED"'
kernel: audit: type=1400 apparmor="ALLOWED" operation="open"
  profile="/opt/tramontana/releases/*/tramontana" name="/opt/tramontana/shared/plantillas/factura.html"
  pid=4318 comm="tramontana" requested_mask="r" denied_mask="r"

En modo complain la marca es ALLOWED con un denied_mask: significa «esto lo habría bloqueado». Aquí ha aparecido un acceso legítimo que el perfil no contemplaba —las plantillas—, así que se añade y se repite el ciclo:

  /opt/tramontana/shared/plantillas/**         r,
# 4. Ampliar de forma asistida si se prefiere, y pasar a enforce
$ sudo aa-logprof
$ sudo aa-enforce /opt/tramontana/releases/*/tramontana
Setting /opt/tramontana/releases/*/tramontana to enforce mode.
$ sudo systemctl restart tramontana.service
$ ~/scripts/revision_salud.sh; echo "estado: $?"
estado: 0

A partir de aquí, una denegación real aparece como apparmor="DENIED", y es lo primero que hay que mirar cuando el servicio empieza a fallar tras un despliegue que cambia rutas:

$ sudo journalctl -k -f | grep apparmor

SELinux, para quien vaya a RHEL:

AppArmor SELinux
Distribuciones Ubuntu, Debian, SUSE RHEL, Fedora, CentOS Stream
Identifica objetos por Ruta del fichero Etiqueta en el inodo
Curva de aprendizaje Suave; perfiles legibles Pronunciada; más potente
Diagnóstico journalctl + aa-logprof ausearch + audit2allow, restorecon
Modo permisivo Por perfil (complain) Global o por dominio

La diferencia por ruta frente a etiqueta tiene una consecuencia práctica: en SELinux, mover un fichero puede cambiar su contexto y romper el acceso (de ahí restorecon); en AppArmor, un enlace simbólico o un bind mount puede saltarse una regla basada en ruta. Cada modelo tiene su punto débil.

Endurecimiento del kernel con sysctl

En 06-03 aplicaste los sysctl de red. Estos son los del propio kernel, y son solo de seguridad: los de rendimiento se ven en 07-03.

# /etc/sysctl.d/60-endurecimiento.conf

# Solo root puede leer el buffer del kernel. Evita filtrar direcciones de
# memoria y detalles del hardware utiles para construir un exploit.
kernel.dmesg_restrict = 1

# Oculta los punteros del kernel en /proc y en los registros.
kernel.kptr_restrict = 2

# ASLR completo: aleatoriza el espacio de direcciones, incluido el heap.
# 2 es el valor por defecto en Ubuntu; se declara para que sea explicito.
kernel.randomize_va_space = 2

# Impide crear enlaces duros a ficheros de los que no eres propietario y
# seguir enlaces simbolicos ajenos en directorios con sticky bit.
# Cierra toda una familia de ataques de carrera en /tmp.
fs.protected_hardlinks = 1
fs.protected_symlinks = 1

# La misma proteccion para FIFOs y ficheros regulares en directorios
# escribibles por todos.
fs.protected_fifos = 2
fs.protected_regular = 2

# Solo root puede usar BPF. Reduce mucho la superficie del subsistema.
kernel.unprivileged_bpf_disabled = 1

# Un proceso solo puede depurar a sus descendientes directos.
# Impide que un proceso comprometido lea la memoria de otro del mismo usuario
# (y con ella los secretos que ese otro tenga cargados).
kernel.yama.ptrace_scope = 1

# No permitir a usuarios sin privilegios crear espacios de nombres de usuario.
# ATENCION: rompe los contenedores sin privilegios. Comentado porque en
# 07-05 vas a necesitarlo; descomentar solo en servidores sin contenedores.
#kernel.unprivileged_userns_clone = 0
$ sudo sysctl --system 2>&1 | grep -A9 '60-endurecimiento'
* Applying /etc/sysctl.d/60-endurecimiento.conf ...
kernel.dmesg_restrict = 1
kernel.kptr_restrict = 2
kernel.randomize_va_space = 2
fs.protected_hardlinks = 1
fs.protected_symlinks = 1
fs.protected_fifos = 2
fs.protected_regular = 2
kernel.unprivileged_bpf_disabled = 1
kernel.yama.ptrace_scope = 1

$ sysctl kernel.yama.ptrace_scope
kernel.yama.ptrace_scope = 1

Dos avisos que debes anotar, porque son las dos formas de que esto te muerda más adelante:

  • kernel.yama.ptrace_scope = 1 puede romper depuradores. strace o gdb sobre un proceso que ya está en marcha y no es hijo tuyo dejará de funcionar sin sudo. En 07-02 vas a usar exactamente esas herramientas; recuerda que este parámetro es la explicación si te da Operation not permitted.
  • kernel.unprivileged_userns_clone = 0 rompe contenedores sin privilegios, y en 07-05 vas a montar Docker. Por eso queda comentado con la razón escrita al lado. Un parámetro de endurecimiento con una nota explicando por qué no está activado es documentación de calidad; borrarlo y olvidar el motivo no lo es.

Y el criterio general: cada línea de este fichero lleva su comentario. Un sysctl.d con quince valores sin explicación es imposible de mantener, porque nadie —tú incluido, en seis meses— sabrá si se puede quitar alguno.

Montajes seguros

Tres opciones de montaje que limitan el daño en los directorios donde cualquiera puede escribir:

Opción Efecto
noexec No se puede ejecutar nada desde ahí
nosuid Se ignoran los bits SUID/SGID
nodev Se ignoran los ficheros de dispositivo

El caso de uso es directo: un atacante que consigue escribir un fichero suele necesitar ejecutarlo, y /tmp es el sitio donde casi siempre puede escribir. Con noexec, ese paso falla.

Y aquí es donde la lección insiste en algo que las guías de endurecimiento suelen omitir: noexec en /tmp rompe cosas reales. Varios instaladores extraen a /tmp y ejecutan desde allí; algunos gestores de paquetes de lenguajes también. Hay que comprobarlo antes:

# 1. Comprobar el impacto en caliente, sin tocar fstab
$ sudo mount -o remount,noexec,nosuid,nodev /tmp

# 2. Ejercitar lo que podria romperse
$ sudo apt install --reinstall -y tree >/dev/null && echo "apt: correcto"
apt: correcto
$ ~/scripts/desplegar.sh --dry-run 3.2.1 && echo "despliegue: correcto"
despliegue: correcto
$ sudo needrestart -r l >/dev/null && echo "needrestart: correcto"
needrestart: correcto

# 3. Y comprobar que la restriccion realmente actua
$ printf '#!/bin/sh\necho hola\n' > /tmp/prueba.sh && chmod +x /tmp/prueba.sh
$ /tmp/prueba.sh
bash: /tmp/prueba.sh: Permiso denegado

Ese último bloque es la verificación positiva: no basta con que nada se haya roto, hay que comprobar que la medida hace algo. Con las dos comprobaciones hechas, se hace persistente:

# /etc/fstab
UUID=3f8a...  /tmp      ext4  defaults,noatime,noexec,nosuid,nodev  0  2
/tmp          /var/tmp  none  bind,noexec,nosuid,nodev              0  0
tmpfs         /dev/shm  tmpfs defaults,noexec,nosuid,nodev          0  0
$ sudo systemctl daemon-reload
$ sudo mount -a && echo "fstab correcto"
fstab correcto
$ findmnt -o TARGET,OPTIONS /tmp /var/tmp /dev/shm
TARGET     OPTIONS
/tmp       rw,noexec,nosuid,nodev,relatime
/var/tmp   rw,noexec,nosuid,nodev,relatime
/dev/shm   rw,noexec,nosuid,nodev

mount -a antes de reiniciar sigue siendo obligatorio, por lo que ya sabes desde 05-04: un fstab mal escrito impide arrancar el servidor.

Gestión de vulnerabilidades y cierre del incidente de openssl

Leer un CVE sin engañarse

Un CVE es el identificador único de una vulnerabilidad concreta. Su CVSS es una puntuación de 0 a 10 acompañada de un vector que describe cómo se explota:

CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H  →  9.8 CRÍTICA
        │    │    │    │    │    │  └─ Confidencialidad/Integridad/Disponibilidad: alta
        │    │    │    │    │    └─ Alcance: sin cambio
        │    │    │    │    └─ Interacción del usuario: ninguna
        │    │    │    └─ Privilegios requeridos: ninguno
        │    │    └─ Complejidad del ataque: baja
        │    └─ Vector de ataque: red

Y el punto que hay que interiorizar: la puntuación base no es el riesgo real para ti. Un 9.8 en un componente que no tienes instalado, o que no está expuesto, o que ya está mitigado por otra capa, es menos urgente que un 6.5 en el servicio que da la cara a Internet. El vector es más informativo que el número: AV:N (explotable por red) sin privilegios ni interacción del usuario es lo que obliga a actuar hoy.

$ apt list --upgradable
$ pro security-status
1543 packages installed:
     1489 packages from Ubuntu Main/Restricted repository
       54 packages from Ubuntu Universe/Multiverse repository
Ubuntu Pro is not attached. Ubuntu Pro would provide security updates for
54 packages until 2034.

$ sudo apt install debsecan && debsecan --suite noble --format summary | head -5

El cierre del incidente

El 18 de agosto a las 06:12, unattended-upgrades actualizó openssl y libssl3t64. El paquete está actualizado en disco, pero los procesos que arrancaron antes siguen con la biblioteca antigua cargada en memoria. Es una vulnerabilidad con papeleo: el informe dice que está parcheada y el servicio sigue expuesto.

$ grep -E 'openssl|libssl' /var/log/apt/history.log | tail -2
Start-Date: 2026-08-18  06:12:03
Upgrade: libssl3t64:amd64 (3.0.13-0ubuntu3.4, 3.0.13-0ubuntu3.5), openssl:amd64 (3.0.13-0ubuntu3.4, 3.0.13-0ubuntu3.5)

# La prueba directa: procesos con la biblioteca ANTIGUA (marcada DEL: borrada
# del disco pero aun mapeada en memoria)
$ sudo lsof 2>/dev/null | grep -E 'DEL.*libssl'
postgres  1105 postgres  DEL  REG  253,0  /usr/lib/x86_64-linux-gnu/libssl.so.3
sshd       894 root      DEL  REG  253,0  /usr/lib/x86_64-linux-gnu/libssl.so.3

$ sudo apt install needrestart
$ sudo needrestart -r l
Scanning processes...
Scanning candidates...
Scanning linux images...

Services to be restarted:
 systemctl restart [email protected]
 systemctl restart ssh.service

Service restarts being deferred:
 (none)

No containers need to be restarted.
No user sessions are running outdated binaries.
No VM guests are running outdated hypervisor (qemu) binaries on this host.

Dos servicios. El orden importa, y por dos razones distintas:

# 1. La base de datos primero, y con la aplicacion preparada para reconectar.
#    Reiniciar postgresql corta las conexiones abiertas de tramontana.service.
$ sudo systemctl restart [email protected]
$ sudo systemctl restart tramontana.service
$ ~/scripts/revision_salud.sh; echo "estado: $?"
estado: 0

# 2. SSH al final, con RELOAD y no restart, y con la segunda sesion abierta.
#    reload no corta las sesiones existentes; restart tampoco deberia, pero
#    reload es la operacion correcta y no hay razon para arriesgarse.
$ sudo sshd -t && sudo systemctl reload ssh

Un matiz importante sobre ssh: reload recarga la configuración, pero no sustituye la biblioteca cargada en el proceso maestro. Para eso hace falta reiniciar el demonio — que no corta las sesiones ya establecidas, porque cada una vive en un proceso hijo propio. Con la segunda sesión abierta como red de seguridad:

$ sudo systemctl restart ssh
$ sudo systemctl is-active ssh
active
# Verificar desde la segunda sesion que se puede abrir una tercera ANTES de cerrar nada

Y la verificación de cierre:

$ sudo lsof 2>/dev/null | grep -c -E 'DEL.*libssl'
0
$ sudo needrestart -r l
No services need to be restarted.
$ sudo ss -tulpn | grep -E ':22|:5432|:8080'
tcp LISTEN 0.0.0.0:22       users:(("sshd",pid=8841,fd=3))
tcp LISTEN 10.0.2.15:5432   users:(("postgres",pid=8902,fd=5))
tcp LISTEN 127.0.0.1:8080   users:(("tramontana",pid=8977,fd=6))

Ese 0 es el cierre. El segundo incidente queda cerrado, y con él los tres.

Para que no vuelva a pasar, needrestart se configura en modo lista y se integra en la revisión:

# /etc/needrestart/needrestart.conf
$nrconf{restart} = 'l';       # solo listar, nunca reiniciar solo
$nrconf{kernelhints} = 1;     # avisar si el kernel en uso no es el instalado
# Anadir a revision_salud.sh: pendientes de reinicio tras actualizacion
$ sudo needrestart -r l -p >/dev/null; echo "codigo needrestart: $?"
codigo needrestart: 0

La decisión de no reiniciar automáticamente es deliberada y coherente con el unattended-upgrades de 05-03: un reinicio no supervisado de la base de datos a las 6 de la mañana es una interrupción de servicio que Marta debe conocer, no un efecto secundario.

Endurecimiento de systemd revisitado

En 05-05 endureciste tramontana.service. Ahora se mide y se mejora:

$ systemd-analyze security tramontana.service | tail -12
→ Overall exposure level for tramontana.service: 3.4 MEDIUM 🙂

3.4 MEDIUM para un servicio de red no está mal, y hay margen. Las directivas que faltan, cada una con su razón:

# /etc/systemd/system/tramontana.service.d/endurecimiento.conf
[Service]
# El servicio no necesita ajustar el kernel ni cargar modulos.
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes

# No debe manipular la jerarquia de cgroups.
ProtectControlGroups=yes

# No debe crear ficheros SUID/SGID: cierra una via clasica de persistencia.
RestrictSUIDSGID=yes

# Impide cambiar la "personalidad" del proceso para emular otra arquitectura.
LockPersonality=yes

# Solo llamadas al sistema propias de un servicio normal. El resto se
# rechaza con EPERM en el kernel, antes de llegar al codigo del proceso.
SystemCallFilter=@system-service
SystemCallFilter=~@privileged @resources @obsolete
SystemCallArchitectures=native

# Solo las familias de sockets que necesita.
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=yes
RestrictRealtime=yes

# Sin acceso a dispositivos ni a /home
PrivateDevices=yes
ProtectHome=yes
ProtectProc=invisible
ProcSubset=pid

# ATENCION: MemoryDenyWriteExecute rompe los tiempos de ejecucion con JIT
# (Java, Node.js, .NET, algunos Python). Solo si la aplicacion es nativa.
MemoryDenyWriteExecute=yes

SystemCallFilter merece explicación porque es la directiva de más valor: instala un filtro seccomp en el kernel que rechaza las llamadas al sistema fuera del conjunto permitido. Un exploit que consiga ejecución de código dentro del proceso se encuentra con que mount, ptrace, init_module o setuid fallan con EPERM — no porque falten permisos de usuario, sino porque el kernel no las acepta de este proceso. Es contención real, no configuración cosmética.

Y el aviso de MemoryDenyWriteExecute es el tipo de detalle que separa una guía útil de una lista copiada: impide que una página de memoria sea escribible y ejecutable a la vez, lo que bloquea muchas técnicas de explotación, y rompe cualquier motor con compilación en tiempo de ejecución. Si la aplicación de Tramontana fuera Java o Node, esta línea la dejaría sin arrancar.

$ sudo systemctl daemon-reload && sudo systemctl restart tramontana.service

# Verificar que el servicio SIGUE FUNCIONANDO: endurecer y romper no es endurecer
$ ~/scripts/revision_salud.sh; echo "estado: $?"
estado: 0
$ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/casas
200

$ systemd-analyze security tramontana.service | tail -3
→ Overall exposure level for tramontana.service: 1.6 OK 🙂

De 3.4 MEDIUM a 1.6 OK, con el servicio funcionando y verificado. El orden —aplicar, verificar, medir— es el mismo que en la rotación de credenciales y en el diagnóstico de rendimiento. Aplicar sin verificar es cómo se descubre a las 3 de la madrugada que el endurecimiento tumbó el servicio.

Copias como último control frente al ransomware

Cuando todo lo anterior falla, quedan las copias. Y frente al ransomware hay un detalle que suele pasarse por alto: una copia montada permanentemente es alcanzable por el ransomware, porque el proceso que cifra los ficheros del sistema cifra también los del volumen montado. Una copia cifrada por el atacante no es una copia.

Las tres propiedades que hacen que una copia resista:

Propiedad Cómo se consigue Estado en Tramontana
Fuera de línea o inmutable Medio desconectado, o almacenamiento con retención forzada Pendiente para el destino externo del 3-2-1
Credencial separada La contraseña del repositorio no es accesible desde el servidor comprometido Parcial: está en pass, pero el servidor puede leerla
Restauración probada Prueba periódica documentada Hecho: los tres escenarios de 05-08

La mejora concreta que se deduce: restic admite el modo append-only en el destino remoto, en el que el cliente puede escribir copias nuevas pero no borrar las existentes. Con eso, un atacante con acceso al servidor no puede destruir el histórico. Es la medida que hay que pedir al proveedor del almacenamiento externo, y queda anotada en la checklist.

Checklist de endurecimiento y mantenimiento de la postura

Esta es la tabla que se entrega, alineada con la estructura de los CIS Benchmarks y con lo que has hecho realmente en el curso. Las tres columnas son deliberadas: sin la de evidencia, una checklist es una declaración de intenciones.

# Control Estado Evidencia
1 Sistema de ficheros: noexec,nosuid,nodev en /tmp, /var/tmp, /dev/shm Hecho findmnt -o TARGET,OPTIONS
2 Cifrado en reposo del volumen de copias Hecho cryptsetup luksDump, /etc/crypttab
3 Repositorios firmados; solo actualizaciones oficiales Hecho /etc/apt/sources.list.d/*.sources, /etc/apt/keyrings/
4 Actualizaciones de seguridad automáticas Hecho unattended-upgrades, solo -security
5 Servicios pendientes de reinicio tras actualizar Hecho needrestart -r l sin pendientes; incidente cerrado
6 Superficie de escucha mínima Hecho ss -tulpn: 22, 5432 interno, 8080 en localhost
7 Servicios innecesarios deshabilitados y enmascarados Hecho ModemManager enmascarado y purgado
8 Sin entorno gráfico Hecho Instalación de Ubuntu Server
9 Cortafuegos con política de denegación por defecto Hecho ufw status verbose; ruleset nftables documentado
10 sysctl de red endurecidos Hecho /etc/sysctl.d/ (06-03)
11 sysctl de kernel endurecidos Hecho /etc/sysctl.d/60-endurecimiento.conf, comentado
12 SSH: sin root, sin contraseña, solo claves Hecho sshd -T -C user=...; excepción de Match eliminada
13 Bloqueo por intentos fallidos (IP y cuenta) Hecho fail2ban-client status sshd, faillock --user
14 Política de contraseñas y caducidad Hecho pwquality.conf, login.defs, chage -l
15 Un único UID 0; sin cuentas sin contraseña Hecho Los tres one-liners de auditoría
16 Mínimo privilegio en sudo Hecho /etc/sudoers.d/tramontana con Cmnd_Alias
17 MAC: perfil de AppArmor en enforce Hecho aa-status, perfil de tramontana
18 Servicio con endurecimiento de systemd Hecho systemd-analyze security: 1.6 OK
19 Secretos fuera de los ficheros de configuración Hecho LoadCredentialEncrypted, pass
20 TLS: material emitido, verificado y con renovación probada Hecho certbot renew --dry-run, revisar_certificado.sh
21 Registro persistente con rotación Hecho /var/log/journal, logrotate.d/tramontana
22 Integridad de ficheros vigilada, base de datos fuera Hecho tramontana-integridad.timer, suma en el portátil
23 Auditoría de accesos a configuración e identidades Hecho auditctl -l, claves tramontana_conf y privilegios
24 Copias 3-2-1, cifradas, con restauración probada Hecho restic snapshots, comprobar_copia.sh, runbook
25 Cifrado en tránsito en vigor Pendiente (08-01) Certificado listo; falta el proxy inverso. El 8080 no está expuesto
26 Copia externa en modo append-only Pendiente Requiere decisión de proveedor; mitiga ransomware
27 authorized_keys2 sin explicar Abierto Procedimiento de respuesta a incidentes de 06-04 en curso
28 Registros centralizados fuera del servidor Asumido Coste actual; primera inversión cuando haya presupuesto
29 NIDS Asumido Aporta poco con un solo servidor (razonado en 06-04)
30 Antivirus Asumido Servidor sin ficheros de terceros; consume memoria acotada
31 Segundo factor en SSH Asumido Acceso ya por clave; se revisa si crece el equipo
32 Seguridad de la aplicación (inyección, lógica) Fuera de alcance Responsabilidad de desarrollo
33 Seguridad física y del hipervisor Fuera de alcance Responsabilidad del proveedor
34 Auditoría formal de seguridad Fuera de alcance Requiere tercero independiente

Las filas 25 a 27 son las importantes de verdad, porque son las que un informe honesto no oculta. Y las filas 32 a 34 delimitan lo que un administrador no puede resolver, que es información que la dirección necesita tener.

Mantener la postura

Un sistema endurecido se degrada solo: cada cambio, cada paquete nuevo, cada regla «temporal» lo aleja del estado que documentaste. Cuatro prácticas lo sostienen:

  1. Revisión periódica con métricas comparables. Lynis mensual, con su índice de endurecimiento como serie temporal junto a la línea base de rendimiento de 05-07:
    $ sudo lynis audit system --quiet | grep 'Hardening index'
      Hardening index : 82 [################    ]
    
    Del 68 de 06-04 al 82 actual. Lo relevante no es el número: es que si baja, hay una explicación que buscar.
  2. Gestión del cambio. Toda modificación de configuración con su copia previa, su diff -u, su verificación y su anotación. Es la convención del curso, y es el control contra el actor más probable del modelo de amenazas.
  3. Documentación viva en el runbook, fuera del servidor: el modelo de amenazas, esta checklist con fechas, el inventario de secretos con su última rotación, y el procedimiento de respuesta a incidentes.
  4. Revisión del propio modelo de amenazas cuando cambie la realidad: un servidor más, un servicio nuevo, un dato nuevo que se empieza a tratar. Un modelo de amenazas de hace dos años describe un sistema que ya no existe.

Advertencia de cumplimiento

srv-tramontana trata datos personales de huéspedes. El RGPD exige medidas técnicas y organizativas apropiadas al riesgo (art. 32), y varias de las decisiones de esta lección —la retención de registros, el alcance de la auditoría sobre la actividad de personas trabajadoras, el plazo de despliegue del cifrado en tránsito de la fila 25— tienen implicaciones legales que no corresponde decidir a un administrador. En un entorno real:

  • El responsable de seguridad debe revisar el modelo de amenazas y la checklist, y validar los controles asumidos.
  • El responsable de protección de datos debe validar el tratamiento, los plazos de retención y la evaluación del riesgo residual.
  • Y esta checklist no sustituye a una auditoría formal. Es una autoevaluación honesta, hecha por quien administra el sistema, y por definición tiene el punto ciego de quien lo ha construido.

Errores Comunes y Consejos

  • Endurecer sin modelo de amenazas. Es la causa de la mitad de los sistemas frágiles: medidas copiadas que rompen cosas y no protegen contra nada que fuera probable. Media página de modelo cambia todas las decisiones que vienen después.
  • Tocar PAM sin una sesión de root abierta. Un error de sintaxis en common-auth puede impedir todo inicio de sesión, incluida la consola. Sesión de root en otra terminal, copia previa, y prueba en una tercera.
  • Olvidar pam_faillock.so authsucc. Sin esa línea el contador de fallos nunca se reinicia, y el bloqueo llega días después sin causa aparente.
  • Olvidar enforce_for_root = 1 en pwquality.conf: la cuenta más importante queda exenta de la política de contraseñas.
  • Poner un perfil de AppArmor directamente en enforce. Rompe el servicio y te obliga a descubrir los accesos que faltan bajo presión. Siempre complain primero, ejercitar de verdad, y luego enforce.
  • Aplicar noexec a /tmp sin comprobarlo. Hay instaladores que extraen y ejecutan ahí. Prueba con mount -o remount y ejercita apt y tus scripts antes de tocar fstab.
  • Editar fstab sin mount -a. Un fstab roto impide arrancar el servidor. Es la red de seguridad de 05-04 y sigue siendo obligatoria.
  • Confundir «paquete actualizado» con «vulnerabilidad corregida». Mientras el proceso tenga la biblioteca vieja en memoria, sigue expuesto. needrestart -r l después de cada actualización, y lsof | grep DEL como confirmación.
  • Endurecer y no verificar. systemd-analyze security mejora la puntuación aunque hayas dejado el servicio sin arrancar. Aplicar, verificar con revision_salud.sh, y luego medir.
  • MemoryDenyWriteExecute en una aplicación con JIT. Java, Node y .NET no arrancan. Lee lo que hace cada directiva antes de copiarla.
  • Una checklist sin columna de evidencia. «Firewall configurado» no dice nada; ufw status verbose sí. Sin evidencia, la checklist es una declaración de intenciones.
  • Consejo de método. Convierte las auditorías de esta lección en auditar_endurecimiento.sh usando lib/comunes.sh, con un timer mensual y el principio de silencio si todo va bien. Lo que depende de tu memoria no es un control.

Ejercicios

Ejercicio 1

Tras aplicar el drop-in de endurecimiento, tramontana.service deja de arrancar:

$ sudo systemctl status tramontana.service --no-pager | head -6
● tramontana.service - Tramontana Reservas
     Active: failed (Result: exit-code) since Tue 2026-08-18 16:04:11 CEST
    Process: 9214 ExecStart=/opt/tramontana/app/tramontana ... (code=killed, signal=SYS)

Describe el procedimiento de diagnóstico, identifica la causa más probable a partir de la información del mensaje, y explica cómo acotarías qué directiva concreta lo provoca sin quitarlas todas de golpe.

Ejercicio 2

Escribe el perfil de AppArmor para el envoltorio /home/operador/bin/desplegar-seguro, que se ejecuta vía sudo y llama a desplegar.sh. Razona qué debe permitir y —más importante— qué debe denegar, y explica qué ataque concreto contiene ese perfil que ni el DAC ni la regla de sudoers contienen.

Ejercicio 3

Marta te pide un informe de una página para la dirección: «¿estamos seguros?». Redáctalo apoyándote en el modelo de amenazas y en la checklist, sin tecnicismos innecesarios, diciendo con honestidad qué está protegido, qué no y qué decisiones necesitan presupuesto o autorización.

Soluciones

Solución 1

La pista está en el propio mensaje: code=killed, signal=SYS. SIGSYS es la señal que envía el kernel cuando un filtro seccomp rechaza una llamada al sistema. Eso apunta directamente a SystemCallFilter, y no a las demás directivas (que producirían típicamente EACCES, EPERM o un fallo de ruta).

Procedimiento, de lo más informativo a lo más costoso:

# 1. Confirmar la hipotesis y ver QUE llamada se rechazo
$ sudo journalctl -u tramontana.service -n 30 --no-pager | grep -iE 'seccomp|SYS|syscall'
audit: type=1326 audit(1755530651.882:88): auid=4294967295 uid=997 pid=9214
  comm="tramontana" exe="/opt/tramontana/releases/3.2.1/tramontana"
  syscall=318 compat=0 ip=0x7f2a1c4b3e2a code=0x80000000

# 2. Traducir el numero de llamada a su nombre
$ ausyscall 318
getrandom

getrandom es la llamada para obtener aleatoriedad del kernel — la aplicación la usa para TLS y para identificadores de sesión. Está en @system-service, pero la excluí al añadir SystemCallFilter=~@privileged @resources @obsolete, y getrandom pertenece al conjunto @resources en algunas versiones de systemd. La segunda línea, que parecía una mejora, es la que rompe el servicio.

Cómo acotar sin quitarlo todo, que es la parte de método del ejercicio. La clave es que los drop-ins se pueden apilar y anular por separado:

# a) Un drop-in temporal que solo neutraliza la directiva sospechosa.
#    La linea vacia RESETEA la lista acumulada; luego se pone solo lo minimo.
$ sudo mkdir -p /etc/systemd/system/tramontana.service.d
$ sudo tee /etc/systemd/system/tramontana.service.d/99-diagnostico.conf >/dev/null <<'EOF'
[Service]
SystemCallFilter=
SystemCallFilter=@system-service
EOF
$ sudo systemctl daemon-reload && sudo systemctl restart tramontana.service
$ ~/scripts/revision_salud.sh; echo "estado: $?"
estado: 0

Arranca. Con eso queda confirmado que el problema era la exclusión, no el conjunto base. Ahora se afina en lugar de renunciar al filtro:

# b) Recuperar la restriccion util, devolviendo solo lo necesario
$ sudo tee /etc/systemd/system/tramontana.service.d/99-diagnostico.conf >/dev/null <<'EOF'
[Service]
SystemCallFilter=
SystemCallFilter=@system-service
SystemCallFilter=~@privileged @obsolete
SystemCallFilter=getrandom
EOF
$ sudo systemctl daemon-reload && sudo systemctl restart tramontana.service
$ ~/scripts/revision_salud.sh && systemd-analyze security tramontana.service | tail -1
→ Overall exposure level for tramontana.service: 1.7 OK 🙂

Se mantiene ~@privileged @obsolete —que es donde está casi todo el valor defensivo— se abandona @resources, y se añade getrandom explícitamente. Exposición 1.7 en lugar de 1.6, con el servicio funcionando: un endurecimiento peor sobre el papel y infinitamente mejor en la práctica, porque el de 1.6 no arrancaba.

Método general aplicable a cualquier fallo de endurecimiento:

  1. Leer la señal o el código de error: SIGSYS → seccomp; EPERM en un fichero → ProtectSystem/ReadWritePaths; EACCES con apparmor="DENIED" → AppArmor; fallo al mapear memoria → MemoryDenyWriteExecute.
  2. Buscar la evidencia concreta en el journal (syscall=, denied_mask=, la ruta).
  3. Neutralizar una directiva con un drop-in de mayor prefijo numérico, nunca editando el original.
  4. Verificar que arranca, afinar hacia el mínimo necesario, y volver a medir.
  5. Consolidar el drop-in con un nombre definitivo y dejar escrito el motivo de la excepción — igual que el unprivileged_userns_clone comentado del sysctl.

Solución 2

# /etc/apparmor.d/home.operador.bin.desplegar-seguro
abi <abi/4.0>,
include <tunables/global>

/home/operador/bin/desplegar-seguro {
  include <abstractions/base>
  include <abstractions/bash>

  # El envoltorio y el script que invoca
  /home/operador/bin/desplegar-seguro     r,
  /home/operador/scripts/desplegar.sh     rix,
  /home/operador/scripts/lib/comunes.sh   r,
  /usr/bin/bash                           rix,

  # Utilidades concretas que el script necesita, ENUMERADAS
  /usr/bin/{tar,curl,ln,sha256sum,systemctl,flock,mktemp,rm,mv,date,logger} rix,

  # Origen del paquete de release: solo lectura
  /srv/tramontana/backups/envios/*.tar.gz    r,
  /srv/tramontana/backups/envios/*.sha256    r,

  # Destino del despliegue: escritura acotada a releases y al enlace
  /opt/tramontana/releases/                  rw,
  /opt/tramontana/releases/**                rw,
  /opt/tramontana/app                        rw,
  /opt/tramontana/HISTORIAL                  rw,

  # Registro y bloqueo
  /var/log/tramontana/despliegue.log          rw,
  /var/lock/tramontana-despliegue.lock        rwk,
  /tmp/                                       r,
  /tmp/**                                     rw,

  # Consultar el estado del servicio tras el despliegue
  /run/systemd/private                        rw,

  # DENEGADO de forma explicita, para que conste y quede registrado:
  deny /etc/shadow                            rwklx,
  deny /etc/tramontana/secretos/**            rwklx,
  deny /srv/tramontana/backups/restic/**      rwklx,
  deny /home/operador/.ssh/**                 rwklx,
  deny /home/operador/.password-store/**      rwklx,
  deny /root/**                               rwklx,
  deny /usr/bin/{nc,ncat,socat,ssh,scp,python3,perl} x,
}

Qué permite: leer el paquete de release, escribir en releases/, cambiar el enlace app, escribir su registro y su bloqueo, y hablar con systemd para reiniciar el servicio. Nada más.

Qué deniega, y qué ataque contiene cada denegación:

Denegación Ataque que contiene
/etc/tramontana/secretos/** El proceso corre como root vía sudo, así que el DAC le permitiría leer la credencial cifrada. AppArmor no
/srv/tramontana/backups/restic/** Un despliegue no tiene ninguna razón para tocar las copias. Contiene el borrado de copias por ransomware
/home/operador/.password-store/** Impide que el envoltorio acceda a la fuente de verdad de los secretos
/home/operador/.ssh/** Bloquea añadir una clave autorizada — el mecanismo de persistencia que AIDE detectó en 06-04
x sobre nc, socat, python3, perl Bloquea la shell inversa, que es el primer paso tras conseguir ejecución con privilegios

Y ahora el fondo del ejercicio: qué contiene este perfil que el DAC y sudoers no contienen.

La regla de sudoers permite a operador ejecutar desplegar-seguro como root. A partir de ese momento, el DAC no ofrece ninguna protección: root puede leer /etc/shadow, puede leer la credencial de la base de datos, puede borrar las copias, puede añadirse una clave SSH y puede lanzar nc para abrir una shell hacia el exterior. El envoltorio se escribió precisamente para evitar dar root completo, pero la contención depende por entero de que el contenido del script sea correcto y siga siéndolo.

Ahí está el hueco. Si desplegar.sh tiene un fallo —una variable sin entrecomillar que permita inyectar un comando, un tar que extraiga un fichero con ../ en su ruta— el atacante ejecuta código como root a través de una vía autorizada. Ni sudo ni los permisos Unix lo detienen: la ejecución es legítima desde su punto de vista.

El perfil de AppArmor es la capa que sí lo detiene, porque el control no depende del usuario sino del programa: sea cual sea el código que se acabe ejecutando dentro de este perfil, no podrá leer los secretos, no podrá tocar las copias, no podrá escribir en authorized_keys y no podrá lanzar un intérprete para salir. El daño queda acotado al despliegue, que es exactamente lo que se pretendía cuando se escribió el envoltorio y lo que hasta ahora era solo una intención.

Eso es defensa en profundidad en su forma más concreta: el sudoers limita quién y qué comando; AppArmor limita qué puede hacer ese comando, incluso cuando el comando se comporta mal.

Solución 3

Informe de seguridad — srv-tramontana / Tramontana Reservas Para: Dirección · De: Operaciones de sistemas · 18 de agosto de 2026

En una frase. El servidor está razonablemente protegido frente a las amenazas que de verdad le afectan, con tres cuestiones pendientes que detallo al final, una de las cuales requiere una decisión de dirección.

Qué protegemos, por orden de importancia. Primero, los datos personales de nuestros huéspedes: nombres, fechas de estancia e importes. Su filtración sería irreversible, dañaría a terceros y tiene consecuencias legales. Segundo, la disponibilidad del servicio de reservas, cuyo coste es económico y acotado en el tiempo.

De quién nos protegemos, y qué hemos hecho.

  • Ataques automatizados desde Internet, que son constantes. El servidor solo acepta conexiones por dos puertas: la de administración —que ya no admite contraseñas, solo claves criptográficas— y la del sitio web. Todo lo demás está cerrado por defecto, y quien insiste en probar contraseñas queda bloqueado automáticamente. En el último mes se han bloqueado 47 intentos desde una misma procedencia.
  • Robo o filtración de una contraseña. Las credenciales del sistema ya no están escritas en ningún fichero legible: están cifradas y solo el programa que las necesita puede usarlas. Tenemos además un procedimiento escrito para cambiarlas sin interrumpir el servicio.
  • Nuestros propios errores, que estadísticamente son el riesgo más probable. Toda modificación se hace con copia previa, se comprueba antes de aplicarse y queda registrada. Un sistema independiente vigila cada día que ningún fichero crítico haya cambiado sin explicación.
  • Uso indebido de accesos legítimos. Cada persona tiene solo los permisos que su trabajo requiere, y los accesos a la configuración quedan registrados.

Qué hacemos si algo falla. Tenemos copias de seguridad cifradas, con la restauración probada —no solo configurada—, capaces de recuperar el servicio en 8 horas perdiendo como máximo 4 horas de reservas, plazos que se acordaron con Operaciones. Y un procedimiento escrito de respuesta a incidentes, guardado fuera del servidor.

Las tres cuestiones pendientes.

  1. El tráfico web todavía no viaja cifrado. El certificado está emitido y verificado, pero falta desplegar el componente que lo presentará, previsto en la siguiente fase. Mientras tanto el servicio no es accesible desde Internet, así que la exposición se limita a nuestra red interna. No requiere decisión: está planificado.
  2. Un hallazgo sin explicar. Nuestra vigilancia detectó un fichero de configuración de acceso que nadie creó. Está en investigación con el procedimiento previsto. Informaré del resultado; si se confirmara acceso a datos personales, hay obligación legal de notificarlo en 72 horas.
  3. Copia externa protegida contra borrado. Hoy, quien tuviera control del servidor podría destruir las copias. Existe una modalidad de almacenamiento que no permite borrar lo ya guardado, y es la mejor defensa disponible frente a un ataque de secuestro de datos. Requiere decisión de dirección: implica contratar almacenamiento externo.

Qué no está en nuestras manos. Tres cosas quedan fuera de lo que la administración de sistemas puede resolver, y conviene que consten: la seguridad interna de la propia aplicación, que corresponde a desarrollo; la seguridad física y del proveedor de infraestructura; y un atacante con recursos muy superiores a los de una empresa de nuestro tamaño.

Y una advertencia necesaria. Este informe es una autoevaluación hecha por quien administra el sistema, y por tanto tiene el punto ciego de quien lo ha construido. No sustituye a una auditoría independiente. Recomiendo, además, que el tratamiento de datos de huéspedes y los plazos de conservación de registros los revise el responsable de protección de datos.

En resumen: el trabajo está hecho, medido y documentado. La única decisión que necesito de la dirección es la del punto 3.

Conclusión

srv-tramontana tiene ahora una postura de seguridad, y eso es cualitativamente distinto de tener medidas de seguridad. Hay un modelo de amenazas escrito que dice qué protegemos, de quién y qué asumimos fuera de alcance, y cada decisión de esta lección se justifica en él. La superficie de ataque se ha reducido: la aplicación escucha solo en local, lo que no se usa está enmascarado y purgado. La autenticación pasa por PAM con política de contraseñas, bloqueo por cuenta y límites de recursos —y localizaste el Match User luis que llevaba semanas contradiciendo tu propia configuración—. AppArmor contiene la aplicación en enforce, con un perfil que impide lanzar una shell o leer las copias aunque el proceso se vea comprometido. Los sysctl de endurecimiento están aplicados y comentados, incluida la línea que dejaste desactivada con la razón escrita al lado. /tmp está sin permiso de ejecución, comprobado antes de hacerlo persistente. Y systemd-analyze security ha pasado de 3.4 a 1.6 con el servicio verificado y funcionando, que es la única mejora que cuenta.

Los tres incidentes están cerrados: los 47 intentos desde 203.0.113.44 con fail2ban, la db_password en claro con systemd-creds y pass, y hoy la libssl vulnerable que seguía cargada en memoria — porque un paquete actualizado no es una vulnerabilidad corregida hasta que los procesos lo saben. Queda una checklist de 34 filas con su columna de evidencia, tres cuestiones abiertas dichas sin adornos y un informe para la dirección que no promete lo que no puede cumplir. El índice de Lynis ha subido de 68 a 82, y lo importante de ese número no es su valor sino que ahora es una serie temporal que alguien vigila.

Y con esto se cierra el Módulo 6: configuraste la red de forma persistente con la reversión automática de netplan try; endureciste SSH con claves ed25519, sin root y sin contraseñas, y aprendiste a leer sshd -T -C para saber qué configuración se aplica realmente a alguien; levantaste un cortafuegos de lista blanca sobre nftables y supiste el orden en que se aplica para no quedarte fuera; montaste detección de intrusos con AIDE y auditd, con la base de datos protegida fuera del servidor, y un procedimiento de respuesta a incidentes con su regla incómoda; sacaste los secretos de los ficheros de configuración y preparaste el material criptográfico de TLS con renovación probada y vigilancia de la caducidad; y hoy has reunido todo eso en una postura coherente y documentada.

Hasta aquí has operado un servidor, y lo has operado a mano. Sabes usarlo, administrarlo y defenderlo, pero hay dos cosas que todavía no sabes hacer: mirar dentro y multiplicarlo. En el Módulo 7: Temas Avanzados haces las dos. Verás el proceso de arranque completo —de la UEFI a GRUB, al initramfs y a systemd— y aprenderás a recuperar un sistema que no arranca, que es la habilidad que separa a quien reinstala de quien arregla. Aprenderás diagnóstico avanzado con strace, perf y eBPF para responder a «¿qué está haciendo exactamente este proceso?» cuando los contadores no bastan (y descubrirás que el ptrace_scope que acabas de poner es parte del enunciado del problema). Ajustarás el kernel con sysctl, ahora sí por rendimiento. Y después empieza la multiplicación: virtualización con KVM y libvirt, contenedores con Docker y por qué son procesos aislados y no máquinas pequeñas, automatización con Ansible —que convertirá todo el trabajo manual de los módulos 5 y 6 en código versionado y reproducible, y las 8 horas de RTO en bastante menos—, y por fin alta disponibilidad y balanceo de carga, donde srv-tramontana deja de ser un único punto de fallo. Actualiza el snapshot de tu VM, guarda el runbook fuera de la máquina, y nos vemos en el Módulo 7.

Curso de Linux: De Principiante a Administrador de Sistemas

Módulo 1: Introducción a Linux

Módulo 2: Comandos Básicos de Linux

Módulo 3: Habilidades Avanzadas en la Línea de Comandos

Módulo 4: Scripting en Shell

Módulo 5: Administración del Sistema

Módulo 6: Redes y Seguridad

Módulo 7: Temas Avanzados

Módulo 8: Proyectos Prácticos

© Copyright 2026. Todos los derechos reservados