ho scritto un honeypot in go che risponde a ssh, ftp e http senza parlare nessuno dei tre

cybersecurity · Oct 4, 2026 · 6 min read

volevo vedere cosa bussa alla mia porta alle tre di notte. così ho scritto beetrap: ascolta su tre porte, manda un banner di servizio falso, legge una riga di risposta, la stampa in una tui e chiude.

questo è tutto il prodotto. non c'è dell'altro.

cosa è davvero

tre file go, 200 righe, 4.720 byte di sorgente:

file righe byte lavoro
cmd/beetrap/main.go 19 367 crea un canale, avvia la tui
internal/capture/capture.go 58 1.119 ascolta, saluta, legge una riga
internal/ui/ui.go 123 3.234 disegna il log, conta i colpi

go.mod ha due dipendenze dirette ed entrambe sono la tui: bubbletea 0.27.0 e lipgloss 0.12.1. le altre 18 sono indirette, e ognuna arriva a causa del terminale.

la parte che non mi aspettavo: tutto il lato di rete ha zero codice di terze parti. capture.go importa bufio, fmt, io, net, strings, time — tutta libreria standard, tutto quanto. la «implementazione del protocollo» è una mappa da stringa a stringa:

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": "",
}

questa è tutta la conoscenza dei protocolli del progetto, ed è lunga 1.119 byte.

perché go, in specifico

  • niente root, per impostazione predefinita. le porte sono 2222, 2121 e 8080, tutte sopra 1023, quindi il primo avvio non vuole privilegi. un honeypot in python mi darebbe la stessa cosa. non è un motivo per cui mi piace go.
  • una goroutine per connessione, in una riga. go _handle(conn, service, ch) dentro il ciclo di accept. la stessa forma in java è un pool di thread, una coda limitata e una politica di rifiuto. qui è una parola chiave, e il modo di fallimento è uno slowloris fermo su una goroutine per otto secondi.
  • un file statico. go build -o beetrap ./cmd/beetrap produce un singolo eseguibile senza interprete, senza venv, senza node_modules. un honeypot è qualcosa che lasci girare su una macchina che nessuno mantiene, quindi meno pezzi mobili è meglio.
  • le librerie per la tui. bubbletea ti dà l'architettura a update-loop per un terminale, lipgloss fa lo stile. per un log in sola lettura che non torna mai indietro, il framework costa circa 120 righe e mi risparmia di scrivere a mano gli escape ansi e di parsare la larghezza del terminale.
  • net era già nella scatola. la ragione onesta: non avevo mai scritto un listener, e net.Listen + Accept l'hanno fatto sembrare quattro righe. non ho scelto go per la concorrenza. la concorrenza è venuta gratis e non ho mai dovuto guadagnarmela.

come l'ho strutturato, e perché la struttura è sbagliata

non c'è un router. ci sono tre goroutine, hardcoded, avviate dall'Init() della tui:

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)
}

ecco la tabella di instradamento: tre tuple, in una funzione che non ha niente a che fare con la rete.

i livelli sono invertiti e preferisco nominarlo invece di rifattorizzare in silenzio. internal/capture è il package che apre davvero i socket e non sa quali porte serve né perché. internal/ui — il package il cui unico lavoro è disegnare testo colorato in un terminale — possiede la topologia di deploy. se aggiungo telnet, modifico il codice di disegno.

la forma giusta è una struct di configurazione costruita in main, un ciclo su uno slice di servizi, e un confine di package che non sa niente delle porte. farei anche del nome del servizio un tipo invece di una string grezza, perché adesso "SSH" è una chiave di lookup in due mappe che vivono in due package diversi e niente verifica che le due mappe siano d'accordo.

le connessioni arrivano allo schermo attraverso un unico canale bufferizzato di dimensione 32, creato in main:

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

la ui non chiama mai la rete. aspetta sul canale, riceve un messaggio, lo aggiunge, riattiva l'attesa. è lo stesso message loop del frontend di questo sito, per la stessa ragione: ciò che disegna non deve essere ciò che blocca. il ciclo di accept scrive solo su un canale e non tocca mai la vista.

cosa mi hanno insegnato i protocolli, e non mi aspettavo niente di tutto questo

la cosa che avevo in testa sbagliata prima di scriverlo: pensavo che un honeypot dovesse parlare il protocollo. non deve. tre servizi, tre regole completamente diverse su chi parla per primo:

servizio porta chi parla per primo cosa mandiamo cosa catturiamo
SSH 2222 il server SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.6 la stringa di versione del client
FTP 2121 il server 220 (vsFTPd 3.0.5) USER <qualcuno>
HTTP 8080 il client niente GET / HTTP/1.1, o peggio

ssh manda per primo la sua stringa di identificazione. è il protocollo raro in cui il server parla senza essere interrogato e il client risponde con la propria. quindi la riga che leggiamo indietro non è una credenziale, è un'impronta: SSH-2.0-OpenSSH_9.6 ti dice che il client è un openssh vero, quale versione, su quale distribuzione. quello è tutto il payload dell'handshake di uno scanner, e lo ottieni da una sola riga.

la prima riga di ftp è il nome utente. il 220 del server è il saluto, e la cosa immediatamente successiva che dice un client è USER root, o USER admin, o USER anonymous. una riga dentro il protocollo e hai la lista di credenziali che un bot sta percorrendo, prima che abbia mandato qualcosa di segreto.

http è l'inverso, e la stringa vuota nella mappa non è un bug. un server http che scrive qualcosa prima di leggere la richiesta è rotto. la mappa ha "HTTP": "" e l'handler controlla con if b := _banners[service]; b != "" — quel controllo è l'intero motivo per cui http si comporta, ed è una riga che per due protocolli è portante e per il terzo è corretta.

la lezione generale: la prima lettura di un honeypot è il byte di valore più alto che vedrà mai. identificazione, non autenticazione. impari chi c'è là fuori dall'handshake, e non devi mai implementare la parte in cui si loggano.

cosa non fa

  • non vede mai una password. nessuno scambio di chiavi, nessun metodo auth none, nessun pam, nessun /etc/shadow. il socket viene salutato, si legge una riga, defer conn.Close() scatta. un client ssh vero aspetta scadranno gli 8 secondi e molla. è il design corretto, non una funzionalità mancante: un honeypot che accetta login è una passività con un form di login sopra.
  • cattura una riga, 512 byte, entro 8 secondi. io.LimitReader(conn, 512) e poi ReadString('\\n'). un client che manda un blob binario senza newline ottiene quello che ReadString restituisce, cioè spazzatura. va bene, purché lo sappia.
  • una goroutine per connessione, senza limite. niente limita la concorrenza. un socket che si connette e non manda niente tiene una goroutine per tutti gli otto secondi. mille di quelli sono mille goroutine. sopportabile su una macchina personale, scortese su un piccolo vps.
  • niente viene scritto su disco. il log vive in uno slice in ram, ridimensionato a height - 10 righe solo quando viene disegnato. premi q ed è sparito. un honeypot che dimentica è uno screensaver con un'interfaccia di rete.
  • le porte non si possono cambiare senza modificare il sorgente go. niente flag, niente variabili d'ambiente, niente file di configurazione.

i due bug che correggerei prima di fidarmi

1. un bind fallito è completamente silenzioso. VxStartListener parte così:

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

l'errore viene buttato. non stampato, non contato, non renderizzato. se la 2222 è già occupata, quella goroutine muore durante l'avvio e il contatore SSH resta a zero per sempre — accanto a un contatore FTP vivo, nella stessa intestazione, con l'aspetto esatto di una giornata tranquilla.

questo è il peggior modo di fallire che uno strumento di sicurezza possa avere: è indistinguibile dal successo. la correzione sono tre righe. manda l'errore giù lo stesso canale, coloralo di rosso nell'intestazione, esci con codice diverso da zero.

2. il readme ti dice di fare qualcosa che il codice non può fare. dice:

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

«per legare le porte standard (22, 21, 80) senza root». non legherai la porta 22. le porte sono letterali intere dentro ui.Init(). la capability viene concessa a un binario che non chiede mai una porta privilegiata. la correzione è un flag, non una capability:

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

l'intento di quella sezione del readme non è sbagliato, è che il codice semplicemente non è collegato. e il motivo si vede nella storia: quattro dei cinque commit del repository si intitolano «Add files via upload». i file sono stati scritti da un'altra parte e incollati, e il readme è arrivato con loro, descrivendo una versione di questo programma che non esiste.

più piccolo, ma reale: il ciclo di accept fa if err != nil { continue }. quando un listener va davvero giù, Accept restituisce subito e per sempre lo stesso errore, e la goroutine gira al 100% di cpu. dovrebbe fare break.

cosa direi al me di ieri

tutta la faccenda è una lezione in costume da rete: non devi implementare un protocollo per imparare da un protocollo. mandi la prima riga giusta, leggi la risposta, chiudi. tutta l'intelligenza di un honeypot sta in quella prima lettura, e tutto ciò che viene dopo — scambio di chiavi, autenticazione, una shell finta — è costo, passività e codice che dovrei essere felice di non aver scritto.

e la seconda lezione è quella noiosa, che vale per ogni strumento che abbia mai pubblicato: se ti inghiotti un errore hai pubblicato qualcosa che ti mente per omissione. il fallimento di bind è un singolo return. è tutta la distanza tra uno strumento di sicurezza e uno screensaver.