· 8 min de lectura

De fail2ban a CrowdSec, y cómo acabé bloqueándome desde la UAB

Cambié fail2ban por CrowdSec en mi VM de Azure. Bouncer nftables, una lista comunitaria de ~14.000 IPs y el día que no pude entrar desde la universidad.

Primer plano de latiguillos de fibra óptica naranjas, morados y turquesa conectados a un switch con LEDs amarillos
Todo el tráfico pasa por algún sitio; mejor filtrarlo antes de que llegue. Foto: «2024 Data Center Details» de Jefferson Lab · Dominio público, recortada y redimensionada

Mi portfolio, arnauserver.me, vive en una VM de Azure con Ubuntu 24.04 y unos 840 MiB de RAM. Como cualquier máquina con IP pública, se come escaneos de SSH, peticiones a paneles de WordPress que no existen e intentos de explotar CVE de hace años. Durante un tiempo de eso se encargaba fail2ban. Ahora lo hace CrowdSec. Cuento por qué cambié, cómo lo tengo y cómo conseguí bloquearme a mí mismo desde la universidad a los pocos días.

Lo que no me acababa de convencer de fail2ban

fail2ban funciona y no tengo nada serio contra él. Lee logs, aplica expresiones regulares por servicio (los jails) y, si una IP pasa de cierto umbral, la banea en el firewall. El problema está en ese mismo planteamiento: solo reacciona a lo que ya ha pasado en mi servidor. Una IP que lleva semanas atacando a miles de máquinas tiene que fallar varias veces conmigo antes de que la bloquee.

CrowdSec separa en dos piezas lo que fail2ban hace en una. El agente lee y parsea los logs y evalúa escenarios (fuerza bruta SSH, escaneo HTTP, explotación de CVE conocidas…); cuando uno salta, genera una decisión. El bouncer es quien la aplica, y en mi caso el de nftables mete las IPs en sets del firewall.

Lo interesante viene después. El agente manda sus alertas a la API central (CAPI) y a cambio recibe la lista comunitaria, con IPs que otras instalaciones han pillado atacando. O sea, bloqueo de antemano a gente que todavía no me ha tocado.

Yo quería proteger mejor la web, no solo SSH. Con la lista comunitaria, buena parte de los escáneres que van por Internet buscando paneles de WordPress o ficheros .env llegan ya bloqueados, sin haber hecho una sola petición a nginx. Y las colecciones de nginx y http-cve detectan patrones de ataque web que con fail2ban me habría tocado escribir y mantener a mano.

Cómo lo tengo montado

Instalación desde upstream, con pinning

Ubuntu 24.04 trae CrowdSec en sus repositorios, pero en la 1.4.6, que ya tiene sus años. Tiré del repositorio oficial de CrowdSec (packagecloud), que ahora me da la 1.8.2. Para que APT no empiece a mezclar paquetes de los dos sitios, el pinning va en dos partes: de packagecloud no entra nada salvo los dos paquetes de CrowdSec que quiero.

# /etc/apt/preferences.d/crowdsec
Package: *
Pin: origin packagecloud.io
Pin-Priority: -1

Package: crowdsec crowdsec-firewall-bouncer-nftables
Pin: origin packagecloud.io
Pin-Priority: 990

Con apt-cache policy crowdsec compruebas que la versión candidata sale de packagecloud.io con prioridad 990.

apt-cache policy crowdsec
sudo apt install crowdsec crowdsec-firewall-bouncer-nftables

Lo hice así porque tengo unattended-upgrades con reinicio automático a las 04:00, y no me hace ninguna gracia que una actualización desatendida me cambie el origen del paquete mientras duermo.

Colecciones

Las colecciones agrupan parsers y escenarios del Hub. Tengo cinco:

sudo cscli collections install \
  crowdsecurity/nginx \
  crowdsecurity/sshd \
  crowdsecurity/linux \
  crowdsecurity/http-cve \
  crowdsecurity/base-http-scenarios
sudo systemctl reload crowdsec

nginx y sshd parsean los logs de los dos servicios expuestos y linux los del sistema. http-cve se encarga de los intentos de explotar vulnerabilidades conocidas, y base-http-scenarios añade escaneos genéricos, rutas sospechosas y crawlers pesados.

Todo esto se complementa con lo que ya hacía nginx: el default_server responde 444 a los hosts desconocidos, ssl_reject_handshake corta los SNI que no son míos y hay un rate limit de 30 peticiones por minuto por IP. nginx frena el ruido y CrowdSec convierte lo que se repite en baneos de firewall.

Para ver que hace algo

Estos son los comandos que más uso:

sudo cscli metrics              # qué logs se leen y qué parsers aciertan
sudo cscli decisions list       # baneos generados localmente
sudo cscli decisions list -a    # incluye también los de la lista comunitaria

Y para ver lo que de verdad está aplicando el firewall:

sudo nft list ruleset | less

Los números

Esto es lo que dice el status.json de mi status page mientras escribo:

"banned_ips": { "total": 14010, "local": 4 }

14.010 IPs bloqueadas, y solo 4 son decisiones locales. El resto viene de la lista comunitaria. Casi todo lo que bloqueo no ha llegado ni a intentarlo conmigo; con fail2ban habría tenido que esperar a que cada una fallara en mis logs.

En recursos, la máquina tiene 836 MiB en total y ahora mismo usa 483, con 353 disponibles y una carga media de 0,33 a 15 minutos. Eso con nginx, CrowdSec, el bouncer, el colector de Web Vitals y el backend del chat corriendo a la vez.

El día que no pude entrar desde la UAB

A los pocos días de migrar intenté entrar por SSH desde la red de la UAB y nada, la conexión se quedaba colgada. Probé la web en el navegador y tampoco cargaba. Desde casa, todo perfecto.

Qué estaba pasando

La universidad sale a Internet por NAT, con unas cuantas IPs públicas compartidas por miles de dispositivos. Basta con que uno haga algo feo (un portátil infectado, alguien trasteando con un escáner) para que esa IP de salida acabe reportada. Alguna de las IPs de la UAB estaba en la lista comunitaria, y el bouncer tiraba sus paquetes antes de que llegaran a sshd o a nginx. Por eso caía todo a la vez: el bloqueo es por IP y le dan igual los puertos.

Es lo que tiene la inteligencia compartida. Una IP no es una persona, y detrás de un NAT grande hay mucha gente.

La whitelist que no servía para esto

Lo primero que probé fue una whitelist en la fase de enriquecimiento del parser:

# /etc/crowdsec/parsers/s02-enrich/uab-whitelist.yaml
name: arnau/uab-whitelist
description: "No generar baneos locales para la red de la UAB"
whitelist:
  reason: "NAT compartido de la UAB"
  cidr:
    - "<RANGOS_UAB>"   # los rangos reales no los publico

Con esto mi propio agente deja de banear IPs de la UAB, por ejemplo si me equivoco de llave tres veces seguidas (tengo MaxAuthTries 3). Pero mi problema seguía ahí. La whitelist actúa sobre los eventos que parsea mi agente, no sobre las decisiones que bajan de la CAPI, así que la IP seguía tan tranquila en el set del bouncer.

Un accept antes del drop

El bouncer de nftables crea su propia tabla y una cadena con la regla que descarta las IPs de los sets. Lo que hice fue meter al principio de esa cadena una regla que acepta SSH y web (22, 80 y 443) desde los rangos de la UAB:

sudo nft insert rule ip crowdsec crowdsec-chain-input \
  ip saddr { <RANGOS_UAB> } tcp dport { 22, 80, 443 } counter accept comment "uab-allow"

Cuidado con el nombre de la cadena. Las versiones antiguas del bouncer usaban una sola crowdsec-chain, pero las actuales crean una por hook (crowdsec-chain-input, crowdsec-chain-forward). sudo nft list ruleset te dice cuál tienes.

Y tiene que ser insert, que la coloca al principio. Con add acaba al final, detrás del drop, y no sirve para nada.

Que aguante los reinicios

Una regla puesta a mano desaparece en cuanto el bouncer se reinicia y recrea su tabla, cosa que pasa con cada actualización y con el reinicio nocturno. Así que la reaplico con un ExecStartPost en un drop-in de systemd:

sudo systemctl edit crowdsec-firewall-bouncer
[Service]
ExecStartPost=/usr/local/bin/crowdsec-uab-ssh-allow.sh
#!/usr/bin/env bash
# Espera a que el bouncer cree su cadena y añade la excepción (SSH + web) para la UAB
set -uo pipefail
TABLE="crowdsec"; CHAIN="crowdsec-chain-input"; TAG="uab-allow"

for _ in $(seq 1 30); do
  nft list chain ip "$TABLE" "$CHAIN" &>/dev/null && break
  sleep 1
done

# Idempotente: solo inserta si la regla etiquetada no está ya
if ! nft list chain ip "$TABLE" "$CHAIN" | grep -q "\"$TAG\""; then
  nft insert rule ip "$TABLE" "$CHAIN" \
    ip saddr { <RANGOS_UAB> } tcp dport { 22, 80, 443 } counter accept comment "\"$TAG\""
fi

# Si aun así no está, que quede en el log
if ! nft list chain ip "$TABLE" "$CHAIN" | grep -q "\"$TAG\""; then
  logger -t crowdsec-uab-ssh-allow -p auth.warning "No se pudo insertar $TAG"
  exit 1
fi

El comment hace de etiqueta: el script se puede ejecutar las veces que haga falta sin duplicar la regla, y de un vistazo veo si está puesta.

Por qué me parece un riesgo asumible

Saltarse la lista comunitaria para todo el rango de la UAB suena mal, lo sé. Lo acepto porque lo que queda abierto tiene otras protecciones. SSH solo admite llave ed25519, root no puede entrar, hay MaxAuthTries 3 y la cuenta azureuser tiene la contraseña bloqueada: alguien detrás del NAT de la UAB llega al puerto, pero no tiene nada que adivinar. La web es estática y pública, así que desde la UAB verían lo mismo que cualquiera, y el rate limit de nginx (30 peticiones por minuto y por IP) se sigue aplicando porque no depende de CrowdSec. Y la excepción cubre solo el 22, el 80 y el 443, nada más.

Al principio la regla era solo para el 22, porque lo primero que noté fue el SSH. Pero la web había caído igual, y tenerla inaccesible justo desde donde más la enseño era un poco absurdo, así que la amplié.

Lo que haría de otra manera

El fallo gordo fue no probar desde todas mis redes el mismo día de la migración. Un ssh -v desde la UAB, desde casa y desde los datos del móvil me habría ahorrado descubrirlo al llegar a clase.

También di por hecho que la whitelist de parser lo cubría todo y perdí un buen rato con eso. CrowdSec tiene además allowlists centralizadas (cscli allowlists), que sí filtran las IPs de las listas de bloqueo antes de guardarlas. Me habrían ahorrado el script, pero solo trabajan con IPs y rangos, sin puertos, y habría acabado abriendo todo el servidor a la UAB. Para lo que yo necesito, la regla de nftables limitada a SSH y web me sigue pareciendo lo más ajustado.

Lo que tengo pendiente es practicar con Run Command de Azure, que ejecuta comandos en la VM a través del agente de Azure y no depende ni de SSH ni del firewall. Si algún día me vuelvo a quedar fuera desde todas partes, prefiero no estar aprendiendo a usarlo en ese momento.