Auth bypass: así entró un minero a mi servidor
Introducción
En un post anterior les conté cómo convertí una HP Pavilion vieja en mi servidor personal, con Docker, Cloudflare Tunnel y un montón de servicios self-hosted corriendo ahí. Hoy les cuento la otra cara de esa historia: qué pasa cuando uno de esos servicios queda expuesto al mundo sin la protección que debería tener, y alguien (o algo, un script automatizado) lo encuentra antes que uno.
Spoiler: terminé con un contenedor de Gitea minando criptomonedas para otra persona, 4743 cuentas de spam, y un fin de semana entero limpiando el desastre. Este post es la bitácora completa del incidente, cómo lo detecté, cómo lo contuve, y lo que armé después para que la próxima vez me entere en minutos y no por accidente.

El síntoma: una laptop que no se apagaba
Todo empezó con algo completamente ajeno a seguridad. Estaba configurando Tailscale para poder conectarme por SSH a mi servidor desde cualquier lugar sin exponer el puerto directamente a internet. En medio de esa tarea, revisando el consumo de RAM del equipo, algo no me cuadró:
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
pavilion 2129157 10.8 34.0 2469280 2398564 ? Ssl ago11 415:12 /tmp/javab
Un proceso corriendo directo desde /tmp, con 34% de RAM y 415 horas de CPU acumuladas. Ningún binario legítimo corre desde /tmp. Ahí empezó la investigación.
La investigación: siguiendo el rastro
Paso 1 — ¿Quién es este proceso?
readlink -f /proc/2129157/exe
# /tmp/javab (deleted)
El binario se había borrado a sí mismo del disco después de ejecutarse, pero seguía corriendo (Linux permite esto: mientras el proceso tenga el file descriptor abierto, el archivo sigue "vivo" en memoria aunque ya no exista en el filesystem). Es una técnica clásica de malware para dificultar el análisis forense.
Paso 2 — ¿De qué contenedor viene?
Como todos los procesos de Docker comparten el mismo kernel que el host, se pueden identificar cruzando el cgroup del proceso sospechoso contra los PID raíz de cada contenedor:
cat /proc/2129157/cgroup
# 0::/system.slice/docker-2fdd5fc4bcedcf2d013422f7f08a9d05cf5129d6ad7da446b3dd6c4953db1fd1.scope
El padre del proceso era s6-svscan (el init que usa la imagen oficial de Gitea), y el PID raíz coincidía exactamente con el contenedor gitea. Confirmado: mi Gitea estaba comprometido.
Paso 3 — ¿Qué tan grave es?
Un vistazo más profundo mostró que no era un proceso aislado, sino un watchdog auto-respawneante:
478778 1570219 pavilion /bin/sh /tmp/idle
478779 1570219 pavilion /bin/sh /tmp/.rguard
478816 478778 pavilion /bin/sh /tmp/idle
1510560 1570219 pavilion /tmp/javab
/tmp/idle relanzaba /tmp/.rguard en loop constante, y este a su vez volvía a levantar /tmp/javab (el minero) cada vez que se moría. Maté el proceso original y, segundos después, ya había uno nuevo con PID distinto. Un kill -9 puntual no servía de nada.
El vector: un bypass que nunca debí dejar así
¿Cómo entró? Reviso la config de mi Cloudflare Tunnel y ahí está: gitea.mi-dominio.com apuntaba directo al contenedor, sin Cloudflare Access — el resto de mis servicios sí tenían esa capa de autenticación antes de siquiera llegar a la app, pero gitea se me había quedado en modo "bypass intencional" de cuando lo configuré por primera vez, y nunca volví a revisarlo.
Los logs de acceso de Gitea confirmaron el patrón: tráfico masivo de bots scrapeando repos públicos, y perfiles de usuario con nombres claramente generados (hannacoventry, nicki21y40455, wilheminareave). Un scanner automatizado había encontrado mi instancia expuesta, y a partir de ahí, todo fue cuesta abajo.
La contención: matar el árbol completo, no la rama
Con un watchdog que se auto-regenera, no tiene sentido perseguir procesos uno por uno. La solución correcta es matar el namespace completo de un solo golpe:
# 1. Preservar evidencia mientras el proceso sigue vivo
cp /proc/2129157/exe ~/incident/javab.bin
docker cp gitea:/tmp/idle ~/incident/idle.bin
docker cp gitea:/tmp/.rguard ~/incident/rguard.bin
docker cp gitea:/tmp/.khp ~/incident/khp.bin
# 2. Verificar que no hay escape hacia el host
docker inspect gitea --format '{{range .Mounts}}{{.Source}} -> {{.Destination}}{{println}}{{end}}'
# (sin docker.sock montado = sin vector de escape a Docker/host)
# 3. Aislar de la red
docker network disconnect proxy gitea
# 4. Matar TODO el árbol de una — más confiable que perseguir PIDs
docker stop -t 5 gitea
docker stop mata todo el namespace del contenedor de una sola vez, sin dejar huérfanos. Es mucho más confiable que intentar kill proceso por proceso contra algo que se regenera solo.
IOCs (Indicators of Compromise)
Para quien administre su propio Gitea expuesto y quiera chequear si tiene el mismo problema, estos son los hashes de los binarios que encontré:
| Archivo | Tamaño | SHA-256 |
|---|---|---|
javab | 8.6 MB | 4d1a59ee3632459f3795ecf7e85921cb0d86f8befc9c99608978810f9517ad3e |
idle | 8.6 KB | 4d44672808f5843490405b4fb5b6100dc9d3d4c0e5f4fa506b462b8b68d614d7 |
.rguard | 1.2 KB | 9a7360914b9722831d140a626958e32b1930c3484db15511e5dc6723c4aea13c |
.khp | 3.1 KB | 2a8412534bb8f71492a8f04247461d2bde0a5327dac0dd9f0c5ac4552b4a014c |
El patrón de nombres (idle, .rguard — literalmente "rival guard", que mata mineros de la competencia — y un dropper principal) es característico de campañas de cryptojacking tipo Kinsing/TeamTNT que escanean masivamente internet buscando servicios mal configurados.
La limpieza: cuando el CLI oficial es demasiado lento
Con el contenedor contenido, tocaba limpiar. Reconstruí Gitea desde una imagen parcheada (capa de escritura nueva = malware fuera, los repos en el volumen /data siguen intactos) y me encontré con la magnitud real del problema:
Total usuarios: 4744
Usuarios legítimos: 1
Usuarios spam: 4743
4743 cuentas registradas con emails desechables (spambog.com, tempr.email, mailcatch.com...). El registro llevaba tiempo abierto sin que me diera cuenta.
Mi primer intento de limpieza fue el camino "correcto": el CLI oficial de Gitea, uno por uno.
gitea admin user delete --username X --purge
El problema: cada invocación levanta el binario completo de Gitea (init de storage, conexión a DB, indexers...). Con 4743 cuentas, eso son horas. Peor: lancé el loop sin throttling y el load average del servidor se disparó a 33 en cuestión de minutos, saturando el equipo.
La solución rápida: SQL directo contra la base SQLite, con el contenedor detenido para evitar corrupción por escrituras concurrentes.
docker stop gitea
cp gitea.db gitea.db.pre-purge.bak # backup antes de tocar nada
sqlite3 gitea.db <<'SQL'
DELETE FROM repository WHERE owner_name != 'mi-usuario-admin';
DELETE FROM watch WHERE user_id IN (SELECT id FROM user WHERE name != 'mi-usuario-admin');
DELETE FROM star WHERE uid IN (SELECT id FROM user WHERE name != 'mi-usuario-admin');
-- (y así con cada tabla que referencia usuarios)
DELETE FROM user WHERE name != 'mi-usuario-admin';
VACUUM;
SQL
De horas a segundos. Después corrí gitea doctor check --all --fix para reconciliar índices huérfanos (avatares, releases sin repo, branches sin repo) y quedó todo limpio.
