Respuesta a incidente: intrusión por fuerza bruta en el VPS (8-sep-2026)
Formato sin culpas. Horas en UTC, tomadas de los registros del proxy y de la sesión de respuesta.
Resumen e impacto
El 7-sep-2026 un atacante adivinó por fuerza bruta la contraseña de una cuenta de administrador en un sitio de staging de WordPress que corría en mi VPS, subió plugins propios y soltó 21 ejecutables. Durante unas 15 horas consumieron la CPU y la RAM hasta dejar la máquina inutilizable. La intrusión no salió del contenedor del sitio. Se rescataron los datos, se reconstruyó el servidor desde cero en una máquina nueva, se endureció la configuración y se rotaron las credenciales expuestas. Los servicios principales volvieron en menos de 5 horas desde la detección.
Impacto
- Todo lo que corría en el VPS quedó fuera de servicio: el pipeline de automatización, n8n, el panel de control y los sitios demo.
- Lo que corre fuera del VPS no se vio afectado.
- Sin pérdida de datos en n8n: la base se rescató íntegra.
- Dos despliegues personales que solo existían como build copiado al servidor no se pudieron rescatar.
Duración
- de intrusión sin detectar
- 15 h 42 min
- de la detección a la contención
- 54 min
- hasta n8n de vuelta
- 4 h 52 min
- hasta el último servicio
- 6 h 43 min
Línea de tiempo (UTC)
Siete intentos fallidos en
wp-login.php, precedidos de sondeo automatizado de otras rutasAcceso: la contraseña cae en 13 segundos
Van directo a la pantalla de subir plugins
Aparecen tres plugins ajenos y 21 ejecutables ocultos en carpetas del núcleo
Detección: se reporta que todo el servidor está caído
Un solo ingreso por SSH alcanza a medir carga de 120 a 157 con 1 vCPU, 53 MB de RAM libres y el swap lleno
Contención: máquina apagada y snapshot tomado como evidencia
Pliego forense de cinco preguntas, todo de solo lectura
Forense desde el entorno de recuperación, con el disco montado y el sistema comprometido sin arrancar: vector, alcance y cronología
Rescate de la base de n8n (
integrity_check = ok) y de la configuración que no estaba en gitn8n y el pipeline de vuelta en un servidor nuevo
Desde una copia temporal del snapshot: verificación de limpieza de los otros sitios y copia de sus volúmenes; el sitio afectado no se restaura
El sitio afectado, reconstruido desde cero desde el repositorio
Endurecimiento aplicado y verificado
Panel de control de vuelta, ahora en contenedor
Detección y causa raíz
Detección
No la hizo una alerta: la máquina estuvo unas 12 horas al 100 % antes de que alguien lo notara. El aviso que sí existía reportó el síntoma (servicio caído), no la causa.
Causa raíz
El wp-login.php del staging estaba expuesto a internet sin límite de intentos, y el sitio tenía una cuenta de administrador con nombre predecible y contraseña débil. Siete intentos alcanzaron. No hubo vulnerabilidad del código propio, del núcleo de WordPress ni de los plugins del proyecto.
Factores que agrandaron el impacto
- Los doce sitios compartían un solo usuario de base de datos con permisos sobre las doce bases.
- Ningún contenedor tenía límite de memoria: un sitio se comió la RAM de toda la máquina y arrastró a n8n.
- El staging estaba abierto a internet y compartía máquina con el pipeline.
Contención
- Máquina apagada y snapshot conservado como evidencia; el sistema comprometido no se volvió a arrancar.
- Ningún archivo del intruso se ejecutó, ni siquiera para inspeccionarlo.
- Alcance verificado, no supuesto: cron, systemd, usuarios del sistema y llaves autorizadas sin cambios; ningún contenedor montaba el socket de Docker, así que no había vía de escape al host; los otros once sitios, limpios.
Recuperación
- Rescate de datos: la base de n8n (15,7 MB más su WAL), la configuración de proxy y compose, los temas de los sitios (251 MB, montados por bind y fuera de los volúmenes) y el espacio de trabajo del pipeline (77 MB).
- Reconstrucción desde cero: servidor nuevo; n8n restaurado con su base rescatada, sus 2 flujos y sus 2 credenciales; certificados emitidos solos por Caddy.
- Los otros diez sitios se copiaron solo después de comprobar que estaban limpios: cero archivos ocultos en el núcleo, cero PHP en uploads, cero plugins ajenos.
- El sitio afectado no se restauró: su base se borró (con copia previa guardada fuera del servidor) y se rehízo desde el repositorio.
Hardening
Aplicado el 8-sep (verificado sobre lo que quedó corriendo, no sobre el archivo)
| Antes | Después |
|---|---|
| Un usuario de base con permisos sobre las 12 bases | Un usuario por sitio, con permisos solo sobre la suya; el compartido, borrado |
| Sin tope de memoria | 384 MB por sitio y 512 MB la base |
| Se podían subir e instalar plugins desde el panel | DISALLOW_FILE_MODS y DISALLOW_FILE_EDIT activos; actualizaciones menores del núcleo automáticas |
| Cuenta de administrador con nombre predecible | El sitio reconstruido no tiene esa cuenta; contraseña nueva de 26 caracteres |
Aplicado después
- Límite de intentos en
wp-login.php - Alertas de carga y disco
- Tras dos caídas por memoria (13 y 15 de septiembre): el panel exige al menos 400 MB libres y carga menor a 2 antes de encender un sitio, con un máximo de 2 encendidos
- El runner de agentes ganó un vigilante de inactividad
Pendiente
- Segundo factor para administradores de WordPress
- Staging detrás de contraseña o lista de IP
Rotación de credenciales (decidida por evidencia, no por reflejo)
- Rotadas
- La credencial de base que compartían los sitios, dada por leída porque el atacante tuvo PHP arbitrario en el contenedor; las contraseñas del sitio afectado.
- No rotadas, con motivo
- Las llaves SSH (en el servidor solo había llaves públicas y no se modificaron); el token del bot de Telegram (desde dentro del contenedor no se veía la lista de procesos del host); la contraseña root de la base (nunca estuvo en los contenedores de WordPress).
Retrospectiva
Qué salió bien
- No se arrancó el sistema comprometido: la forense se hizo con el disco montado desde el entorno de recuperación.
- El alcance se probó con comprobaciones concretas antes de decidir qué reconstruir.
- La base de n8n salió íntegra y el pipeline volvió el mismo día.
- Nada se copió al servidor nuevo sin pasar antes la comprobación de limpieza.
Qué no salió bien
- Unas 15 horas sin detectar la intrusión: no había alertas de carga ni de disco.
- Al principio se atribuyó el problema a procesos zombi de 31 días, que eran un problema aparte y anterior.
- Se pidió rotar el token de Telegram antes de comprobar que no estaba expuesto; se corrigió tras verificarlo.
- El primer rescate olvidó los volúmenes de los sitios y los temas montados por bind; se recuperaron antes de destruir la copia temporal.
- Encender los diez sitios a la vez llevó la máquina nueva a 1935 de 1967 MB y carga 21, y n8n se reinició: el mismo modo de falla del incidente, sin intruso.
- El servidor nuevo quedó sin swap, y eso explicó caídas posteriores. Hoy vuelve a tener 2 GB de swap.
Acciones preventivas
| # | Acción | Estado |
|---|---|---|
| 1 | Un usuario de base por sitio, con permisos solo sobre la suya | Hecho |
| 2 | Tope de memoria por contenedor | Hecho |
| 3 | DISALLOW_FILE_MODS en los sitios | Hecho |
| 4 | Ninguna cuenta de administrador con nombre predecible; contraseñas largas y únicas | HechoEn el sitio reconstruido |
| 5 | Encendido condicionado a memoria y carga; máximo 2 sitios a la vez | Hecho |
| 6 | Límite de intentos en wp-login.php | Hecho |
| 7 | Segundo factor para administradores de WordPress | Pendiente |
| 8 | Staging detrás de contraseña o lista de IP | Pendiente |
| 9 | Alertas de carga y disco | Hecho |
| 10 | Separar el pipeline de los sitios expuestos | Riesgo aceptadoMitigado con límite de memoria por servicio y máximo 2 demos encendidas |
| 11 | Recrear el swap de 2 GB | Hecho |
Lecciones
- Un staging no es un juguete: tiene el mismo stack que producción y merece las mismas defensas, o no estar abierto a internet.
- Si una credencial comparte doce sistemas, un sitio comprometido vale por doce.
- Sin límite de memoria, cualquier contenedor puede tumbar la máquina entera.
- Alertar sobre la causa (carga, memoria, disco), no solo sobre el síntoma.
- Rotar lo que la evidencia dice que quedó expuesto, y documentar por qué lo demás no.
- Todo lo que corre en un servidor tiene que poder rehacerse desde un repositorio.