artículos / Me secuestraron un servidor, y la puerta...

Me secuestraron un servidor, y la puerta la dejé abierta yo

AWONG 5 minutos de lectura 43 vistas

Levanté una demo en diez minutos entre dos charlas y la puse en el único servidor donde había jurado que no viviría ninguna app. Doce días después: React2Shell, un minero de Monero y un rootkit de kernel escondido a plena vista. Un agente me avisó y decidí que se lo había inventado.

Me secuestraron un servidor, y la puerta la dejé abierta yo

El 30 de julio estaba en Frontera Futura, en Tijuana. Entre charla y charla se acercó gente a preguntar por automatización y agentes en maquiladora. Tenía una demo en mi repo de GitHub que contestaba exactamente esa pregunta. La levanté en diez minutos. Doce días después, esa demo le había dado root a un desconocido, un minero de criptomonedas a mi CPU y un rootkit a mi kernel.

Me secuestraron el servidor. Y la puerta se la dejé abierta yo.

Voy a contar el incidente completo, incluida la parte donde el error es mío. Sobre todo esa parte.

El principio que llevo años repitiendo

Cuando explico arquitectura, dibujo siempre lo mismo:

servidor nginx (aislado) → cortafuegos / VPN → aplicaciones

El servidor que da la cara a internet no corre nada. Solo enruta. Las aplicaciones viven detrás, en otra caja, alcanzables por túnel. La idea es vieja y aburrida: si alguien revienta una app, revienta una app, no revienta tu puerta de entrada.

Lo he dicho en pláticas. Lo he puesto en slides. Y ese día lo rompí yo.

Cómo se rompe un principio en diez minutos

No usé mi agente principal, el que tiene contexto de toda mi infraestructura. Usé el chico, el de consultas rápidas. Ese agente tiene acceso al nginx y a nada más. Le pedí que levantara demomaq desde el repo.

Hizo lo único que podía hacer con lo que tenía: desplegar la app en el servidor de nginx. Me lo dijo. Y yo contesté adelante.

El agente no se equivocó. Un agente pequeño hace lo que le pides con las herramientas que le diste. El que tenía el mapa completo de la red en la cabeza era yo, y el que dijo adelante también.

Ahí quedó: una demo, en el servidor que da la cara a internet, con un vhost público. Me fui a la siguiente charla.


11 de agosto

demomaq es una app Next.js, y por ahí entraron: ejecución de comandos contra un shell del servidor.

Durante días conté esta parte mal, y quiero corregirla aquí antes que en ningún otro sitio: esa ejecución de comandos no salió de código mío. Yo no escribí un exec. Desplegué el andamiaje tal cual venía y le puse encima lo que la demo tenía que enseñar.

Lo que sí había era CVE-2025-55182, que se hizo famosa con el apodo de React2Shell. Es deserialización insegura en el protocolo Flight de React Server Components: divulgada el 3 de diciembre de 2025, CVSS 10 de 10, ejecución remota sin autenticación con una sola petición HTTP. Afecta a React 19.0, 19.1 y 19.2, y a Next.js 15.x y 16.x con App Router. No hay configuración que lo mitigue: o parchas o estás expuesto. Y es explotable hasta en una app en blanco recién salida de create-next-app.

Lo que se sabe de cómo se explotó en el mundo real hace que encaje demasiado bien con lo mío. Bitsight midió que tardaron menos de 48 horas en empezar: reconocimiento el 4 de diciembre, explotación el 5. Los honeypots recogieron más de 68,000 peticiones relacionadas en menos de una semana, el 97% intentando ejecutar código. Aparecieron unas 145 pruebas de concepto públicas, muchas con escaneo masivo automatizado y evasión de firewalls de aplicación. Y entre lo primero que se dejó caer en las máquinas vulneradas: mineros XMRig contra carteras de Monero.

Que es, palabra por palabra, lo que a mí me instalaron ocho meses después.

Y esto no lo digo de memoria. El servidor se formateó, pero el repositorio de la demo sigue en mi cuenta, así que fui a leerlo.

Lo que encontré, en los 72 archivos de código de esa app:

  • Cero llamadas a child_process, exec o spawn. No hay una sola línea capaz de ejecutar un comando del sistema.
  • Cero Server Actions escritas por mí. Ni un use server en todo el proyecto.
  • Cero rutas de API propias.
  • El package.json todavía se llama my-v0-project: el nombre que le pone el generador. Nunca se lo cambié, porque efectivamente desplegué el andamiaje tal cual.
  • Y las versiones: Next.js 15.2.4 y React 19, con App Router. Justo en el rango que React2Shell rompe.

La última vez que toqué ese repositorio fue en septiembre de 2025. El CVE se publicó tres meses después. Nunca se parchó, porque para cuando existió el parche yo ya no miraba ese proyecto.

Sumando todo: esa aplicación no tenía ninguna superficie de servidor escrita por mí. La única que existía era la del framework — y estaba en versión vulnerable, sin parchar, expuesta a internet. El log lleno de failed-to-find-server-action apunta a esa misma ruta.

Forense de laboratorio no es. No puedo demostrar que el binario desplegado fuera bit por bit ese commit, y un minero de Monero es el desenlace más genérico del cryptojacking — sale igual detrás de este CVE que de un Redis abierto. Pero cuando una app no tiene una sola línea capaz de ejecutar comandos y aun así alguien ejecutó comandos en ella, la puerta no la abrió mi código.

Que la falla venga del framework no me deja limpio, y por eso no cambio el título del artículo. React2Shell se publicó en diciembre. Yo desplegué esa app en julio, ocho meses después, sin preguntarme en qué versión iba — con un CVE de criticidad 10 dando vueltas, con exploits públicos y con escáneres barriendo internet desde el primer fin de semana. La vulnerabilidad la puso otro. La app sin parchar, de cara a internet y en el peor servidor de mi red, la puse yo.

10:56 — la herramienta

Descarga gs-netcat, un canal de control remoto, y lo guarda con un nombre que no llama la atención: ~/.config/htop/systemds. Tres minutos después borra el binario del disco.

11:0x — el robo que falló

Aquí hay dos cosas distintas y quiero separarlas, porque una es mérito del diseño y la otra es pura suerte.

Lo que es diseño: ese servidor no guarda credenciales. Es un nginx. Su trabajo es enrutar, no operar; ahí no viven los .env de las aplicaciones ni los secretos del negocio, porque las aplicaciones no viven ahí. Ese es justamente el principio que había roto ese día — y aun rompiéndolo a medias, la caja seguía siendo pobre en botín.

Lo que fue suerte: su script de robo buscó en las rutas de siempre — el .aws de root, el .ssh de root, /var/www, /app. Corría como un usuario sin privilegios y mi caja no usa esos caminos.

27 comandos de robo de credenciales. 27 fallos. Nunca miró el directorio del usuario donde sí estaban mis llaves de AWS. El volcado de variables de entorno de la app le devolvió configuración de Node y cero secretos.

No se llevó nada. Y no porque yo lo hubiera blindado: porque su automatización asumía una casa distinta a la mía.

12:05 — root

Cómo escaló no lo puedo probar al cien: el historial de root no existe. Pero la vía estaba servida y no hacía falta buscar otra. El usuario de la app pertenecía al grupo docker, y estar en ese grupo es ser root con pasos extra: montas el disco entero dentro de un contenedor y sales por el otro lado con todos los permisos. Sin exploit, sin ruido, sin rastro. Es, casi con seguridad, por donde pasó — y es la misma puerta que usé yo para limpiar después.

Ese detalle no es del atacante. Es mío.

13:38 — el negocio

Con root ya es rutina: un backdoor en PAM para robar contraseñas de quien entrara por SSH (le quedó a medias, el módulo nunca llegó a existir en disco) y un minero de Monero como servicio de systemd, apuntando a un pool público con el nombre de mi servidor como identificador del trabajador.

Cuatro días de mi CPU pagando la luz de otro.


Cómo lo encontré: cuatro días tarde, y con aviso previo

Entraron el martes 11. Me enteré el sábado 15. Cuatro días.

Y aquí viene la parte que menos me gusta contar. Sí hubo un aviso, y lo ignoré.

Uno de mis agentes, trabajando en otra cosa en esa máquina, me dijo que el servidor no tenía sudo. Lo leí, me pareció un disparate — claro que tiene sudo, es un Ubuntu — lo archivé como alucinación y seguí con la tarea que llevaba entre manos.

No era una alucinación. Era el primer síntoma, y me lo estaban diciendo en voz alta.

Descartamos lo que un modelo nos dice cuando suena imposible. Y es un reflejo razonable: alucinan. Pero un agente que reporta algo verificable en una máquina real no está inventando prosa, está leyendo el sistema. Ese reporte se comprueba en diez segundos. Yo no gasté los diez segundos.

El sábado fui a instalar una cosa sin relación y me estrellé yo mismo contra lo mismo: sudo no funcionaba. Ni con la contraseña correcta, ni pidiéndola siquiera. Tiré del hilo y salió el minero.

Contuve lo evidente el mismo día: paré y borré el minero, limpié PAM, saqué demomaq del servidor y cerré su vhost. La entrada quedó tapiada. Y creí que había terminado.

El reboot que destapó lo grande

sudo seguía roto. Descarté causas durante horas: no era el módulo PAM del atacante, no era la configuración de systemd, no eran los binarios — el gestor de paquetes los daba íntegros. Así que hice lo que se hace cuando algo huele a estado en memoria: reiniciar.

La caja llevaba 223 días encendida, casi 32 semanas. Al arrancar, el sistema dejó de cargar módulos del kernel. Todos. Sin red virtual, sin cortafuegos, sin Docker, sin túnel. Los sitios se cayeron.

Y ahí estaba la parte incómoda: el rootkit llevaba ahí desde el principio. Bloqueaba la carga de módulos desde el día uno. No se notaba porque los módulos que necesitaba el sistema ya estaban cargados desde un arranque de siete meses atrás. El reboot no rompió nada. El reboot dejó de esconderlo.

Un sistema comprometido te miente a la cara

Esta es la parte que quiero que se lleve quien lea esto.

Cargar un módulo devolvía éxito. Código de retorno cero, sin un solo error. El módulo no estaba en el kernel.

Y el archivo .profile de root:

  • ls decía que no existe.
  • rm decía permiso denegado. Siendo root.
  • touch decía que sí, lo creó. El archivo seguía sin aparecer.

Invisible, imborrable e increable, las tres a la vez. Eso no es un permiso mal puesto: eso es alguien interceptando las llamadas al sistema por debajo. Un rootkit de kernel, de la familia de los que se ocultan a sí mismos de la lista de módulos cargados.

Todo tu instinto de diagnóstico se apoya en que el sistema te dice la verdad. Un comando devuelve cero y le crees. Un archivo no aparece y asumes que no está. Cuando esa capa está comprometida, no estás depurando: estás leyendo lo que el atacante quiere que leas.

Hay otro detalle que me dejó pensando. Días antes, el gestor de paquetes reportaba dos paquetes a medio configurar y un error al copiar precisamente ese .profile. Yo lo leí como mantenimiento pendiente. Era el rootkit defendiendo su persistencia — ese archivo se ejecuta cada vez que root inicia sesión.

Un gestor de paquetes roto puede ser un síntoma, no un descuido.

Dos pruebas baratas

Si alguna vez sospechas de una caja Linux, esto cuesta segundos:

  • Carga un módulo inofensivo, dummy, y comprueba si aparece en la lista de módulos. Si el comando dice que todo bien y el módulo no está, algo está interceptando.
  • Compara la lista de módulos que te da el sistema contra la que expone el kernel en /proc/modules. Si no coinciden, hay algo escondiéndose.

Una advertencia: circulan por ahí trucos de detección basados en mandar señales especiales al proceso cero. En una máquina limpia eso mata procesos. No lo uses para descartar; úsalo solo si ya sabes lo que estás cazando.

Cómo terminó

Barrí el resto de la red con esas dos pruebas: cinco máquinas más entre San Diego, Las Vegas y el proveedor de nube. Todas limpias. El compromiso no se movió de sitio, porque solo una caja tenía una app vulnerable expuesta a internet. Justo la que no debía tener ninguna.

Del servidor comprometido no se recupera nada. Se respalda lo que es dato y configuración — nunca binarios del sistema, que ya no son de fiar — y se formatea. Eso fue lo que hicimos: bajamos los sitios estáticos, la base de datos que vivía en un volumen, la configuración de nginx con sus certificados. Todo verificado antes de tocar el botón.

El lunes siguiente, en la oficina

Volví el lunes y me encontré a un colega peleando con su MacBook Pro: se la habían hackeado. Comentándolo con mi jefe, salió que a él le habían hecho algo parecido meses atrás.

Tres casos en semanas, en el mismo círculo. Voy a ser honesto con lo que eso vale: tres es una anécdota, no una estadística. No tengo forma de saber si están relacionados y no voy a pretender lo contrario. Pero me mandó a leer.

Y lo que hay publicado, con nombre y fecha, sí dice algo:

Ahora, el matiz que casi nunca se cita, y que es la parte más interesante del reporte de Unit 42: la fase autónoma fracasó contra los objetivos principales. El impacto confirmado lo consiguieron las campañas manuales.

Lo mismo que me pasó a mí. Su robo automatizado de credenciales falló entero, los 27 comandos. Lo que sí funcionó fue un humano sentado del otro lado, escribiendo comandos con typos.

Lo que cambió no es la sofisticación del ataque. Es el costo de buscar. Enumerar internet entero, cruzar versiones vulnerables y tocar cuatrocientas puertas ya no cuesta semanas de trabajo de alguien: cuesta minutos de cómputo. Mi demo estuvo doce días arriba. Hace unos años, doce días quizá no hubieran alcanzado para que nadie la encontrara.

Esa es la parte que me quita el sueño, y no el rootkit. El rootkit ya lo formateé.

Lo que me llevo

La tentación es cerrar esto hablando del atacante: que si el minero, que si el rootkit, que si la campaña. Pero el atacante no hizo nada extraordinario. Encontró una puerta abierta con un escáner y ejecutó un guion.

Lo extraordinario fue mío:

  • Desplegué una app sin comprobar si arrastraba un CVE de criticidad 10 publicado ocho meses antes, con exploits públicos circulando desde el primer fin de semana.
  • La puse en el único servidor donde había jurado que no viviría ninguna app.
  • Dejé al usuario de esa app en un grupo que regala root.
  • Y lo hice con prisa, entre dos charlas, para enseñar algo bonito.
  • Y cuando un agente me avisó de que algo andaba mal, decidí que se lo había inventado.

La arquitectura que dibujo en las slides no estaba mal. Estaba sin aplicar en el peor sitio posible. Un principio que respetas salvo cuando tienes prisa no es un principio: es una preferencia.

Y sobre los agentes, que es lo que me preguntaron ese día en Frontera Futura: el agente pequeño no falló. Hizo lo que le pedí con lo que le di. Nadie le dijo dónde estaba prohibido desplegar, porque esa regla vivía en mi cabeza y no en su contexto. Si vas a poner agentes a operar tu infraestructura, tus reglas tienen que estar escritas donde ellos las leen.

Y lo otro, que me costó cuatro días: cuando uno de ellos te diga algo raro sobre una máquina real, compruébalo. Ese es el trabajo. El que dice adelante y el que decide qué merece verificarse siguen siendo el mismo, y sigue siendo el humano.

artículos_relacionados

posts_recientes