j'ai écrit un honeypot en go qui répond à ssh, ftp et http sans parler aucun d'eux
cybersecurity · Oct 4, 2026 · 6 min read
je voulais voir ce qui frappe à ma porte à trois heures du matin. alors j'ai écrit beetrap : il écoute sur trois ports, envoie une fausse bannière de service, lit une ligne en retour, l'affiche dans une tui, et raccroche.
c'est tout le produit. il n'y a pas plus que ça.
## ce que c'est réellement
trois fichiers go, 200 lignes, 4.720 octets de source :
| fichier | lignes | octets | rôle |
| --- | --- | --- | --- |
| `cmd/beetrap/main.go` | 19 | 367 | créer un canal, démarrer la tui |
| `internal/capture/capture.go` | 58 | 1.119 | écouter, saluer, lire une ligne |
| `internal/ui/ui.go` | 123 | 3.234 | dessiner le journal, compter les hits |
`go.mod` a deux dépendances directes et **les deux sont la tui** : bubbletea 0.27.0 et lipgloss 0.12.1. les 18 autres sont indirectes, et chacune arrive à cause du terminal.
la partie que je n'attendais pas : **tout le côté réseau n'a aucun code tiers.** `capture.go` importe `bufio`, `fmt`, `io`, `net`, `strings`, `time` — uniquement la bibliothèque standard, en entier. la « implémentation du protocole » est une table de chaînes vers chaînes :
```go
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": "",
}
```
c'est tout le savoir protocolaire du projet, et il fait 1.119 octets.
## pourquoi go, précisément
* **pas de root, par défaut.** les ports sont 2222, 2121 et 8080, tous au-dessus de 1023, donc le premier lancement ne demande aucun privilège. un honeypot en python m'aurait donné la même chose. ce n'est pas une raison d'aimer go.
* **une goroutine par connexion, en une ligne.** `go _handle(conn, service, ch)` dans la boucle d'acceptation. la même forme en java, c'est un pool de threads, une file bornée et une politique de rejet. ici c'est un mot-clé, et le mode de défaillance, c'est un slowloris assis sur une goroutine pendant huit secondes.
* **un seul fichier.** `go build -o beetrap ./cmd/beetrap` produit un exécutable unique, sans interpréteur, sans venv, sans `node_modules`. un honeypot, c'est quelque chose qu'on laisse tourner sur une machine que personne ne maintient, donc moins il y a de pièces mobiles, mieux c'est.
* **les bibliothèques de tui.** bubbletea vous donne l'architecture de boucle de mise à jour pour un terminal, lipgloss fait le style. pour un journal en lecture seule qui ne défile jamais vers l'arrière, le framework coûte environ 120 lignes et m'épargne d'écrire à la main des séquences ansi et de parser la largeur du terminal.
* **`net` était déjà dans la boîte.** la raison honnête : je n'avais jamais écrit de listener, et `net.Listen` + `Accept` ont fait que ça ressemblait à quatre lignes. je n'ai pas choisi go pour la concurrence. la concurrence est venue gratuitement et je n'ai jamais eu à la mériter.
## comment je l'ai structuré, et pourquoi la structure est mauvaise
il n'y a pas de routeur. il y a trois goroutines, codées en dur, démarrées depuis `Init()` de la tui :
```go
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)
}
```
c'est la table de routage : trois triplets, dans une fonction qui n'a rien à voir avec le réseau.
le cloisonnement est inversé et je préfère le nommer que le refactoriser discrètement. `internal/capture` est le paquet qui ouvre réellement les sockets, et il n'a aucune idée des ports qu'il sert ni pourquoi. `internal/ui` — le paquet dont le seul travail est de dessiner du texte coloré dans un terminal — possède la topologie de déploiement. si j'ajoute telnet, je modifie le code de dessin.
la bonne forme serait une structure de configuration construite dans `main`, une boucle sur un tableau de services, et une frontière de paquet qui ne sait rien des ports. je ferais aussi du nom de service un type plutôt qu'un `string` nu, parce que pour l'instant `"SSH"` est une clé de recherche dans deux tables situées dans deux paquets, et rien ne vérifie que les deux tables concordent.
les connexions parviennent à l'écran par un unique canal tamponné de taille 32, créé dans `main` :
```go
ch := make(chan capture.VxConnection, 32)
p := tea.NewProgram(ui.VxNewModel(ch), tea.WithAltScreen())
```
l'ui n'appelle jamais le réseau. elle attend sur le canal, reçoit un message, l'ajoute, réarme l'attente. c'est la même boucle de messages que le frontend de ce site, pour la même raison : **ce qui dessine ne doit pas être ce qui bloque.** la boucle d'acceptation n'écrit que dans un canal et ne touche jamais à la vue.
## ce que les protocoles m'ont appris, et dont je n'attendais rien
ce que j'avais dans la tête avant d'écrire : je croyais qu'un honeypot devait parler le protocole. il ne le doit pas. trois services, trois règles complètement différentes sur qui parle en premier :
| service | port | qui parle en premier | ce qu'on envoie | ce qu'on capture |
| --- | --- | --- | --- | --- |
| SSH | 2222 | le serveur | `SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.6` | la chaîne de version du client |
| FTP | 2121 | le serveur | `220 (vsFTPd 3.0.5)` | `USER <quelqu'un>` |
| HTTP | 8080 | le client | rien du tout | `GET / HTTP/1.1`, ou pire |
**ssh envoie d'abord sa chaîne d'identification.** c'est le rare protocole où le serveur parle sans y être invité et où le client répond avec la sienne. la ligne qu'on relit n'est donc pas un identifiant, c'est une empreinte : `SSH-2.0-OpenSSH_9.6` vous dit que le client est un vrai openssh, quelle version, sur quelle distribution. c'est toute la charge utile d'un handshake de scanner, et vous l'obtenez en une seule ligne.
**la première ligne de ftp est le nom d'utilisateur.** le `220` du serveur est la salutation, et la toute première chose que dit un client, c'est `USER root`, ou `USER admin`, ou `USER anonymous`. une ligne à l'intérieur du protocole, et vous avez la liste d'identifiants qu'un bot est en train de parcourir, avant qu'il n'ait envoyé quoi que ce soit de secret.
**http est l'inverse, et la chaîne vide dans la table n'est pas un bug.** un serveur http qui écrit quoi que ce soit avant d'avoir lu la requête est cassé. la table contient `"HTTP": ""` et le gestionnaire teste avec `if b := _banners[service]; b != ""` — c'est cette garde qui fait tout le travail, et c'est une ligne qui se trouve être porteuse de vie pour deux protocoles et correcte pour le troisième.
la leçon générale : **la première lecture d'un honeypot est l'octet de plus grande valeur qu'il verra jamais.** identification, pas authentification. vous apprenez qui est dehors grâce au handshake, et vous n'avez jamais à implémenter la partie où ils se connectent.
## ce qu'il ne fait pas
* **il ne voit jamais de mot de passe.** aucun échange de clés, aucune méthode d'authentification `none`, aucun `pam`, aucun `/etc/shadow`. le socket est salué, une ligne est lue, `defer conn.Close()` se déclenche. un vrai client `ssh` attend le délai de 8 secondes et abandonne. c'est la bonne conception, pas une fonctionnalité manquante : un honeypot qui accepte les connexions est une responsabilité avec un formulaire de connexion collé dessus.
* **il capture une ligne, 512 octets, en 8 secondes.** `io.LimitReader(conn, 512)` puis `ReadString('\n')`. un client qui envoie un bloc binaire sans saut de ligne obtient ce que `ReadString` renvoie, c'est-à-dire n'importe quoi. correct, tant que je le sais.
* **une goroutine par connexion, sans limite.** rien ne plafonne la concurrence. un socket qui se connecte et n'envoie rien retient une goroutine pendant les huit secondes complètes. mille de ceux-là, c'est mille goroutines. supportable sur une machine personnelle, impoli sur un petit vps.
* **rien n'est écrit sur le disque.** le journal vit dans un tableau en ram, réduit à `height - 10` lignes seulement au moment du dessin. appuyez sur `q` et il a disparu. un honeypot qui oublie, c'est un économiseur d'écran avec une interface réseau.
* **les ports ne peuvent pas être changés sans modifier le code go.** pas d'options, pas de variables d'environnement, pas de fichier de configuration.
## les deux bugs que je corrigerais avant de lui faire confiance
**1. un bind qui échoue est totalement silencieux.** `VxStartListener` démarre ainsi :
```go
ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
if err != nil {
return
}
```
l'erreur est jetée. ni affichée, ni comptée, ni rendue. si le port 2222 est déjà pris, cette goroutine meurt au démarrage et le compteur ssh reste bloqué à zéro pour toujours — à côté d'un compteur ftp en vie, dans le même en-tête, avec exactement l'air d'une journée tranquille.
c'est le pire mode de défaillance qu'un outil de sécurité puisse avoir : **il est indiscernable du succès.** la correction tient en trois lignes. envoyer l'erreur dans le même canal, la peindre en rouge dans l'en-tête, sortir avec un code non nul.
**2. le readme vous demande de faire quelque chose que le code ne peut pas faire.** il dit :
```bash
go build -o beetrap ./cmd/beetrap
sudo setcap cap_net_bind_service=ep ./beetrap
```
« pour lier les ports standards (22, 21, 80) sans root ». vous ne lierez pas le port 22. les ports sont des littéraux entiers à l'intérieur de `ui.Init()`. la capability est accordée à un binaire qui ne demande jamais de port privilégié. la correction, c'est une option, pas une capability :
```go
sshPort := flag.Int("ssh", 2222, "port to fake ssh on")
```
l'intention dans cette section du readme n'est pas fausse, le code n'est simplement pas relié. et la raison se voit dans l'historique : quatre des cinq commits du dépôt s'intitulent « Add files via upload ». les fichiers ont été écrits ailleurs puis collés, et le readme est arrivé avec eux, décrivant une version de ce programme qui n'existe pas.
plus petit, mais réel : la boucle d'acceptation fait `if err != nil { continue }`. quand un listener tombe vraiment, `Accept` renvoie la même erreur immédiatement et pour toujours, et la goroutine tourne à 100% cpu. elle devrait faire `break`.
## ce que je dirais à mon moi d'avant
toute la chose est une leçon en costume de réseau : **vous n'avez pas besoin d'implémenter un protocole pour en apprendre quelque chose.** vous envoyez la bonne première ligne, vous lisez la réponse, vous raccrochez. toute l'intelligence d'un honeypot réside dans cette première lecture, et tout ce qui suit — échange de clés, authentification, un faux shell — c'est du coût, de la responsabilité et du code que je suis heureux de ne pas avoir écrit.
et la deuxième leçon est la moins spectaculaire, et elle s'applique à tous les outils que j'ai jamais livrés : **avaler une erreur, c'est livrer quelque chose qui vous ment par omission.** l'échec de bind est un simple `return`. c'est toute la distance entre un outil de sécurité et un économiseur d'écran.