Apuntes DAM
Volver al inicio

Detección de ataques de fuerza bruta en el registro de SSH

Ejercicio de PythonDifícilUnos 70 minutos

Analiza el registro de autenticación de un servidor Linux como haría fail2ban: bloquea las IP con 5 fallos en 10 minutos, lista los usuarios inexistentes probados y avisa de los accesos correctos tras fallos desde la misma IP. Expresiones regulares, datetime y ventanas de tiempo.

  • Expresiones regulares con grupos
  • Operador morsa (:=)
  • datetime.strptime
  • timedelta
  • Ventana deslizante
  • Conjuntos y diccionarios

Enunciado

Cualquier servidor con SSH abierto a Internet recibe cientos de intentos de entrada al día: programas que prueban contraseñas para root, admin o test desde máquinas comprometidas. Cada intento queda en el registro de autenticación (/var/log/auth.log en Debian y Ubuntu) con una línea como Oct 3 10:15:01 web1 sshd[2231]: Failed password for root from 203.0.113.5 port 52314 ssh2.

Herramientas como fail2ban leen ese registro y bloquean en el cortafuegos las IP que fallan demasiadas veces en poco tiempo. Vas a escribir su lógica: con la configuración típica, 5 fallos dentro de una ventana de 10 minutos bloquean la IP.

Lo más peligroso no son los fallos, sino un acceso correcto desde una IP que antes ha fallado: puede ser alguien que ha acertado la contraseña. Esos casos se marcan como alertas para revisarlos.

Qué tiene que hacer el programa

  1. Cada línea empieza con la fecha de syslog en sus primeros 15 caracteres (Oct 3 10:15:01, mes en inglés y sin año: se toma 2026). Las líneas vacías o con una fecha que no se puede leer se ignoran, igual que las que no son ni un fallo ni un acceso correcto.
  2. Un fallo contiene Failed password for USUARIO from IP port (o for invalid user USUARIO, si el usuario no existe en el sistema). Un acceso correcto contiene Accepted password for USUARIO from IP port o Accepted publickey for ….
  3. Una IP se bloquea en el instante de un fallo si, contando ese fallo, tiene al menos 5 fallos en los últimos 10 minutos (con una diferencia de 10 minutos exactos todavía cuenta). Cada IP se bloquea una sola vez, en el primer instante en que se cumple.
  4. Un acceso correcto desde una IP que ya ha tenido algún fallo (en cualquier momento anterior) genera la alerta USUARIO entró desde IP a las HH:MM:SS tras N fallos desde esa IP («fallo» si es 1).
  5. Informe: Intentos fallidos: F · Accesos correctos: A; Bloquear: con una línea IP a las HH:MM:SS (5 fallos desde las HH:MM:SS) por IP, en el orden en que se bloquearon, indicando el primer fallo de la ventana (o Bloquear: ninguna IP); Usuarios inexistentes probados: a, b en orden alfabético (o ninguno); si ha habido fallos, Usuarios más atacados: root (6), admin (3), … con los tres con más fallos (a igualdad, alfabético); y Alertas: con las alertas en orden (o Alertas: ninguna).

Entrada

Las líneas del registro de autenticación de SSH.

Ejemplos de ejecución

Tu programa debe escribir exactamente esta salida para estas entradas. Las pruebas del editor incluyen estos ejemplos y otros casos ocultos.

Una mañana en el servidor web

Entrada

Oct  3 10:15:01 web1 sshd[2231]: Failed password for root from 203.0.113.5 port 52314 ssh2
Oct  3 10:15:04 web1 sshd[2231]: Failed password for invalid user admin from 203.0.113.5 port 52320 ssh2
Oct  3 10:15:09 web1 sshd[2232]: Failed password for root from 203.0.113.5 port 52331 ssh2
Oct  3 10:16:10 web1 sshd[2240]: Accepted password for ana from 198.51.100.7 port 50022 ssh2
Oct  3 10:16:12 web1 sshd[2241]: Failed password for invalid user test from 203.0.113.5 port 52340 ssh2
Oct  3 10:16:30 web1 sshd[2242]: Accepted publickey for luis from 192.0.2.10 port 40122 ssh2
Oct  3 10:17:02 web1 sshd[2243]: Failed password for root from 203.0.113.5 port 52351 ssh2
Oct  3 10:20:45 web1 sshd[2250]: Failed password for ana from 198.51.100.99 port 50100 ssh2
Oct  3 10:21:00 web1 sshd[2251]: Accepted password for ana from 198.51.100.99 port 50101 ssh2
Oct  3 10:22:10 web1 CRON[2260]: pam_unix(cron:session): session opened for user root

Salida por consola

Intentos fallidos: 6 · Accesos correctos: 3
Bloquear:
  203.0.113.5 a las 10:17:02 (5 fallos desde las 10:15:01)
Usuarios inexistentes probados: admin, test
Usuarios más atacados: root (3), admin (1), ana (1)
Alertas:
  ana entró desde 198.51.100.99 a las 10:21:00 tras 1 fallo desde esa IP

La ventana de 10 minutos

Entrada

Oct  3 22:00:00 srv sshd[1]: Failed password for root from 10.0.0.9 port 1 ssh2
Oct  3 22:03:00 srv sshd[1]: Failed password for root from 10.0.0.9 port 1 ssh2
Oct  3 22:06:00 srv sshd[1]: Failed password for root from 10.0.0.9 port 1 ssh2
Oct  3 22:09:00 srv sshd[1]: Failed password for root from 10.0.0.9 port 1 ssh2
Oct  3 22:12:00 srv sshd[1]: Failed password for root from 10.0.0.9 port 1 ssh2
Oct  3 22:13:00 srv sshd[1]: Failed password for root from 10.0.0.9 port 1 ssh2
línea basura

Salida por consola

Intentos fallidos: 6 · Accesos correctos: 0
Bloquear:
  10.0.0.9 a las 22:13:00 (5 fallos desde las 22:03:00)
Usuarios inexistentes probados: ninguno
Usuarios más atacados: root (6)
Alertas: ninguna

Guía paso a paso

Intenta resolverlo por tu cuenta y abre un paso solo cuando te atasques: cada uno te acerca a la solución sin dártela entera.

1. Fecha y hora de cada línea

datetime.strptime(linea[:15], "%b %d %H:%M:%S") lee Oct 3 10:15:01 (el día puede llevar un espacio delante). Como syslog no guarda el año, pon uno con .replace(year=2026): así las restas de fechas funcionan.

2. Extraer los datos con grupos

Una expresión regular con grupos captura el usuario y la IP. El grupo opcional (invalid user )? dice además si el usuario no existe. Con el operador morsa compruebas y guardas el resultado en la misma línea.

python
if m := FALLO.search(linea):
    invalido, usuario, ip = m.groups()
3. La ventana deslizante

Guarda para cada IP la lista de instantes de sus fallos. En cada fallo nuevo, quédate con los que están a 10 minutos o menos (instante - t <= timedelta(minutes=10)): si son 5 o más y la IP no estaba bloqueada, se bloquea ahora.

4. Las alertas

En un acceso correcto, mira cuántos fallos tiene ya esa IP en tu diccionario. Como el registro está en orden, todos los fallos que hay son anteriores.

5. El informe ordenado

Los bloqueos se ordenan por el instante del bloqueo, los usuarios inexistentes alfabéticamente (sorted de un conjunto) y el ranking con la clave (-fallos, usuario).

Resuélvelo aquí

El editor trae el esqueleto del programa. Pulsa «Ejecutar» para comprobarlo con los ejemplos y con 2 casos ocultos que buscan los errores típicos.

🐍PythonDetección de ataques de fuerza bruta en el registro de SSHDifícil

Ejemplo

Entrada (lo que se escribe por teclado)
Oct  3 10:15:01 web1 sshd[2231]: Failed password for root from 203.0.113.5 port 52314 ssh2
Oct  3 10:15:04 web1 sshd[2231]: Failed password for invalid user admin from 203.0.113.5 port 52320 ssh2
Oct  3 10:15:09 web1 sshd[2232]: Failed password for root from 203.0.113.5 port 52331 ssh2
Oct  3 10:16:10 web1 sshd[2240]: Accepted password for ana from 198.51.100.7 port 50022 ssh2
Oct  3 10:16:12 web1 sshd[2241]: Failed password for invalid user test from 203.0.113.5 port 52340 ssh2
Oct  3 10:16:30 web1 sshd[2242]: Accepted publickey for luis from 192.0.2.10 port 40122 ssh2
Oct  3 10:17:02 web1 sshd[2243]: Failed password for root from 203.0.113.5 port 52351 ssh2
Oct  3 10:20:45 web1 sshd[2250]: Failed password for ana from 198.51.100.99 port 50100 ssh2
Oct  3 10:21:00 web1 sshd[2251]: Accepted password for ana from 198.51.100.99 port 50101 ssh2
Oct  3 10:22:10 web1 CRON[2260]: pam_unix(cron:session): session opened for user root
Salida esperada
Intentos fallidos: 6 · Accesos correctos: 3
Bloquear:
  203.0.113.5 a las 10:17:02 (5 fallos desde las 10:15:01)
Usuarios inexistentes probados: admin, test
Usuarios más atacados: root (3), admin (1), ana (1)
Alertas:
  ana entró desde 198.51.100.99 a las 10:21:00 tras 1 fallo desde esa IP
⏳
Test oculto #3
⏳
Test oculto #4
0/4 tests pasados · pulsa un test para ver su entrada y su salida esperada

Solución explicada

Ver la solución completa
python
1import re
2import sys
3from datetime import datetime, timedelta
4
5FALLO = re.compile(r"Failed password for (invalid user )?(\S+) from (\S+) port")
6EXITO = re.compile(r"Accepted (password|publickey) for (\S+) from (\S+) port")
7VENTANA = timedelta(minutes=10)
8MAX_FALLOS = 5
9
10
11def main():
12    fallos_ip = {}            # ip → lista de instantes de fallo
13    bloqueos = {}             # ip → (instante del bloqueo, primer fallo de la ventana)
14    inexistentes = set()
15    fallos_usuario = {}
16    alertas = []
17    n_fallos = n_exitos = 0
18
19    for linea in sys.stdin.read().split("\n"):
20        if not linea.strip():
21            continue
22        # Las líneas de syslog empiezan con «Oct  3 10:15:01» (sin año)
23        try:
24            instante = datetime.strptime(linea[:15], "%b %d %H:%M:%S").replace(year=2026)
25        except ValueError:
26            continue
27        if m := FALLO.search(linea):
28            n_fallos += 1
29            invalido, usuario, ip = m.groups()
30            if invalido:
31                inexistentes.add(usuario)
32            fallos_usuario[usuario] = fallos_usuario.get(usuario, 0) + 1
33            lista = fallos_ip.setdefault(ip, [])
34            lista.append(instante)
35            # Ventana deslizante: solo cuentan los fallos de los últimos 10 minutos
36            recientes = [t for t in lista if instante - t <= VENTANA]
37            if len(recientes) >= MAX_FALLOS and ip not in bloqueos:
38                bloqueos[ip] = (instante, recientes[0])
39        elif m := EXITO.search(linea):
40            n_exitos += 1
41            _, usuario, ip = m.groups()
42            previos = len(fallos_ip.get(ip, []))
43            if previos:
44                alertas.append(f"  {usuario} entró desde {ip} a las {instante:%H:%M:%S} tras {previos} {'fallo' if previos == 1 else 'fallos'} desde esa IP")
45
46    print(f"Intentos fallidos: {n_fallos} · Accesos correctos: {n_exitos}")
47    if bloqueos:
48        print("Bloquear:")
49        for ip, (cuando, desde) in sorted(bloqueos.items(), key=lambda b: b[1][0]):
50            print(f"  {ip} a las {cuando:%H:%M:%S} ({MAX_FALLOS} fallos desde las {desde:%H:%M:%S})")
51    else:
52        print("Bloquear: ninguna IP")
53    print("Usuarios inexistentes probados: " + (", ".join(sorted(inexistentes)) if inexistentes else "ninguno"))
54    if fallos_usuario:
55        ranking = sorted(fallos_usuario.items(), key=lambda u: (-u[1], u[0]))[:3]
56        print("Usuarios más atacados: " + ", ".join(f"{u} ({n})" for u, n in ranking))
57    print("Alertas:" if alertas else "Alertas: ninguna")
58    for a in alertas:
59        print(a)
60
61
62main()

Las expresiones regulares con grupos convierten una línea de texto libre en datos estructurados (usuario, IP, si el usuario existe). Es la base de cualquier análisis de registros, desde un script como este hasta herramientas como Logstash.

La ventana deslizante es la clave de fail2ban: no importa cuántos fallos haya en total, sino cuántos caben en 10 minutos. Recalcularla en cada fallo con una lista por IP es sencillo y suficiente para un registro diario; con millones de líneas se usaría una cola que descarta los fallos antiguos.

Separar la recogida de datos (un único recorrido del registro) de la presentación (el informe al final) permite añadir estadísticas nuevas sin volver a leer nada.

Las alertas cruzan dos tipos de eventos: un acceso correcto no es sospechoso por sí solo, pero sí después de fallos desde la misma IP. Ese tipo de correlación es lo que hacen los sistemas de detección de intrusiones (SIEM).

Para ir más allá

  • Genera las órdenes de nftables o ufw para bloquear las IP detectadas.
  • Desbloquea las IP pasada una hora (bantime) y vuelve a contar sus fallos desde cero.
  • Agrupa los ataques por red /24 para detectar ataques distribuidos desde muchas IP del mismo proveedor.

Dónde se explica