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.
Verificando que no escaló al host
La pregunta que más importa después de contener algo así: ¿se quedó en el contenedor, o llegó al host?
Chequeos que corrí:
docker.sockmontado en el contenedor: no. Sin esto, no hay vector directo de escape a Docker.- Barrido de TODOS los procesos del host (dentro y fuera de cualquier contenedor) buscando ejecutables desde
/tmp,/dev/shm, o con exe borrado: cero resultados fuera de gitea. - Cron y timers systemd: sin entradas nuevas.
/etc/passwd: sin usuarios nuevos.~/.ssh/authorized_keys: sin keys extrañas (solo las que yo reconocía).
Conclusión: el compromiso quedó 100% contenido al contenedor. Pero llegar a esa conclusión me tomó revisar manualmente media docena de vectores distintos, uno por uno. Y ahí fue donde me di cuenta de que necesitaba algo mejor que "revisar manualmente cuando sospecho que algo anda mal".
Lo que armé después: monitoreo en tiempo real con ntfy
Ya tenía ntfy self-hosted corriendo para notificaciones de WatchYourLAN. Lo reutilicé como canal central de alertas de seguridad, y armé un set de watchers que corren solos y me avisan al celular apenas pasa algo raro.
El watchdog periódico
Un script + systemd timer cada 15 minutos que reproduce las mismas señales que encontré a mano durante el incidente:
# Procesos corriendo desde /tmp, /dev/shm, o con exe borrado
for pid in $(ps -eo pid --no-headers); do
exe=$(readlink -f "/proc/$pid/exe" 2>/dev/null)
echo "$exe" | grep -qE '^/tmp/|^/dev/shm/|\(deleted\)$' && alert "Proceso sospechoso" "$exe"
done
# Drift de Cloudflare Access: cualquier subdominio publico
# que debería estar gateado y dejó de estarlo
resp=$(curl -sS -D - -o /dev/null "https://${host}/")
echo "$resp" | grep -qi "cloudflareaccess.com" || alert "Access caido en $host" "..."
# Spike de cuentas nuevas (el mismo patron que me exploto gitea)
delta=$((count_actual - count_anterior))
[ "$delta" -gt 20 ] && alert "Spike de usuarios en gitea" "+$delta cuentas nuevas"
Watchers en tiempo real
Además del chequeo periódico, cuatro servicios systemd de larga duración que hacen tail -f sobre eventos críticos y notifican al instante, no cada 15 minutos:
- Login SSH (éxito y fallos), leyendo
journalctl -f -u sshen vivo. - Uso de
sudo, quién y qué comando, víajournalctl -f -t sudo. - Eventos de Docker (
create,start,die,kill) directo del socket condocker events. - Cambios en
authorized_keys, con unsystemd path unitque dispara sin necesidad de polling — el kernel avisa al sistema apenas el archivo se modifica.
# authkeys-watch.path
[Path]
PathModified=/home/pavilion/.ssh/authorized_keys
[Install]
WantedBy=multi-user.target
Todo llega al mismo topic de ntfy, con prioridad distinta según severidad. El costo de armar esto: una tarde. El costo de no tenerlo: un fin de semana entero de forense reactivo, más el tiempo que ese minero estuvo corriendo sin que nadie se diera cuenta.
Lecciones que me llevo
-
Todo bypass de auth es una decisión con fecha de vencimiento. Si vas a exponer algo sin Cloudflare Access (o el equivalente que uses), ponte un recordatorio para revisarlo. Yo no lo hice, y se me olvidó.
-
Un binario borrado que sigue corriendo no es un bug, es una bandera roja.
readlink -f /proc/PID/exemostrando(deleted)en un proceso que no debería estar ahí es motivo suficiente para investigar de inmediato. -
No persigas procesos que se auto-regeneran uno por uno. Si hay un watchdog relanzando el malware, mata el namespace completo (
docker stop) en vez de jugar al gato y al ratón conkill. -
El CLI "oficial" no siempre es la herramienta correcta para limpieza masiva. SQL directo contra la base, con el servicio detenido y un backup previo, fue órdenes de magnitud más rápido y no le costó nada al servidor.
-
Detectar es más barato que investigar después. Los cuatro watchers que armé no habrían prevenido el ataque inicial, pero sí me hubieran avisado en minutos en vez de que lo descubriera por accidente mientras configuraba otra cosa.
Si administras tu propio homelab con servicios expuestos a internet, aunque sea parcialmente, la pregunta que te dejo es la misma que me hice yo: ¿sabes hoy mismo, sin revisar nada, si algo anda corriendo donde no debería? Si la respuesta es "no estoy seguro", ya sabes por dónde empezar.
Conclusión
Este incidente no me lo causó una vulnerabilidad exótica ni un 0-day. Fue un bypass de auth que yo mismo dejé activo y me olvidé de revisar. Ese es el patrón real detrás de la mayoría de los compromisos en homelabs: no fallas sofisticadas, sino configuraciones temporales que nunca vuelven a ser "temporales" en la práctica.
Lo que salvó el incidente de ser peor fue la contención: docker.sock sin montar, namespace aislado, y la disciplina de verificar cada vector posible de escape antes de dar el caso por cerrado. Y lo que evita que se repita no es paranoia constante, sino visibilidad automática — los watchers de ntfy ahora hacen en segundos lo que a mí me tomó un fin de semana entero de forense manual.
Esto es entropía técnica en su forma más pura: la tendencia natural de todo entorno tecnológico a degradarse con el tiempo si no hay gobernanza activa sobre él. Yo tenía la intención de que todos mis servicios pasaran por Cloudflare Access, pero esa intención nunca se convirtió en una política medible y verificable — era solo una expectativa que vivía en mi cabeza, no un estado que se auditara solo. Y sin evidencia continua de que la intención se cumple, la brecha entre lo que creo tener y lo que realmente tengo corriendo se abre sola, silenciosa, hasta que alguien (o algo automatizado) la encuentra primero.
Los watchers que armé después son, en el fondo, políticas: reglas binarias, medibles, que verifican en vez de confiar. "Todo subdominio público debe estar gateado por Access" ya no es un recordatorio mental, es un chequeo automatizado que corre cada 15 minutos y avisa apenas la realidad se desalinea de la intención. Ese es el cambio real que dejó este incidente: pasar de operar TI reactivamente, confiando en que las cosas siguen como las configuré, a gobernarla activamente, con evidencia de que siguen así.
Referencias
"La seguridad es un proceso, no un producto."

