Ein Cryptominer laeuft mit voller Last auf einem Server im Heimnetz, dunkler Serverraum, gluehende Prozessanzeige, dokumentarisch
Homelab 6 Min Read

398 Prozent CPU, und niemand hat es gemerkt

Angefangen hat es mit einem Satz, den man an einem Freitagabend nicht ernst nimmt: Mealie lädt nicht. Kein Drama. Ein Rezeptverwalter, einer von vierundfünfzig Containern, den außer mir niemand benutzt.…

Audio-Guide KI-generierter Beitrag zum Thema

Angefangen hat es mit einem Satz, den man an einem Freitagabend nicht ernst nimmt: Mealie lädt nicht.

Kein Drama. Ein Rezeptverwalter, einer von vierundfünfzig Containern, den außer mir niemand benutzt. Ich habe mich auf den Docker-Host verbunden, um kurz nachzusehen, was los ist, und dabei aus Gewohnheit die Prozessliste nach Last sortiert.

Ganz oben stand das hier:

3268790  999  398 %CPU  2-10:21  ./linuxsys

398 Prozent CPU bei acht Kernen. Seit zwei Tagen und zehn Stunden. Auf einer Maschine, die ich für mein eigenes Wohnzimmer-Rechenzentrum halte und von der ich dachte, dass ich sie kenne.

Der Name ist die erste Lüge

linuxsys klingt wie etwas, das dazugehört. Genau dafür ist der Name gewählt. Niemand nennt seinen Schädling miner.bin, und wer in einer Prozessliste nach Auffälligkeiten sucht, überliest sowas beim ersten Durchgang.

Was er nicht verstecken kann, ist alles andere. Fünf Dinge standen da, und jedes einzelne hätte mir gereicht:

Befund Was er bedeutet
/proc/PID/exe -> /var/tmp/linuxsys (deleted) Die Datei hat sich nach dem Start selbst gelöscht
Arbeitsverzeichnis /var/tmp Kein legitimer Dienst arbeitet von dort
UID 999, Elternprozess beam.smp Direktes Kind von Plausible, meiner Besucherstatistik
Laufzeit 2 Tage 10 Stunden Start am 17. September, 06:19 Uhr
Verbindung nach 103.189.234.47:80 Mining-Pool bei Cloud Host Pte Ltd, AS138608 (SG/ID), getarnt über Port 80

Der erste Punkt ist der eindeutigste. Ein Programm, das sich unmittelbar nach dem Start selbst von der Platte löscht und nur noch im Arbeitsspeicher weiterläuft, tut das aus genau einem Grund: Es soll nichts zum Anfassen übrig bleiben. Legitime Software macht das nie.

Der letzte ist der frechste. Port 80 ist der Port, auf dem Webverkehr läuft. Wer eine Firewall-Regel schreibt, die ausgehend „nur Web” erlaubt, hat diesem Ding die Tür aufgehalten.

Der Trick, den man nur einmal falsch macht

Der erste Reflex ist, das Ding zu killen. Wäre falsch gewesen.

Die Binary war gelöscht, sie existierte nur noch als laufender Prozess. Aber solange der Prozess lebt, hält der Kernel den Dateiinhalt fest, und man kommt über einen Umweg noch dran:

cp /proc/3268790/exe /root/forensik/linuxsys.bin

Damit lag der Schädling wieder als Datei vor:

SHA256: 4d17d32ed6efe92ea61d16602f9c6cd5261cdd3820adec427ee68d4088b6ab5b
Typ:    ELF 64-bit, static-pie, stripped, 10 MB, 0 lesbare Strings

Statisch gelinkt, gestrippt, keine einzige lesbare Zeichenkette — also jemand, der weiß, was er tut. Den Hash schreibe ich hier hin, falls ihn jemand sucht, weil ihm dasselbe Ding auf seiner Maschine begegnet ist.

Merken: Erst sichern, dann killen. Der Kill vernichtet das Beweismittel. Das ist der Unterschied zwischen „da war was” und „ich weiß, was da war”.

Der Weg hinein war die Vordertür

Die interessantere Frage ist immer, wie er reingekommen ist. Die Antwort war unbequem.

Der Elternprozess war beam.smp, der Laufzeitprozess von Elixir. Damit war klar, wer ihn gestartet hat: Plausible, meine selbstgehostete Besucherstatistik. Ein Werkzeug, das ich eingerichtet habe, weil ich keine Google-Analytics-Skripte auf meinen Seiten wollte. Datenschutzfreundlich, schlank, unauffällig.

Und seit dem 7. Juni unverändert auf Version 3.0.1. Über drei Monate ungepatcht.

Wichtig ist, wie er nicht reingekommen ist: nicht über einen offenen Port. Port 8087 reicht meine OPNsense gar nicht nach außen durch. Der Weg war analytics.anime-radar.de über HTTPS, durch den Nginx Proxy Manager, weiter an Plausible — also exakt der Pfad eines ganz normalen Besuchers. Die Anwendung selbst hat den Code ausgeführt, das war eine Remote Code Execution über die eigene Oberfläche.

Das ist der Teil, den ich mir merken musste. Ich hatte die ganze Zeit über Ports nachgedacht. Angegriffen wurde aber die Software, die ich selbst veröffentlicht habe — über den einen Weg, der offen sein muss, damit der Dienst überhaupt seinen Zweck erfüllt.

Wie weit ist er gekommen

Das war die Nacht-halb-durch-Frage. Kurzfassung: nicht weit.

Der Miner lief als unprivilegierter Nutzer im Container. Geprüft habe ich trotzdem alles, was mir eingefallen ist — und zwar in der Reihenfolge, in der es wehgetan hätte:

  • Laufen fremde Prozesse als Root auf dem Host? Nein.
  • Liegt etwas in den temporären Verzeichnissen des Hosts? Nein.
  • Sind meine hinterlegten SSH-Schlüssel unverändert? Ja, die zwei bekannten, sonst nichts.
  • Gibt es neue Cron-Einträge, die ihn nach einem Neustart wiederbringen? Nein.
  • Fremde Anmeldungen? Keine.

Der letzte Punkt war der, der zählt. Auf dieser Maschine liegen die Schlüssel zu zwei Webservern, auf denen nicht nur meine eigenen Seiten liegen. Wären die abgeflossen, wäre aus dem Abend ein ganz anderer geworden. Waren sie nicht. Die Container-Grenze hat gehalten, wofür sie da ist.

Danach ging das Aufräumen schnell: Forensik sichern, dann docker stop plausible — der Miner war damit sofort weg, er existierte ja nur noch im Arbeitsspeicher. Plausible auf 3.2.1 gehoben, vorher ein Postgres-Dump. Das Port-Binding von 0.0.0.0:8087 auf 10.100.0.3:8087 verengt, damit der Dienst gar nicht erst auf allen Adressen lauscht. Load von 7 bis 8 zurück auf 2,2.

Die Lücke war nicht das Problem

Man kann sich jetzt über eine Schwachstelle in einer Analytics-Software aufregen. Bringt nur nichts, die gibt es in jeder Software.

Das eigentliche Versäumnis war ein anderes, und es ist mir erst bei der Aufarbeitung klar geworden: Ich hatte überhaupt keine automatischen Container-Updates. Nicht schlecht konfigurierte. Keine.

Mein Debian aktualisiert sich seit Jahren brav von selbst, dafür gibt es unattended-upgrades, und weil das zuverlässig läuft, hatte ich das Thema Updates innerlich abgehakt. Nur deckt das ausschließlich Debian-Pakete ab. Von den vierundfünfzig Containern, die den eigentlichen Betrieb ausmachen, weiß es nichts. Die aktualisiert man selbst oder gar nicht.

Es war gar nicht.

Seitdem läuft nachts um Viertel nach vier ein Skript, das sechzehn Container aktualisiert. Bewusst eine Positivliste statt „alles”: Mailserver, Datenbanken und die Supabase-Instanz bleiben draußen, weil ich bei denen nicht morgens von einem stillschweigend durchgeführten Hauptversionssprung überrascht werden will. Erster Lauf: zehn Aktualisierungen, null Fehler.

Kurios am Rande: Watchtower, das Standardwerkzeug dafür, wird seit 2023 nicht mehr gepflegt und stolperte über meinen zu neuen Docker-Daemon. Es läuft erst, wenn man ihm per DOCKER_API_VERSION=1.44 eine ältere API-Version vorgibt. Ein Update-Werkzeug, das selbst ein Update bräuchte.

Was noch offen ist

Eine ehrliche Lücke bleibt, und sie sitzt ausgerechnet dort, wo der Vorfall entstanden ist.

Plausible ist auf eine feste Version festgenagelt. Das ist bei Software mit Datenbank-Migrationen auch richtig so — man will nicht, dass sich das Schema nachts von selbst ändert. Nur erkennt ein Update-Werkzeug bei einer festgenagelten Version prinzipbedingt nie, dass es draußen eine neuere gibt. Es prüft, ob sich hinter der angegebenen Version etwas geändert hat. Hat es nicht. Alles grün.

Genau dieser blinde Fleck hat drei Monate lang eine verwundbare Version am Leben gehalten, während daneben die Automatik ihre Arbeit tat.

Die Lösung wird kein weiteres Update-Werkzeug sein, sondern ein Abgleich gegen die Release-Listen der Projekte: Welche Version läuft bei mir, welche ist aktuell, und wie alt ist der Unterschied. Kein Automatismus, der installiert — einer, der fragt.

Was ich mitnehme

Der Miner hat mich nichts gekostet außer Strom und einem Abend. Der Befund dahinter ist mehr wert als der Schaden.

Ich habe monatelang eine Infrastruktur betrieben, die von außen tadellos aussah: gültige Zertifikate, laufende Dienste, keine Alarme. Und in genau dieser Stille lief etwas mit voller Last, das da nicht hingehörte. Es hat sich nicht versteckt, es war nur niemand da, der hinsieht.

Die Lehre ist nicht „patcht eure Software”. Das weiß jeder. Die Lehre ist, dass Stille kein Betriebszustand ist. Ein System, das nie etwas meldet, ist entweder gesund oder taub, und von außen sehen beide gleich aus.

Mealie übrigens war ein ganz anderes Problem. Aber das ist eine eigene Geschichte.