··
Una auditoría dejó la postura de seguridad de mi VPS en 7,5/10. El hueco más serio era el más simple: si alguien tomaba root, no tenía forma de saberlo. Te cuento cómo cubrí ese hueco con AIDE, un canal de alertas en Telegram y backups cifrados que el propio servidor monitorizado no puede corromper.

Hace unos días terminé una auditoría de seguridad sobre mi VPS personal, donde corren este blog y un puñado de proyectos más. La nota fue un 7,5 sobre 10: muchas capas en su sitio (firewall por IPs de Cloudflare, WAF con reglas custom, Authenticated Origin Pull en Traefik, CrowdSec analizando logs, panel de admin tras Cloudflare Access). Pero había un hueco que me incomodaba más que el resto.
El hueco era simple de enunciar y bastante difícil de tapar: si un atacante consiguiese acceso root al servidor (vía supply chain de npm, un CVE en Dokploy o una sesión OAuth comprometida), no tenía forma de saberlo. CrowdSec lee logs HTTP, pero un atacante ya dentro no necesita pasar por Traefik. El firewall protege la entrada, no lo que ocurre cuando alguien ya está dentro. Mis backups son del contenido (SQLite del blog, volúmenes Docker), no del estado del sistema.
Necesitaba algo que respondiese a una pregunta muy concreta. «¿Alguien ha tocado un binario, una unidad de systemd, un script en /etc, una clave SSH, sin que yo lo sepa?» Esa es exactamente la pregunta que responde un HIDS.
HIDS significa Host-based Intrusion Detection System. La idea es vieja y muy poco glamurosa: tomas una foto inicial del estado del filesystem (permisos, propietarios, hashes de cada archivo en los paths que importan) y, periódicamente, comparas el estado actual contra esa foto. Cualquier diferencia que no hayas autorizado tú mismo es una señal de manipulación.
La belleza del enfoque es que no necesitas detectar técnicas concretas (rootkits, malware, persistencia). Detectas cambios. Si el atacante reemplaza /usr/bin/sshd por una versión backdoored, el hash cambia. Si añade una clave a /root/.ssh/authorized_keys, el hash cambia. Si planta una unidad systemd para persistencia, ahí está.
Lo que no detecta también es importante saberlo: nada que ocurra en RAM (un process injection sin tocar disco), nada que el atacante elimine antes del siguiente check, y nada en paths que no estés vigilando. Es una capa más, no una bala de plata.
Hay varias herramientas que implementan esto: AIDE (Advanced Intrusion Detection Environment), Tripwire, Samhain, OSSEC. AIDE me convenció por tres razones:
Es de los repos oficiales de Debian. apt install aide aide-common y listo. Sin curl | bash, sin compilar nada.
Config plana y leíble. Es un fichero de texto donde declaras qué paths vigilar, qué excluir y qué atributos comparar.
Sin daemon ni nada que mantener vivo. Es un binario que lees + comparas. Una invocación cada 24 h es suficiente.
El paquete aide-common en Debian trae una configuración por defecto enorme, dividida en snippets de /etc/aide/aide.conf.d/. La idea es que el sistema autogenere una conf consolidada según los paquetes instalados. En la práctica, esa conf vigila demasiado: directorios con caches, paths que cambian a cada apt upgrade, contadores en /var. El ruido es tan alto que terminas ignorando las alertas.
Reemplacé la conf por una propia, mucho más corta. La idea fue partir de cero y vigilar solo cosas donde un cambio inesperado es señal de manipulación:
# Database paths
database_in=file:/var/lib/aide/aide.db
database_out=file:/var/lib/aide/aide.db.new
database_new=file:/var/lib/aide/aide.db.new
gzip_dbout=yes
# Reporting
report_summarize_changes=yes
report_grouped=yes
report_ignore_e2fsattrs=VNIE
# Atributos comparados: tipo, perms, owner, group, inode, num links,
# size, mtime, ctime, sha256, ACL, xattrs, selinux
FullCheck = p+u+g+i+n+s+m+c+sha256+acl+xattrs+selinux
# Paths vigilados
/etc FullCheck
/usr/bin FullCheck
/usr/sbin FullCheck
/usr/local/bin FullCheck
/usr/local/sbin FullCheck
/usr/local/lib FullCheck
/usr/lib/systemd/system FullCheck
/root FullCheck
# Exclusiones (regex POSIX extendida)
!/etc/resolv\.conf$
!/etc/mtab$
!/etc/adjtime$
!/root/\.bash_history$
!/root/\.cache
# ...y un puñado más para paths que el orquestador regenera
La conf real tiene unos 50 renglones. Cubre lo que importa (binarios del sistema, configs sistema y aplicación, units de systemd, claves SSH y crontabs en /root) y excluye lo que no. Si el primer aide --check tras inicializar la baseline ya muestra diferencias, es señal de que tu conf tiene paths volátiles mal excluidos. Iterar hasta llegar a un check limpio es parte del trabajo.
Inicializar la baseline:
sudo aide -c /etc/aide/aide.conf --init
sudo mv /var/lib/aide/aide.db.new /var/lib/aide/aide.dbEn mi caso terminó siendo una base de 3614 entradas, unos 138 KB tras compresión. Un aide --check contra esa baseline dura 3 o 4 segundos (mucho menos que los 1-2 minutos que esperaba). Tiempo más que razonable para un timer diario.
AIDE imprime el diff a stdout. Útil si alguien se pone a leerlo, inútil si nadie lo mira. Necesitaba que el resultado llegase a un sitio donde lo viese sin estar buscándolo. Telegram es el canal que ya uso para todas las alertas de infra (CF token, certs próximos a caducar, disco lleno) porque tiene tres ventajas concretas:
El bot tiene su propia API, sin librerías raras: un curl vale.
No depende de Cloudflare ni de mi propia infra. Si la cosa que estoy alertando es mi infra, no me alerta a mí mismo a través de mi infra.
Las prioridades del envío (silencioso vs con sonido) las controlo desde el script.
Tengo un helper infra-telegram.sh compartido por todos los scripts de monitorización del host. Lee credenciales de un fichero 0600 root (nunca hardcoded) y expone una función simple alert:
_INFRA_TELEGRAM_ENV="/etc/myinfra/telegram.env"
alert() {
local title=$1 body=$2 priority=${3:-default}
[ -r "$_INFRA_TELEGRAM_ENV" ] || return 0
. "$_INFRA_TELEGRAM_ENV"
[ -z "${TELEGRAM_BOT_TOKEN:-}" ] && return 0
local emoji silent="false"
case "$priority" in
urgent) emoji="🚨" ;;
high) emoji="⚠️" ;;
*) emoji="ℹ️"; silent="true" ;;
esac
curl -sS -X POST --max-time 10 \
--data-urlencode "chat_id=${TELEGRAM_CHAT_ID}" \
--data-urlencode "text=${emoji} ${title}\n\n${body}" \
--data "disable_notification=${silent}" \
"https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
>/dev/null 2>&1
}Sobre ese helper monté un wrapper específico para AIDE. Corre aide --check, parsea el bloque Summary: de la salida, y según el exit code decide qué hacer:
rc 0 (sin diff): silencio total. No molesto a nadie con un «todo en orden» diario.
rc 1-13 (added/removed/changed): alerta high con un fragmento del diff y la pista del comando para revisarlo.
rc ≥ 14 (error real de AIDE): alerta urgent. Esto significa que el HIDS está roto, no que haya manipulación, lo cual es peor.
Un detalle del parsing me costó tiempo encontrar. La salida de AIDE imprime Added entries: dos veces: una en el bloque Summary con el número, otra como cabecera de la sección de detalle sin número. Un awk ingenuo extrae el número de la primera y luego lo sobreescribe con cadena vacía al encontrar la segunda. La fix fue gatear el match con un flag de bloque:
counts=$(echo "$out" | awk '
/^Summary:/ { in_summary=1; next }
/^---/ { in_summary=0 }
in_summary && /Added entries:/ { gsub(/[^0-9]/,"",$NF); a=$NF }
in_summary && /Removed entries:/ { gsub(/[^0-9]/,"",$NF); r=$NF }
in_summary && /Changed entries:/ { gsub(/[^0-9]/,"",$NF); c=$NF }
END { printf "%s %s %s", a+0, r+0, c+0 }
')El script no hace aide --update automáticamente. Cualquier diff requiere revisión humana. Tras un apt upgrade legítimo, acepto los cambios manualmente con:
sudo aide -c /etc/aide/aide.conf --update
sudo mv -f /var/lib/aide/aide.db.new /var/lib/aide/aide.dbUn detalle a tener en cuenta: aide --update devuelve exit code distinto de cero cuando hay diff, igual que --check. Si lo encadenas con &&, el mv nunca se ejecuta porque el update siempre encuentra cambios (esa es la razón por la que actualizas). Hay que separarlos con ; o ejecutarlos en pasos.
Service oneshot + timer diario:
# /etc/systemd/system/check-aide.service
[Unit]
Description=AIDE filesystem integrity check
[Service]
Type=oneshot
ExecStart=/usr/local/bin/check-aide.sh
Nice=10
IOSchedulingClass=best-effort
IOSchedulingPriority=7
# /etc/systemd/system/check-aide.timer
[Unit]
Description=Daily AIDE filesystem integrity check
[Timer]
OnCalendar=*-*-* HH:MM:00 # ajusta a tu hora preferida
RandomizedDelaySec=10min
Persistent=true
[Install]
WantedBy=timers.targetEl RandomizedDelaySec evita que todos los timers de mi VPS disparen exactamente al mismo segundo. Persistent=true hace que se ejecute al arrancar el sistema si se saltó la ventana planificada (por reinicio, por ejemplo).
Un HIDS bien montado tiene una propiedad incómoda: su propia base de datos es el primer objetivo del atacante. Si consigue root, puede simplemente correr aide --update tras instalar su backdoor para que la nueva «realidad» quede absorbida en la baseline. La siguiente comparación no detecta nada raro.
Hay dos defensas naturales contra esto:
Backups inmutables fuera del propio host vigilado. Si el atacante puede modificar aide.db, no debería poder modificar las copias.
Verificación periódica de que los backups antiguos siguen ahí, intactos, y comparables con la baseline actual.
Lo elegante es que las dos defensas se montan con primitivas estándar de SSH y cifrado, sin servicios extra ni licencias.
El destino de los backups es un segundo VPS que ya tenía en propiedad para otro proyecto. La pregunta importante no era «cómo copio el archivo» (eso es scp), sino cómo asegurarme de que un atacante con root en el servidor monitorizado no pueda manipular las copias. La respuesta vino en cuatro capas.
Generé una clave ed25519 dedicada exclusivamente para esta tarea (no reutilizo claves para otras cosas). En el lado receptor, en lugar de añadir la pública tal cual a authorized_keys, le añadí restricciones. Esto es OpenSSH puro, sin software extra:
from="<ip-del-server-monitorizado>",restrict,command="/home/usuario/bin/aide-backup-receive.sh" ssh-ed25519 AAAA... aide-backup@origenLo que hace esa línea es lo siguiente:
from="<ip>" hace que el sshd rechace la conexión antes incluso de pedir auth si la IP de origen no coincide. No es defensa de capa de aplicación, es de capa de red.
restrict es un clamp completo, sin PTY, sin port forwarding, sin agent forwarding, sin X11, sin user-rc, y prohíbe a futuro cualquier opción que se añada al protocolo.
command="..." es el forced command. Independientemente de lo que pida el cliente al hacer ssh, el sshd ejecuta únicamente ese comando. Da igual que el cliente intente ssh ... "ls /etc", ssh ... "rm -rf ~" o un shell interactivo, lo único que se ejecuta es el script especificado. Lo que el cliente envía como argumentos queda en la variable SSH_ORIGINAL_COMMAND (que mi script ignora deliberadamente).
Probarlo es tranquilizador. Desde el servidor de origen:
ssh -i /root/.ssh/aide_backup_key -p <port> usuario@destino "id; whoami; ls /etc"
# stdout: ERROR: archivo recibido demasiado pequeño (0 bytes)El cliente intentó tres comandos. El sshd ignoró los tres y ejecutó el forced command, que leyó stdin vacío y rechazó la operación. Ni una sola línea de salida del id o del ls.
El forced command es un script que lee stdin y guarda el contenido en un archivo. La sutileza está en quién decide el nombre:
#!/bin/bash
set -euo pipefail
DEST_DIR="$HOME/aide-backups"
DATE=$(date -u +%Y%m%d) # <-- nombre = fecha del SERVIDOR
TARGET="$DEST_DIR/aide-${DATE}.db.gz.age"
TMP=$(mktemp -p "$DEST_DIR" "aide-${DATE}.XXXX")
trap 'rm -f "$TMP"' EXIT
# Tope duro de 10 MB
head -c 10485760 > "$TMP"
# Validaciones: tamaño mínimo y magic bytes gzip (1f 8b)
size=$(stat -c%s "$TMP")
[ "$size" -lt 1024 ] && { echo "ERROR: demasiado pequeño" >&2; exit 1; }
magic=$(od -An -N2 -tx1 "$TMP" | tr -d ' \n')
[ "$magic" != "1f8b" ] && { echo "ERROR: no es gzip" >&2; exit 1; }
# (cifrado + audit log van aquí, los explico abajo)
mv -f "$TMP" "$TARGET"
sha256sum "$TARGET" | awk '{print $1}' # eco del sha para verificaciónEl cliente no controla el nombre del archivo. Si el script de origen pretendiese subir aide-20251225.db.gz (sobreescribir un backup pasado), el destino lo guardaría como aide-<hoy>.db.gz igualmente. Eso significa que un servidor de origen comprometido puede como máximo sobreescribir el backup del día actual. Las copias de días anteriores quedan inmutables para él.
El script también valida que lo recibido tiene un mínimo de pinta de ser un backup legítimo (más de 1 KB, comienza con magic bytes de gzip), capa el tamaño máximo a 10 MB para impedir ataques de disco lleno, y devuelve por stdout el SHA-256 de lo recibido. El cliente compara ese SHA con el del archivo local antes de mandar la confirmación a Telegram. Si difieren, alerta urgent inmediata.
Hasta aquí, los backups están protegidos contra modificación, pero no contra lectura. Si el servidor de backup cae, el atacante tiene en sus manos toda la historia de bases de datos de AIDE: estructura del filesystem, hashes, paths, mucho mapa interno. Útil para preparar un siguiente ataque más informado.
La solución que usé es age: una herramienta de cifrado moderna, opensource y muy simple. Genera una keypair en un comando:
age-keygen -o ~/.../aide_backup.key
# Output: clave privada en el fichero, pública impresa por stdout
# Public key: age1...La regla esencial es que la privada no toca ninguno de los dos servidores. Vive solo en mi máquina personal, con copia en password manager. La pública sí va en el script receptor del servidor de backup, hardcoded como recipient:
AGE_RECIPIENT="age1..." # solo la pública
# en la función de recepción:
age -r "$AGE_RECIPIENT" -o "$TMP_AGE" "$TMP_PLAIN"
mv -f "$TMP_AGE" "$TARGET"El servidor de backup puede cifrar (basta con la pública) pero no puede descifrar lo que ha cifrado (eso requiere la privada). Si el servidor cae, atacante obtiene ciphertext puro: blobs binarios sin valor sin la privada que vive en otra máquina.
Un detalle: cifrar preserva la integridad detectada por SHA-256 si comparas correctamente. El script receptor sigue devolviendo el SHA del archivo plain (antes de cifrar) para que el cliente lo verifique. Lo que cambia es lo que queda guardado en disco: ciphertext, no plain.
Para verificar manualmente desde mi Mac descargo el ciphertext y lo descifro al vuelo, sin tocar disco:
remote_hash=$(ssh backup-server "cat $latest" | age -d -i ~/.../aide_backup.key | shasum -a 256 | awk '{print $1}')
local_hash=$(ssh source-server 'sudo sha256sum /var/lib/aide/aide.db | awk "{print \$1}"')
[ "$local_hash" = "$remote_hash" ] && echo "MATCH" || echo "MISMATCH"Un detalle barato y muy útil. El script receptor appendea una línea por cada intento de push (legítimo o no) a ~/aide-backups/.access.log:
2026-04-28T12:00:21Z ip=<origen> outcome=ok size=142450 sha256=db603b01b30f...
2026-04-28T12:15:19Z ip=<origen> outcome=reject_size size=0 sha256=none
2026-04-28T12:15:20Z ip=<origen> outcome=reject_size size=12 sha256=noneEsto da visibilidad de cosas anómalas: pushes fuera del schedule, frecuencia rara, IPs distintas. Los outcome=reject_* son particularmente interesantes: significa que algo intentó usar la SSH key de forma inapropiada. En la captura de arriba se ven los rechazos de mis tests negativos (payloads vacíos y de 12 bytes), todos correctamente bloqueados antes de tocar nada.
Conviene ser explícito sobre qué cubre y qué no este montaje. El backup es solo tan seguro como las suposiciones que lo sustentan.
Escenario | Resultado |
|---|---|
Atacante toma root en el servidor monitorizado | Detectable en ≤ 24 h por el check diario. Puede sobreescribir el backup de hoy; todos los anteriores son inmutables para él. |
Atacante toma la cuenta del servidor de backup | Lee ciphertext (inútil sin la clave privada). Puede modificar el script receptor para futuro, lo que se detectaría en el siguiente compare-remote por mismatch SHA. |
Atacante intercepta el tráfico SSH | Imposible sin acceso a las claves privadas. El host key del servidor de backup está pinneado en el known_hosts del origen. |
Pierdo el ordenador con la age private key | Pierdo el acceso a backups históricos. La baseline viva en el servidor monitorizado sigue funcionando. Mitigación: copia de la privada en password manager. |
Atacante manipula procesos en memoria, sin tocar disco | Indetectable por AIDE. Hace falta otra capa (auditd, eBPF) que no tengo aún. |
El sistema final tiene unas características concretas que me dejan tranquilo:
Ventana máxima de detección de manipulación en filesystem: 24 h, alineada con el check diario.
Tiempo de ejecución del check: 3-4 segundos.
Falsos positivos esperados: uno cada vez que hago apt upgrade sin acordarme de aceptar la baseline. Aceptable.
Tráfico diario hacia el servidor de backup: ~140 KB.
Almacenamiento del backup: ~12 MB para 90 días de retención.
Coste de mantenimiento mensual estimado: 5 minutos, mayoritariamente para aceptar diffs legítimos tras actualizaciones.
Dependencias externas: ninguna. Todo son herramientas estándar (AIDE de los repos Debian, SSH, age, systemd, curl).
Lo más interesante para mí no fue tanto AIDE en sí (es viejo y conocido) como pensar el canal de backup como una pieza de seguridad. Una clave SSH bien restringida con forced command + un nombre de archivo determinado por el servidor + cifrado con la privada en otro sitio te da garantías de inmutabilidad y confidencialidad sin necesidad de nada exótico. Solo primitivas que llevan ahí desde los 90.
Lecciones operativas que apunté para no repetirlas:
El awk doble-match en la salida de AIDE (Added entries: aparece dos veces, una con número y otra como cabecera). Gatear con flag de bloque.
aide --update devuelve bitfield distinto de cero con diff; no encadenar con && el mv de la baseline.
Crear un archivo en un path vigilado dispara también detección del padre (su mtime cambia). No es ruido, es señal extra correcta.
Si tu helper de Telegram silencia errores del curl con >/dev/null 2>&1, una alerta que no llega es invisible. Cuando algo no aparece, replica manualmente con la respuesta visible para ver qué dijo Telegram.
Próximo paso natural: añadir auditd o algo basado en eBPF para cerrar el hueco de manipulación en RAM sin tocar disco. Es otro tipo de bicho y no me corre prisa, pero está apuntado.

Jose, autor del blog
QA Engineer. Escribo en voz alta sobre automatización, IA y arquitectura de software. Si algo te ha servido, escríbeme y cuéntamelo.
¿Qué te ha parecido? ¿Qué añadirías? Cada comentario afina la siguiente entrada.
Si esto te ha gustado

ScamDetector combina inteligencia artificial, búsqueda de reputación de teléfonos y escaneo de URLs para ayudarte a identificar estafas digitales. Sin registro, sin datos almacenados.

Repaso completo de las medidas de seguridad que puedes aplicar a un VPS Linux: desde CrowdSec y el firewall hasta el hardening del kernel, pasando por SSH, Docker y las actualizaciones automáticas.

Nuestros posts viven en una base de datos SQLite. Si alguien accede a ella, puede cambiar cualquier artículo sin dejar rastro. Construimos un verificador externo con hashes SHA-256 y firma Ed25519 que vigila la integridad desde un segundo servidor.