escribí un honeypot en go que responde a ssh, ftp y http sin hablar ninguno

cybersecurity · Oct 4, 2026 · 6 min read

quería ver qué toca mi puerta a las tres de la mañana. así que escribí beetrap: escucha en tres puertos, envía un banner de servicio falso, lee una línea de vuelta, la imprime en una interfaz de terminal y cuelga.

ese es el producto entero. no hay más.

qué es en realidad

tres archivos de go, 200 líneas, 4.720 bytes de código:

archivo líneas bytes trabajo
cmd/beetrap/main.go 19 367 crear un canal, arrancar la interfaz
internal/capture/capture.go 58 1.119 escuchar, saludar, leer una línea
internal/ui/ui.go 123 3.234 dibujar el registro, contar los golpes

go.mod tiene dos dependencias directas y ambas son la interfaz: bubbletea 0.27.0 y lipgloss 0.12.1. las otras 18 son indirectas, y todas llegan por el terminal.

la parte que no esperaba: toda la parte de red tiene cero código de terceros. capture.go importa bufio, fmt, io, net, strings, time — todo biblioteca estándar, todo. la «implementación del protocolo» es un mapa de cadena a cadena:

var _banners = map[string]string{
	"SSH":  "SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.6\r\n",
	"FTP":  "220 (vsFTPd 3.0.5)\r\n",
	"HTTP": "",
}

ese es todo el conocimiento de protocolo del proyecto, y son 1.119 bytes.

por qué go, concretamente

  • sin root, por defecto. los puertos son 2222, 2121 y 8080, todos por encima de 1023, así que la primera ejecución no necesita privilegios. un honeypot en python me daría lo mismo. esta no es una razón para que me guste go.
  • una goroutine por conexión, en una línea. go _handle(conn, service, ch) dentro del bucle de aceptación. la misma forma en java es un conjunto de hilos, una cola acotada y una política de rechazo. aquí es una palabra clave, y el modo de fallo es un slowloris occupyendo una goroutine durante ocho segundos.
  • un único archivo estático. go build -o beetrap ./cmd/beetrap produce un solo ejecutable sin intérprete, sin venv, sin node_modules. un honeypot es algo dejas corriendo en una máquina que nadie mantiene, así que cuanto menos piezas móviles mejor.
  • las bibliotecas de interfaz. bubbletea te da la arquitectura de bucle de actualización para un terminal, lipgloss hace el estilo. para un registro de solo lectura que nunca retrocede, el marco cuesta unas 120 líneas y me ahorra escribir a mano escapes ansi y analizar el ancho del terminal.
  • net ya venía de serie. la razón honesta: nunca había escrito un listener, y net.Listen + Accept lo hacían parecer como cuatro líneas. no elegí go por la concurrencia. la concurrencia vino gratis y nunca tuve que ganármela.

cómo lo organicé, y por qué la estructura está mal

no hay router. hay tres goroutines, fijas, arrancadas desde el Init() de la interfaz:

func (m VxModel) Init() tea.Cmd {
	go capture.VxStartListener("SSH", 2222, m.ch)
	go capture.VxStartListener("FTP", 2121, m.ch)
	go capture.VxStartListener("HTTP", 8080, m.ch)
	return _wait(m.ch)
}

esa es la tabla de enrutado: tres tuplas, en una función que no tiene nada que ver con redes.

el estratificado está invertido y prefiero nombrarlo antes que refactorizarlo en silencio. internal/capture es el paquete que realmente abre sockets y no tiene ni idea de qué puertos sirve ni por qué. internal/ui — el paquete cuyo trabajo entero es dibujar texto de colores en un terminal — es dueño de la topología de despliegue. si añado telnet, edito el código de dibujo.

la forma correcta es una estructura de configuración construida en main, un bucle sobre una lista de servicios, y un límite de paquete que no sepa nada de puertos. también haría que el nombre del servicio fuera un tipo en lugar de un string suelto, porque ahora mismo "SSH" es una clave de búsqueda en dos mapas que viven en dos paquetes y nada comprueba que los dos mapas coincidan.

las conexiones llegan a la pantalla a través de un canal con búfer de tamaño 32, creado en main:

ch := make(chan capture.VxConnection, 32)
p := tea.NewProgram(ui.VxNewModel(ch), tea.WithAltScreen())

la interfaz nunca llama a la red. espera en el canal, recibe un mensaje, lo añade, rearma la espera. ese es el mismo bucle de mensajes que el frontend de este sitio, por la misma razón: lo que dibuja no debe ser lo que bloquea. el bucle de aceptación solo escribe en un canal y nunca toca la vista.

lo que me enseñaron los protocolos, y no esperaba nada de eso

lo que tenía mal en la cabeza antes de escribirlo: pensaba que un honeypot tiene que hablar el protocolo. no es así. tres servicios, tres reglas completamente distintas sobre quién habla primero:

servicio puerto quién habla primero qué enviamos qué capturamos
SSH 2222 el servidor SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.6 la cadena de versión del propio cliente
FTP 2121 el servidor 220 (vsFTPd 3.0.5) USER <alguien>
HTTP 8080 el cliente nada en absoluto GET / HTTP/1.1, o peor

ssh envía primero su cadena de identificación. es el protocolo raro en el que el servidor habla sin que se lo pidan y el cliente responde con la suya. así que la línea que leemos de vuelta no es una credencial, es una huella: SSH-2.0-OpenSSH_9.6 te dice que el cliente es un openssh real, qué versión, en qué distribución. esa es toda la carga útil del handshake de un escáner, y la consigues de una sola línea.

la primera línea de ftp es el nombre de usuario. el 220 del servidor es el saludo, y lo siguiente que dice un cliente es USER root, o USER admin, o USER anonymous. una línea dentro del protocolo y ya tienes la lista de credenciales que un bot está recorriendo, antes de haber enviado nada secreto.

http es el inverso, y la cadena vacía en el mapa no es un bug. un servidor http que escribe algo antes de leer la petición está roto. el mapa tiene "HTTP": "" y el manejador comprueba con if b := _banners[service]; b != "" — esa comprobación es toda la razón de que http se comporte bien, y es una línea que casualmente sostiene dos protocolos y es correcta para el tercero.

la lección general: la primera lectura de un honeypot es el byte de más valor que jamás verá. identificación, no autenticación. aprendes quién está ahí por el handshake, y nunca tienes que implementar la parte en la que se registran.

lo que no hace

  • nunca ve una contraseña. no hay intercambio de claves, ni método de autenticación none, ni pam, ni /etc/shadow. el socket recibe un saludo, se lee una línea, salta defer conn.Close(). un cliente ssh real espera el plazo de 8 segundos y se rinde. ese es el diseño correcto, no una característica que falta: un honeypot que acepta registros es una responsabilidad con un formulario de acceso.
  • captura una línea, 512 bytes, en 8 segundos. io.LimitReader(conn, 512) y luego ReadString('\n'). un cliente que envía un bloque binario sin salto de línea obtiene lo que devuelva ReadString, que es basura. bien, mientras lo sepa.
  • una goroutine por conexión, sin límite. nada acota la concurrencia. un socket que conecta y no manda nada ocupa una goroutine los ocho segundos completos. mil de esos son mil goroutines. viable en una máquina personal, descortés en un vps pequeño.
  • no se escribe nada en disco. el registro vive en un slice en ram, recortado a height - 10 filas solo cuando se dibuja. pulsa q y desaparece. un honeypot que olvida es un protector de pantalla con interfaz de red.
  • los puertos no se pueden cambiar sin editar código go. no hay banderas, ni variables de entorno, ni archivo de configuración.

los dos bugs que arreglaría antes de confiar en él

1. un enlace fallido es completamente silencioso. VxStartListener arranca así:

ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
if err != nil {
	return
}

el error se descarta. no se imprime, no se cuenta, no se renderiza. si el 2222 ya está ocupado, esa goroutine muere durante el arranque y el contador de ssh se queda en cero para siempre — junto a un contador de ftp vivo, en la misma cabecera, con el aspecto exacto de un día tranquilo.

este es el peor modo de fallo que puede tener una herramienta de seguridad: es indistinguible del éxito. el arreglo son tres líneas. mandar el error por el mismo canal, pintarlo de rojo en la cabecera, salir con código distinto de cero.

2. el readme te dice que hagas algo que el código no puede hacer. dice:

go build -o beetrap ./cmd/beetrap
sudo setcap cap_net_bind_service=ep ./beetrap

«para enlazar puertos estándar (22, 21, 80) sin root». no vas a enlazar el puerto 22. los puertos son literales enteros dentro de ui.Init(). la capacidad se le otorga a un binario que nunca pide un puerto privilegiado. el arreglo es una bandera, no una capacidad:

sshPort := flag.Int("ssh", 2222, "port to fake ssh on")

la intención de esa sección del readme no es incorrecta, el código simplemente no está conectado a ella. y la razón se ve en el historial: cuatro de los cinco commits del repositorio se titulan «Add files via upload». los archivos se escribieron en otro sitio y se pegaron, y el readme llegó con ellos, describiendo una versión de este programa que no existe.

más pequeño pero real: el bucle de aceptación hace if err != nil { continue }. cuando un listener se cae de verdad, Accept devuelve ese mismo error de inmediato y para siempre, y la goroutine gira al 100% de cpu. debería hacer break.

qué le diría a mi yo del pasado

todo esto es una lección con disfraz de red: no tienes que implementar un protocolo para aprender de él. mandas la primera línea correcta, lees la respuesta, cuelgas. toda la inteligencia de un honeypot vive en esa primera lectura, y todo lo que viene después — intercambio de claves, autenticación, una shell falsa — son costes, responsabilidades y código que deberíaándome mucho no haber escrito.

y la segunda lección es la aburrida, que aplica a todas las herramientas que he enviado: traga un error y habrás enviado algo que te miente por omisión. el fallo de enlace es un solo return. es toda la distancia entre una herramienta de seguridad y un protector de pantalla.