Am Abend des 22.10. fiel beim manuellen Prüfen eine Datei auf, die dort nicht hing. Bis dahin fiel nichts auf. Logs und Zeitstempel der Layer zeigten, dass der Angreifer seit mindestens dem 17.10. drin war, über Tage zurückkam und den Container als Sprungbrett für einen Krypto-Miner benutzte.

Das Setup ist ein verbreitetes Muster: Plex als Docker-Container mit network_mode: host, als root, im LXC srv-r2d2 auf dem Proxmox-Host host-yavin. Davor nginx für TLS auf 443 nach 127.0.0.1:32400. Dazu eine Portweiterleitung am Gateway des Hosters (gateway-obiwan): öffentliche IP:32400 über WireGuard in den LXC, mit Source-NAT. Plex sah jeden externen Client als 10.99.99.1.

Genau diese Adresse machte die Requests in den Augen von Plex zu internen Clients. allowedNetworks="10.0.0.0/8,172.16.0.0/12,192.168.0.0/16" deckt alle privaten Bereiche ab. Plex stufte die Verbindungen aus dem Internet als Allowed Network (WAN) ein und gab ihnen einen Gast-Token. Der Angreifer galt als lokaler Client, obwohl er aus dem Internet kam.

Die Angriffsfläche

Der Container lief mit plexinc/pms-docker:latest, als root (PLEX_UID=0), im Host-Netzwerk des LXC, beim Angriff in Version 1.43.0.10492. Laut NVD (National Vulnerability Database) sind Versionen vor 1.43.3.10861 von drei Schwachstellen betroffen:

  • Ein Plugin-Funktionsaufruf über eine manipulierte searchOne-URL.
  • Eine SSRF (Server-Side Request Forgery) über den Transcoder-Parameter path.
  • Ein beliebiges Schreiben über TranscoderH264Options, bei dem Plex eine .so aus dem Codecs-Verzeichnis lädt.

Die dritte Lücke zählt. Sie schreibt keine Konfiguration, sie legt eine Bibliothek ab, die Plex aus dem Codecs-Verzeichnis lädt. Aus einem HTTP-Request wird Codeausführung als root.

Die Zeitleiste

Alle Zeiten in UTC. Die Plex-Logs reichten nur bis zum 07.10. zurück; der Erstkontakt kann älter sein.

  • 17.10., 06:20: erster Request mit Exploit-Muster. Gast-Auth, ein neuer Player poc_…, searchOne und GetResultFromNetwork, Callback auf 127.0.0.1.<ip>.sslip.io.
  • 19.10., 12:31: erster erfolgreicher Schreibzugriff ins Codecs-Verzeichnis, PUT /:/prefs?TranscoderH264Options=…/Codecs/…/zz13068.so:qp=0 mit HTTP 200. Plex lud die Datei als Bibliothek.
  • 20.10., 10:05: erste veränderte Datei im Container. /usr/bin/pkill war durch ein Character-Device ersetzt.
  • 21.10., 12:13: /usr/bin/kworker erscheint, ein statisches ELF mit dem User-Agent Spider-UA. Dazu Autostart über /etc/init.d/kworker und rc2.d bis rc5.d/S99kworker, drei Sekunden nach einem erfolgreichen Prefs-PUT.
  • 21.10., 16:54: ein Burst, den wir zuerst für den Einstieg hielten. Es war eine spätere Sitzung derselben Quelle.
  • 21.10., 20:45: zweite Welle. systemhelper liegt an drei Pfaden, ist in Rust geschrieben, enthält xmrig und ist UPX-gepackt. In derselben Sekunde steht die erste ausgehende Verbindung zum Miner-Ziel in der Firewall.
  • 22.10., 03:59: /tmp/postgresql erscheint, UPX-gepackt, enthält den String SOCKS5, vermutlich ein Proxy. Zum Fundzeitpunkt lief der Prozess nur aus memfd, die Datei war gelöscht. Elternprozess war ein gefälschtes systemd, Speicherbedarf etwa 1,95 GB RSS.
  • 22.10., abends: Fund beim manuellen Prüfen, Beginn der Incident Response.

Der Einstieg: ein Hostname, der mit 127.0.0.1 beginnt

Der Callback im ersten Request ist der Teil, den man leicht überliest. Der Hostname beginnt mit 127.0.0.1, zeigt aber über sslip.io auf den Angreifer. Wer nur auf das Präfix schaut, hält den Empfänger für lokal. Wer auflöst, sieht eine fremde Adresse (85.11.x.x).

Danach ist der Weg geradlinig: Prefs-PUT, .so im Codecs-Verzeichnis, Laden durch Plex, Shell. /root/.bash_history zeigt keine getippte Sitzung, sondern eine Fern-Shell mit Markern: stty -echo schaltet das Echo ab, __P2S__ trennt die Blöcke:

id; hostname; uname -m
# Download eines Binarys vom Angreifer-Server nach /tmp/.s bzw. /tmp/systemd
# Start mit dem Argument plex2shell
# anschließend Löschen der jeweiligen zz*.so aus dem Codecs-Verzeichnis

Das Löschen der zz*.so am Sitzungsende ist Spurenbeseitigung: Der Zugang lief über das Binary weiter. Ein Blick auf das Codecs-Verzeichnis am Fundtag belegte den Einstieg deshalb nicht.

Vier Persistenzwege

systemhelper sicherte sich vierfach ab: ein Cron-Eintrag, der jede Minute läuft, ein Skript in cron.daily, ein init.d-Skript und vier systemd-Units mit Restart=always. Wer nur einen davon entfernt, verliert.

Dazu kam Werkzeugmanipulation: /usr/bin/curl und /usr/bin/pkill waren durch Character-Devices ersetzt, das echte curl lag unter /tmp/.pxb/curl. Aufräumskripte finden kein curl, pkill beendet nichts. Ergänzt um SELINUX=disabled und kernel.nmi_watchdog=0:

ls -l /usr/bin/curl /usr/bin/pkill
# c 1, 3 ... statt eines regulären Programms

Ein Response-Skript, das curl aufruft, scheitert still, pkill gegen den Miner tut nichts. Zur Reaktion gehört ein Blick auf die Binarys selbst.

Was die Reaktion rettete

Die Reihenfolge war wichtiger als einzelne Befehle:

  1. Snapshot des LXC.
  2. Implantat-Binary aus /proc/<pid>/exe sichern, dazu maps, status, offene Filedeskriptoren und Sockets, danach SHA256 bilden.
  3. Netz trennen, den Prozess als Beweis weiterlaufen lassen.
  4. docker rm -f, Image frisch ziehen. Danach lief Version 1.43.5.
  5. Erst dann das Netz wieder hochfahren.
  6. Port 32400 auf Loopback begrenzen.
  7. Test über den Proxy ohne Token.
  8. Forensik aus dem ZFS-Snapshot.

Schritt 2 vor Schritt 3: Das Binary lief nur im Speicher, die Datei war gelöscht. Ein Neustart hätte den Speicherbeleg vernichtet.

Die Firewall-Regel für 32400:

iptables -I INPUT -p tcp --dport 32400 ! -i lo -j DROP

Damit sie einen Neustart überlebt, als systemd-oneshot:

[Unit]
Description=Plex 32400 nur auf Loopback

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/bin/sh -c 'iptables -C INPUT -p tcp --dport 32400 ! -i lo -j DROP || iptables -I INPUT -p tcp --dport 32400 ! -i lo -j DROP'
ExecStop=/bin/sh -c 'iptables -D INPUT -p tcp --dport 32400 ! -i lo -j DROP'

[Install]
WantedBy=multi-user.target

Ohne iptables -C legt jeder Start eine weitere Kopie an.

Der Test lief über den Proxy ohne Anmeldung:

curl -sk -o /dev/null -w '%{http_code}\n' https://<proxy>/library/sections
curl -sk -o /dev/null -w '%{http_code}\n' https://<proxy>/identity

/library/sections liefert 401, /identity 200. Das ist der Unterschied zwischen „Port zu“ und „Dienst kaputt“.

Die Forensik kam aus dem ZFS-Snapshot unter <dataset>/.zfs/snapshot/<name>/, weil der Container-Layer nach docker rm weg war. Dort stand die Persistenz vollständig.

Die Regel, die sich nicht abschalten ließ

Die Portweiterleitung lag auf dem Provider-Gateway und ließ sich nicht einfach entfernen. OPNsense 26.7 erwartet <disabled>1</disabled>. Ein <disabled/> oder ein leerer Wert wird ignoriert. Die Regel bleibt aktiv, obwohl filter_configure() sauber durchläuft und die Oberfläche „deaktiviert“ anzeigt.

Erst disabled=1 plus ein Reload entfernte die rdr-Regeln:

# Wert prüfen: leer oder <disabled/> heißt NICHT deaktiviert
grep -n '<disabled' /conf/config.xml

configctl filter reload

pfctl -s nat | grep 32400

Zustände bleiben übrig. Ein pf-State altert weiter und sieht in Logs aus wie „noch aktiv“. Zustände zur Ziel-IP killen:

pfctl -k <ziel-ip>

Ob wirklich Ruhe ist, zeigt der Paketzähler. Zweimal messen, dazwischen warten. Unverändert heißt tot.

pfctl -vss | grep <ziel-ip>

Der Test, der von außen kommen muss

Ein Test über die eigene öffentliche IP ist wertlos. Hairpin-NAT beantwortet den Request lokal und meldet Erfolg, während die Weiterleitung von außen offen bleibt. Der Beweis kommt von einem echten Fremdhost ohne VPN und Split-DNS:

nmap -Pn -p 32400,443,993 <öffentliche-ip>

32400 muss zu sein, 443 und 993 bleiben offen. Dazu: keine IPv6-Regel mit demselben Loch, kein UPnP.

Backup ins Repo, aber mit Bedacht

Nach dem Vorfall liegen die Firewall-Konfigurationen versioniert im Git, nicht nur im Download-Ordner. Eigenes backups/ im jeweiligen Repo, Dateiname opnsense-config-<datum>-<anlass>.xml, dazu ein md5-Abgleich gegen die Quelle:

md5sum backups/opnsense-config-2026-10-22-plex-vorfall.xml
ssh root@gateway-obiwan 'md5sum /conf/backup/config-*.xml | tail -1'

Beide Summen müssen gleich sein.

Solche Dateien enthalten WireGuard-Keys, Zertifikate samt privaten Schlüsseln, Passwort-Hashes und API-Secrets. Das Repo bleibt privat, ein öffentliches Firewall-Backup wäre ein zweiter Vorfall.

Was ich falsch gemacht habe

1. Ich habe den lauten Ausschlag für den Anfang gehalten. Der Burst am 21.10. um 16:54 sah nach Erstkontakt aus, der erste Request kam am 17.10. um 06:20. Wer nur den lauten Ausschlag liest, datiert den Vorfall vier Tage zu spät. Logs gehören bis zum Anfang gelesen, die Abdeckung geprüft.

2. Ich habe im LXC-Root gesucht, nicht im Container. Per pct exec war nichts zu finden, die Persistenz lag im Container-Layer.

3. Der Storage-Treiber war overlayfs, nicht overlay2. Der Layer lag unter /var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/<n>/fs, die üblichen overlay2-Pfade führen ins Leere.

4. iptables im FORWARD greift bei einem gebrückten LXC nicht. Der Traffic lief weiter. Wirksam war:

ip link set dev veth-r2d2i0 down

5. Das Abschalten der Weiterleitung galt als erledigt, war es aber nicht. Leeres disabled sieht nach „aus“ aus, die Regel routete weiter. Ohne disabled=1, configctl filter reload und pfctl -s nat bleibt sie bestehen.

6. Der Test lief über die eigene öffentliche IP. Hairpin-NAT beantwortet ihn lokal. Erst der Test von einem Fremdhost zeigte, dass 32400 noch offen war.

7. Der LXC hat einen eigenen PID-Namespace. /proc/<pid> vom Host passte nicht auf den Prozess im Container.

8. Eine Verbindung zu einer Seedbox sah verdächtig aus, war aber legitimer Plex-Verkehr. Entscheidend ist nicht die Zieladresse, sondern die Zuordnung zum Prozess:

ss -tnp

9. „unhealthy“ kam vom Healthcheck, nicht vom Dienst. Er rief curl auf, und curl fehlte im Image. Der Container meldete dauerhaft krank, Plex lief. Der Fix prüft den Port ohne externes Werkzeug:

healthcheck:
  test: ["CMD-SHELL", "bash -c '</dev/tcp/localhost/32400'"]

Was das Logging danach zeigte

Gesammelt wird zentral über Grafana Alloy. Jeder Host schickt seine Logs, Docker-Logs eingeschlossen, an einen Loki. Firewalls und Switches liefern per rsyslog an einen eigenen Empfänger. Loki hält 14 Tage heiß, ein Archiv deckt 90 Tage ab. Der Alertmanager geht per n8n-Webhook raus, kritische Alarme zusätzlich per Telegram. Auf dem WAN des Provider-Gateways läuft Suricata im Alert-Modus, ein kleiner Watchdog meldet Ausfälle per Telegram. Zabbix bleibt für das Metrik-Monitoring zuständig.

Ehrlich gesagt hat das SIEM (Security Information and Event Management) diesen Einbruch nicht entdeckt. Der befallene Container war zu dem Zeitpunkt an keine Logquelle angebunden. Er kam am Abend der Entdeckung dazu. Gefunden wurde der Befall nebenbei: Ein KI-Agent sollte den Container ins Zabbix-Monitoring aufnehmen und prüfte vor dem Anlegen der RAM-Trigger die Speicherlast mit ps. Dort stand ein Prozess /tmp/postgresql mit zwei GB Arbeitsspeicher, dazu hunderte OOM-Kills (Out of Memory). Plex nutzt kein Postgres.

Dazu kam eine zweite Lücke. Die Syslog-Lieferung der Heim-Firewall war nach einer Umstellung zwei Tage still, ohne dass es auffiel. Ein fehlender Strom sieht genauso aus wie ein ruhiger Tag.

Danach hat das SIEM seinen Wert gezeigt. Ein IOC-Sweep (Indicator of Compromise) lief über alle Hosts in einem Rutsch. Die Abfragen gingen je Tagesfenster, weil Loki Abfragen auf einen Tag begrenzt. Die Firewall-Logs zeigten die Verbindung des Containers zur Angreifer-Infrastruktur (85.11.x.x) und geblockte Zugriffe am Provider-Gateway. Die Log-Lücke der Heim-Firewall wurde exakt sichtbar, mit Anfang und Ende.

Die Konsequenz passt auf jeden Aufbau: Abdeckung vor Features. Jede Quelle wird angebunden, bevor sie live geht. Pro Host gibt es einen Stille-Alarm, der anschlägt, wenn in X Minuten keine Logzeile kommt. Das Fenster richtet sich nach der gemessenen Log-Rate: laute Hosts 10 Minuten, leise Container bis 12 Stunden.

absent_over_time({host="srv-r2d2"}[12h])

Dazu Alarmregeln auf Miner-typische Muster und ungewöhnliche ausgehende Verbindungen. Wie der Aufbau im Detail aussieht, steht im Leitfaden für zentrales Logging.

Was du heute prüfen solltest

  • allowedNetworks gegen die echte Topologie prüfen. Alle privaten Bereiche freizugeben, macht aus jedem Client hinter Source-NAT einen internen Client.
  • Jede Portweiterleitung suchen, die mit SNAT in ein privates Netz zeigt. Source-NAT versteckt den Ursprung vor der Anwendung.
  • Plex-Version prüfen: mindestens 1.43.3.10861.
  • Plex-Logs nach Allowed Network (WAN) und TranscoderH264Options durchsuchen, inklusive rotierter Dateien:
grep -nE 'Allowed Network \(WAN\)|TranscoderH264Options' \
  "/pfad/zu/Plex Media Server/Logs/Plex Media Server"*.log
  • Codecs-Verzeichnis nach zz*.so prüfen:
find "/pfad/zu/Plex Media Server/Codecs" -type f -name 'zz*.so'
  • curl und pkill gegen die Paketversion prüfen. Character-Device statt Binary ist ein Warnsignal.
  • Nach dem Abschalten einer Weiterleitung von einem Fremdhost testen, nicht über die eigene öffentliche IP.
  • Nach einem Einbruch alles rotieren, was die Anwendung kannte: Token, Kontopasswort, eingebundene Zugangsdaten (z. B. für Netzlaufwerke). Danach alle Geräte abmelden. Ein neu gebauter Container widerruft keine gestohlenen Zugangsdaten.
  • Bei jeder Firewall-Regel im Umfeld des Vorfalls prüfen, wer sie wann angelegt hat. OPNsense schreibt das in created/updated der Regel.
  • Ändert sich der SSH-Hostkey eines Gateways, nicht einfach übernehmen, sondern über einen zweiten Weg klären, warum.
  • Container nicht als root betreiben. Root mit Host-Netzwerk macht aus einer Bibliothekslücke eine root-Shell mit direktem Netzwerkzugriff.

Grundlagen zum Betrieb ohne root stehen im Artikel über Docker mit eigenem CA in Non-Root-Containern. Wie man Logs zentral sammelt und Abdeckungslücken vermeidet, steht im Leitfaden für zentrales Logging.