ich habe einen honeypot in go geschrieben, der ssh, ftp und http beantwortet, ohne eines davon zu sprechen

cybersecurity · Oct 4, 2026 · 6 min read

ich wollte sehen, was um drei uhr nachts an meine tür klopft. also habe ich beetrap geschrieben: es hört auf drei ports, sendet ein gefälschtes service-banner, liest eine zeile zurück, gibt sie in einer tui aus und legt auf.

das ist das gesamte produkt. mehr gibt es nicht.

## was es tatsächlich ist

drei go-dateien, 200 zeilen, 4.720 bytes quellcode:

| datei | zeilen | bytes | aufgabe |
| --- | --- | --- | --- |
| `cmd/beetrap/main.go` | 19 | 367 | einen channel anlegen, die tui starten |
| `internal/capture/capture.go` | 58 | 1.119 | hören, begrüßen, eine zeile lesen |
| `internal/ui/ui.go` | 123 | 3.234 | das log zeichnen, die treffer zählen |

`go.mod` hat zwei direkte abhängigkeiten und **beide sind die tui**: bubbletea 0.27.0 und lipgloss 0.12.1. die anderen 18 sind indirekt, und jede einzelne kommt wegen des terminals.

der teil, den ich nicht erwartet hatte: **die komplette netzwerkseite hat null code von dritten.** `capture.go` importiert `bufio`, `fmt`, `io`, `net`, `strings`, `time` — alles standardbibliothek, alles. die „protokollimplementierung" ist eine mappe von string zu string:

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

das ist das gesamliche protokollwissen im projekt, und es ist 1.119 bytes lang.

## warum go, konkret

* **kein root, standardmäßig.** die ports sind 2222, 2121 und 8080, alle über 1023, also braucht der erste lauf keine rechte. ein honeypot in python hätte mir dasselbe gegeben. das ist kein grund, go zu mögen.
* **eine goroutine pro verbindung, in einer zeile.** `go _handle(conn, service, ch)` in der accept-schleife. dieselbe form in java ist ein thread-pool, eine begrenzte queue und eine ablehnungsrichtlinie. hier ist es ein schlüsselwort, und der fehlermodus ist ein slowloris, der acht sekunden auf einer goroutine sitzt.
* **eine einzelne datei.** `go build -o beetrap ./cmd/beetrap` erzeugt eine einzige ausführbare datei ohne interpreter, ohne venv, ohne `node_modules`. ein honeypot ist etwas, das man auf einer maschine laufen lässt, die niemand pflegt, also je weniger bewegte teile, desto besser.
* **die tui-bibliotheken.** bubbletea gibt dir die update-loop-architektur für ein terminal, lipgloss macht das styling. für ein nur-lesendes log, das nie zurückblättert, kostet das framework etwa 120 zeilen und erspart mir handgeschriebene ansi-escapes und das parsen der terminalbreite.
* **`net` war schon in der schachtel.** der ehrliche grund: ich hatte nie einen listener geschrieben, und `net.Listen` + `Accept` ließen es wie vier zeilen aussehen. ich habe go nicht wegen der nebenläufigkeit gewählt. die nebenläufigkeit kam gratis mit, und ich musste sie mir nie verdienen.

## wie ich es strukturiert habe, und warum die struktur falsch ist

es gibt keinen router. es gibt drei goroutines, fest verdrahtet, gestartet aus `Init()` der 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)
}
```

das ist die routing-tabelle: drei tupel, in einer funktion, die nichts mit networking zu tun hat.

die schichtung ist invertiert, und ich benenne es lieber, als es still umzubauen. `internal/capture` ist das paket, das wirklich sockets öffnet, und weiß nicht, welche ports es bedient oder warum. `internal/ui` — das paket, dessen einzige aufgabe es ist, farbigen text in ein terminal zu zeichnen — besitzt die deployment-topologie. wenn ich telnet hinzufüge, ändere ich den zeichencode.

die richtige form ist eine config-struct, die in `main` gebaut wird, eine schleife über ein slice von services, und eine paketgrenze, die nichts von ports weiß. ich würde außerdem den service-namen zu einem typ machen statt zu einem nackten `string`, denn gerade ist `"SSH"` ein lookup-schlüssel in zwei mappen, die in zwei paketen leben, und nichts prüft, dass die beiden mappen übereinstimmen.

verbindungen erreichen den bildschirm über einen einzigen gepufferten channel der größe 32, erstellt in `main`:

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

die ui ruft nie das netzwerk. sie wartet auf dem channel, empfängt eine nachricht, hängt sie an, schaltet den wartemodus wieder ein. das ist dieselbe nachrichtenschleife wie das frontend dieser seite, aus demselben grund: **das ding, das zeichnet, darf nicht das ding sein, das blockiert.** die accept-schleife schreibt nur in einen channel und fasst die ansicht nie an.

## was mich die protokolle gelehrt haben, und wovon ich nichts erwartet habe

das, was ich vor dem schreiben im kopf falsch hatte: ich nahm an, ein honeypot müsse das protokoll sprechen. muss es nicht. drei services, drei völlig verschiedene regeln darüber, wer zuerst spricht:

| service | port | wer spricht zuerst | was wir senden | was wir erfassen |
| --- | --- | --- | --- | --- |
| SSH | 2222 | der server | `SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.6` | den versionsstring des clients |
| FTP | 2121 | der server | `220 (vsFTPd 3.0.5)` | `USER <irgendjemand>` |
| HTTP | 8080 | der client | gar nichts | `GET / HTTP/1.1`, oder schlimmer |

**ssh sendet zuerst seinen identifikationsstring.** es ist das seltene protokoll, in dem der server unaufgefordert spricht und der client mit seinem eigenen antwortet. die zeile, die wir zurücklesen, ist also keine zugangsberechtigung, sie ist ein fingerabdruck: `SSH-2.0-OpenSSH_9.6` sagt dir, der client ist ein echtes openssh, welche version, auf welcher distribution. das ist die gesamte nutzlast eines scanner-handshakes, und du bekommst sie aus einer einzigen zeile.

**die erste zeile von ftp ist der benutzername.** das `220` des servers ist die begrüßung, und das allererste, was ein client sagt, ist `USER root`, oder `USER admin`, oder `USER anonymous`. eine zeile tief im protokoll hast du die anmeldenamliste, die ein bot gerade durchgeht, bevor er irgendetwas geheimes gesendet hat.

**http ist das umgekehrte, und der leere string in der mappe ist kein bug.** ein http-server, der etwas schreibt, bevor er die anfrage gelesen hat, ist kaputt. die mappe hat `"HTTP": ""`, und der handler prüft mit `if b := _banners[service]; b != ""` — diese prüfung ist der gesamte grund, warum http sich benimmt, und es ist eine zeile, die zufällig für zwei protokolle tragend ist und für das dritte korrekt.

die allgemeine lektion: **der erste read eines honeypots ist das wertvollste byte, das er jemals sehen wird.** identifikation, nicht authentifizierung. du erfährst, wer da draußen ist, aus dem handshake, und musst den teil, in dem sie sich einloggen, nie implementieren.

## was es nicht tut

* **es sieht nie ein passwort.** kein schlüsselaustausch, keine `none`-auth-methode, kein `pam`, kein `/etc/shadow`. der socket wird begrüßt, eine zeile gelesen, `defer conn.Close()` feuert. ein echter `ssh`-client wartet die 8-sekunden-frist aus und gibt auf. das ist das richtige design, keine fehlende funktion: ein honeypot, das anmeldungen annimmt, ist eine haftung mit einem login-formular drauf.
* **es erfasst eine zeile, 512 bytes, in 8 sekunden.** `io.LimitReader(conn, 512)` und dann `ReadString('\n')`. ein client, der einen binären klumpen ohne zeilenumbruch sendet, bekommt, was `ReadString` zurückgibt, also müll. in ordnung, solange ich es weiß.
* **eine goroutine pro verbindung, unbegrenzt.** nichts deckelt die nebenläufigkeit. ein socket, der sich verbindet und nichts sendet, hält eine goroutine für die vollen acht sekunden. tausend davon sind tausend goroutines. auf einer persönlichen maschine verkraftbar, auf einem kleinen vps unverschämt.
* **nichts wird auf die platte geschrieben.** das log lebt in einem slice im ram, gekürzt auf `height - 10` zeilen, und das nur, wenn es gezeichnet wird. drück `q` und es ist weg. ein honeypot, das vergisst, ist ein bildschirmschoner mit netzwerkschnittstelle.
* **die ports lassen sich nicht ändern, ohne go-quellcode zu bearbeiten.** es gibt keine flags, keine umgebungsvariablen, keine konfigurationsdatei.

## die zwei bugs, die ich beheben würde, bevor ich ihm traue

**1. ein fehlgeschlagenes bind ist völlig stumm.** `VxStartListener` startet so:

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

der fehler wird weggeworfen. nicht gedruckt, nicht gezählt, nicht dargestellt. ist 2222 schon belegt, stirbt diese goroutine beim start und der ssh-zähler bleibt für immer bei null — neben einem lebenden ftp-zähler, in derselben kopfzeile, die exakt aussieht wie ein ruhiger tag.

das ist der schlimmste fehlermodus, den ein sicherheitswerkzeug haben kann: **er ist von erfolg nicht zu unterscheiden.** die behebung sind drei zeilen. den fehler über denselben channel schicken, ihn rot in der kopfzeile malen, mit exit-code ungleich null beenden.

**2. die readme lässt dich etwas tun, was der code nicht kann.** sie sagt:

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

„um standardports (22, 21, 80) ohne root zu binden". du wirst port 22 nicht binden. die ports sind ganzzahlige literale in `ui.Init()`. die capability wird einer binärdatei gegeben, die nie nach einem privilegierten port fragt. die behebung ist ein flag, keine capability:

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

die absicht in dem readme-abschnitt ist nicht falsch, der code ist nur nicht damit verbunden. und der grund steht in der historie: vier der fünf commits im repository heißen „Add files via upload". die dateien wurden anderswo geschrieben und eingefügt, und die readme kam mit ihnen und beschrieb eine version dieses programs, die es nicht gibt.

kleiner, aber echt: die accept-schleife macht `if err != nil { continue }`. wenn ein listener wirklich ausfällt, gibt `Accept` denselben fehler sofort und für immer zurück, und die goroutine dreht mit 100% cpu auf. es sollte `break` tun.

## was ich meinem vergangenen ich sagen würde

das ganze ding ist eine lektion in netzwerk-verkleidung: **du musst kein protokoll implementieren, um aus ihm zu lernen.** du sendest die richtige erste zeile, du liest die antwort, du legst auf. die gesamte intelligenz eines honeypots steckt in diesem ersten read, und alles danach — schlüsselaustausch, authentifizierung, eine falsche shell — sind kosten, haftung und code, den ich froh bin, nicht geschrieben zu haben.

und die zweite lektion ist die langweilige, die für jedes werkzeug gilt, das ich je ausgeliefert habe: **schluckst du einen fehler, hast du etwas ausgeliefert, das dich durch verschweigen belügt.** der bind-fehlschlag ist ein einzelnes `return`. es ist der gesamte abstand zwischen einem sicherheitswerkzeug und einem bildschirmschoner.