Zurück zum Blog

Drei Tage, zehn Updates: Was PHP2Shell und eine BSI-Mail über meinen Betrieb verraten haben

Drei Tage, zehn Updates

Was eine PHP-Lücke und eine BSI-Mail über meinen Betrieb verraten haben

Datum 23. Oktober 2026
Kategorie Security & Betrieb
Lesezeit 30 Minuten

Am 21. Oktober wurde eine kritische Lücke in PHP öffentlich, zusammen mit einem fertigen Angriffswerkzeug. Einen Tag später kam eine Mail vom CERT-Bund des BSI: Unser Gitea sei für eine Code-Ausführungslücke verwundbar, die seit Ende Juli bekannt war. Zwischen diesen beiden Meldungen und dem 23. Oktober lagen zehn Updates von Kerndiensten, ein neuer Wächter für Sicherheitsmeldungen und eine Reihe von Funden, nach denen ich gar nicht gesucht hatte.

Dieser Beitrag beschreibt alles der Reihe nach, mit den Entscheidungen, den Messwerten und den Fehlern, die mir selbst unterlaufen sind. Er ist lang, weil die Kette lang war: Jede Behebung hat den nächsten Fund sichtbar gemacht.

Die Kurzfassung:
  • Das eigentliche Problem war, dass niemand wusste, welche Lücken offen sind. Patches gab es. Gitea lief drei Monate mit einer bekannten Lücke, ohne dass es auffiel.
  • Gegen PHP2Shell haben wir die beiden betroffenen Dienste nicht abgeschaltet, sondern eingedämmt: Nextcloud gestoppt, die mailcow-Oberfläche nur noch über das VPN erreichbar, der Mailverkehr lief weiter.
  • Ein neuer Wächter vergleicht jetzt täglich die laufenden Versionen von 22 Komponenten mit den Sicherheitsmeldungen der Hersteller. Sein erster Lauf fand 80 offene Lücken.
  • Beim Aufräumen kamen Dinge hoch, die schwerer wogen als manches Update: Passwörter, die über Healthchecks im SIEM landeten, eine SQL-Injection im Chatbot, Schutzschalter in n8n, die durch einen leeren Wert ausgeschaltet waren, und ein Rate-Limit, das für die ganze Website zusammen galt.
  • Mehrere Tests, die ich unterwegs gebaut habe, wären still durchgelaufen, obwohl etwas kaputt war. Diese Fälle sind die wichtigste Lehre.

Auslöser 1: PHP2Shell

PHP2Shell sind zwei Lücken im PHP-Kern, CVE-2026-109618 und CVE-2026-109619, beide mit CVSS 9,2 bewertet. Sie erlauben Code-Ausführung aus der Ferne ohne Anmeldung, betroffen ist jede PHP-Version ab 5.4, die Web-Anfragen beantwortet. Die Details wurden vor dem geplanten Patch geleakt, ein Angriffswerkzeug lag öffentlich vor. Behoben ist das ab PHP 8.5.12, 8.4.27, 8.3.36 und 8.2.35. Bevor es um den Patch ging, musste deshalb eine andere Frage geklärt sein: Wo läuft bei uns überhaupt PHP?

Erst die Inventur

Aus dem Kopf hätte ich gesagt: Nextcloud und mailcow. Das stimmte, aber eine erste schnelle Prüfung, die in jedem laufenden Container nach php -v fragte, hatte zwei Lücken. Sie erfasste keine gestoppten Container, und in Container ohne Shell (sogenannte Distroless-Images) konnte sie nicht hineinsehen. Die zweite Runde durchsuchte deshalb direkt das Dateisystem aller 72 Container nach PHP-Binärdateien, PHP-FPM und libphp, für die übrigen per docker export, ohne sie zu starten. Dazu kam der Host selbst.

Das Ergebnis bestätigte die Vermutung, jetzt aber belegt: PHP steckte nur in Nextcloud (PHP 8.5.11 als Apache-Modul, genau die Betriebsart, gegen die die Lücke offiziell nachgewiesen war) und im PHP-FPM von mailcow (8.2.29). Gitea, Directus, Umami, n8n, Authentik und alle anderen laufen auf Go, Node.js, Python oder Java. In beiden PHP-Diensten war session.upload_progress eingeschaltet, über diese Funktion lief der Angriff.

Eindämmen, wo Abschalten zu teuer ist

Abschalten wäre die sichere, aber teure Antwort gewesen, vor allem bei mailcow: Ohne PHP-FPM fallen dort Weboberfläche, Webmail-Login und Autodiscover aus. Der Mailverkehr selbst hängt aber nicht an PHP. Wir haben deshalb unterschiedlich reagiert.

  • Nextcloud wurde gestoppt. Datenbank und Cache liefen weiter, es ging nichts verloren.
  • Die Weboberfläche von mailcow bekam am Reverse Proxy (Traefik) eine IP-Allowlist, die nur noch Adressen aus dem VPN durchlässt. Von außen kam ein 403, SMTP und IMAP liefen unverändert.
  • Bevor ich mich auf die Sperre verlassen habe, habe ich geprüft, ob mailcow seine Weboberfläche nicht noch auf einem zweiten Weg direkt ins Internet veröffentlicht. Die Ports waren zwar an alle Schnittstellen gebunden, von außen aber nicht erreichbar.
  • Der wöchentliche Update-Lauf bekam eine Ausnahme für Nextcloud. Sonst hätte er den gestoppten Dienst am Sonntag wieder hochgefahren, eventuell noch mit dem alten PHP.

Ein kleiner Wächter für den Patch

Statt jeden Morgen von Hand nachzusehen, prüfte ab dann ein kleines Skript zweimal täglich, ob es gepatchte Images gibt. Es lädt dafür nichts herunter: Über die Registry-API liest es die Image-Konfiguration und darin die Umgebungsvariable PHP_VERSION, für Nextcloud direkt am Tag latest, für mailcow über das Image, auf das der aktuelle Stand im mailcow-Repository verweist. Zusätzlich fragt es die laufende Version ab. Gemeldet wird nur ein Zustandswechsel, also „Patch verfügbar“ und später „läuft gepatcht“, und bei einem Fehler der Prüfung jedes Mal.

Am nächsten Morgen kam die Mail: Beide Patches waren verfügbar. Nextcloud ging mit PHP 8.5.12 wieder online (die Nextcloud-Version selbst blieb gleich, eine Datenbank-Migration war nicht nötig). Für mailcow bedeutete das ein Update vom Mai- auf das Oktober-Release, also über fünf Releases hinweg, mit Postfix 3.7 auf 3.10 und Rspamd 3.14 auf 4.2. Dazu unten mehr. Insgesamt war Nextcloud rund 27 Stunden nicht erreichbar, die mailcow-Oberfläche ebenso lange nur über das VPN.

Lehre: Die Inventur war die wertvollste Viertelstunde. Ohne sie hätten wir entweder zu viel abgeschaltet oder einen Dienst übersehen. Und die erste, schnelle Variante hätte ausgerechnet die Container ausgelassen, die am wenigsten auffallen.

Auslöser 2: Die Mail vom BSI

Am zweiten Tag kam eine automatisch erzeugte, signierte Mail vom CERT-Bund. Das BSI scannt regelmäßig nach verwundbaren Diensten und informiert die Betreiber der betroffenen Netzbereiche. Gemeldet wurde Gitea mit drei Lücken:

CVEArtBehoben inBei uns
CVE-2026-60004Code-Ausführung über den diffpatch-Endpunkt1.27.1verwundbar (1.26.3)
CVE-2026-20896Identität übernehmen per Header, wenn allen Proxys vertraut wird1.26.3gepatcht, Konfiguration aber im verwundbaren Muster
CVE-2026-27771Zugriff auf private Container1.26.2gepatcht

Die gefährliche Lücke war seit dem 28. Juli öffentlich. Wir liefen also fast drei Monate damit, ohne es zu merken. Bevor ich etwas änderte, habe ich zwei Dinge geklärt: Wer kann die Lücke ausnutzen, und gibt es Spuren?

Bewerten, bevor man handelt

Für die Code-Ausführung braucht ein Angreifer Schreibrecht auf ein Repository. Er legt per Patch zweimal dieselbe Datei hooks/post-index-change an, Git macht daraus beim Zusammenführen einen echten Hook, und der läuft dann als Gitea-Benutzer. Mit offener Registrierung kann sich jeder dieses Schreibrecht selbst verschaffen. Bei uns ist die Registrierung abgeschaltet, und es gibt nur ein Benutzerkonto. Ein Angreifer hätte also Passwort oder Token gebraucht. Das senkt das Risiko deutlich, beseitigt es aber nicht.

Auffällig war die zweite Zeile der Tabelle. Unsere Konfiguration vertraute weitergeleiteten Headern von allen Absendern (REVERSE_PROXY_TRUSTED_PROXIES = *). Genau dieses Muster nutzt CVE-2026-20896, um sich per Header als beliebiger Benutzer auszugeben. Der Patch in 1.26.3 ändert nur den Standardwert. Eine ausdrücklich gesetzte Einstellung bleibt, wie sie ist. Ausnutzbar war es bei uns nur deshalb nicht, weil die Anmeldung über den Reverse Proxy ausgeschaltet war. Ein Test mit gefälschtem Header lieferte 403. Die Einstellung zeigt jetzt nur noch auf das Netz des Reverse Proxys.

Die Spurensuche ergab keine Aufrufe des diffpatch-Endpunkts und keine eingeschleusten Hooks. Neue Branches seit Ende Juli gab es nur drei, alle vom einzigen Konto, und der Besitzer konnte sie bestätigen. Die Einschränkung gehört dazu: Die Logs reichten nur bis zum 22. September zurück. Für die Wochen davor kann ich nichts sagen.

Update auf 1.27.3, und warum das nicht reichte

Der Hinweis des BSI nannte 1.27.1. Ich habe auf 1.27.3 aktualisiert, die neueste Version der 1.27-Reihe, mit Datenbank-Dump, Volume-Backup und einem Rollback-Tag auf dem alten Image. Am selben Abend zeigte der neue Wächter (nächster Abschnitt), dass 1.27.3 noch 19 weitere Lücken mit hoher oder kritischer Einstufung hat, die erst in Version 28 behoben sind. Gitea hat zum Versionsschema 28.x gewechselt, die 1.27-Reihe bekommt keine Rückportierungen.

Darunter war eine Übernahme von Konten über OpenID. OpenID-Login war bei uns eingeschaltet, aber ungenutzt. Ich habe es sofort abgeschaltet, ebenso den eingebauten SSH-Server. Der funktionierte ohnehin nicht: Die Port-Weiterleitung zeigte seit der Installation auf den falschen Port im Container, und in 30 Tagen gab es keine einzige SSH-Verbindung. Alle Zugriffe laufen über HTTPS.

Am nächsten Tag folgte 28.2.0. Die Release-Notes nennen zwei Breaking Changes: Ausgehende Git-Verbindungen laufen über einen internen Proxy mit neuen Egress-Regeln, und alte Actions-Läufe werden automatisch gelöscht. Beides betraf uns nicht, wir haben keine Mirrors, keine Webhooks und keinen Actions-Runner. Zwei andere Dinge haben wir dafür erst beim Prüfen gefunden.

Fallen beim Hauptversionssprung:
  • Die Git-Hooks aller Repositories waren nach dem Update veraltet, Gitea 28 erzeugt sie anders. Veraltete Hooks können Pushes scheitern lassen. gitea admin regenerate hooks behebt das, ein echter Push hat es bestätigt.
  • gitea doctor check ohne --run prüft in Version 28 gar nichts mehr und meldet „All done (checks: 0)“. Das sieht nach Erfolg aus.
  • Schon seit dem 1.27-Update lief die Oberfläche mit einem Ersatz-Design: Das Theme auto heißt jetzt gitea-auto. Aufgefallen ist das erst an einer Fehlerzeile im Log.

Ein Wächter für Sicherheitsmeldungen

Beide Auslöser erreichten uns von außen, einmal über die Nachrichten, einmal über das BSI. Unsere eigenen Werkzeuge hatten geschwiegen, und es lohnt sich, genau hinzusehen, warum.

Diun meldet neue Image-Versionen, aber nicht, ob eine davon eine kritische Lücke schließt. Ein Gitea-Release mit RCE-Fix sieht dort aus wie jedes andere. Der wöchentliche Trivy-Bericht findet bekannte Lücken in den Paketen eines Images, aber nicht die Sicherheitsmeldungen der Anwendungen selbst. Und Dienste, die bewusst nicht automatisch aktualisiert werden, wurden in der Praxis gar nicht aktualisiert: mailcow stand seit Juni still, Gitea seit Monaten auf derselben Version.

Wie der Wächter arbeitet

Der neue Wächter läuft jeden Morgen. Für jede Komponente ermittelt er zuerst die laufende Version, je nach Dienst aus dem Image-Label, einer Umgebungsvariable, einem Befehl im Container oder auf dem Host. Dann holt er die veröffentlichten Sicherheitsmeldungen des Projekts über die GitHub-API (Security Advisories) und prüft, ob die laufende Version im betroffenen Bereich liegt. Gemeldet werden nur die Stufen „high“ und „critical“. Als zweite Quelle gleicht er den KEV-Katalog der US-Behörde CISA ab, die Liste aktiv ausgenutzter Lücken, über Stichworte zu unseren Produkten.

Eine Mail kommt nur bei neuen Funden, montags zusätzlich eine Liste aller noch offenen. Scheitert die Prüfung selbst, kommt jedes Mal eine Mail. Bekannte Fehlalarme kommen auf eine Ignore-Liste, aber nur mit Begründung und nur für die einzelne Meldung, nie für ein ganzes Projekt.

Versionsbereiche sind kein Standard

Der schwierigste Teil war der Vergleich. Jedes Projekt schreibt seine Versionsbereiche anders:

ProjektAngabe im AdvisoryWas gemeint ist
Gitea>=1.17, <1.27.1eindeutig
Nextcloud>= 32.0.0, >= 33.0.0, >= 34.0.0, Fix „32.0.12, 33.0.6, 34.0.1“je Hauptversion ein Bereich, die Obergrenze steht nur unter „patched“
PHPein Eintrag pro unterstütztem Zweigfehlt ein Zweig, ist er meist nicht betroffen
mailcow< 2026-03bDatums-Releases
MinIORELEASE.2024-…T…Z, teils nur „all“Zeitstempel statt Versionsnummern
Grafana>=9.2.0 <9.2.10 >=9.3.0 <9.3.4mehrere Intervalle in einer Zeile

Der Vergleich arbeitet deshalb zweiggenau: Nennt ein Advisory eine Fix-Version im eigenen Zweig (etwa 2.11.60 für Traefik 2.11), gilt die. Sonst gelten die Bereichsangaben, und eine Untergrenze ohne Obergrenze bekommt die nächste Fix-Version als Grenze. 38 Testfälle aus echten Advisories sichern das ab. Zwei Regeln kamen erst nach dem ersten Lauf dazu, weil sie Fehlalarme verhinderten: Listet ein Advisory Fixes für mehrere Zweige auf und unser älterer Zweig fehlt, gilt er als nicht betroffen. Sonst hätte mailcow nach dem PHP-Update weiter Lücken gemeldet, die erst mit PHP 8.4 eingeführt wurden. Und ist ein Bereich unlesbar, unsere Version aber neuer als jede Fix-Version, ist sie ebenfalls nicht betroffen.

Zwei Stolpersteine im Betrieb:
  • Die Website der CISA sperrt Rechenzentrums-Adressen, unabhängig vom User-Agent (403). Die CISA spiegelt den Katalog aber offiziell auf GitHub, und dieser Download zählt nicht gegen das API-Limit.
  • Ohne Token erlaubt die GitHub-API 60 Anfragen pro Stunde und IP. Auf unserem Server war das Kontingent schon von einem anderen Prozess aufgebraucht, bevor der Wächter überhaupt lief. Ein Lauf braucht etwa 30 Anfragen. Abhilfe ist ein feingranularer Token, der nur öffentliche Repositories lesen darf und sonst keine Rechte hat. Er hebt das Limit auf 5000 pro Stunde.

Der erste Lauf: 80 Funde

Der erste vollständige Lauf meldete 80 offene Lücken in laufenden Diensten, darunter 19 in Gitea 1.27.3, 26 in Wazuh, 12 in Directus und 8 in Authentik. Daraus wurde die Update-Liste der folgenden Tage. Nach den Updates blieben drei Meldungen, die bei uns nicht zutreffen. Sie stehen jetzt mit Begründung auf der Ignore-Liste: eine PHP-Lücke, die erst mit PHP 8.3 entstand, der Befehls-Node von n8n, der bei uns gesperrt ist, und eine Qdrant-Lücke, die nur im Cluster-Betrieb greift. Die letzte ist nach dem Update auf Qdrant 1.20 wieder von der Liste verschwunden.

Einen blinden Fleck hatte der Wächter trotzdem, und der war ernst. Die Next.js-Version unserer Website stand nicht in seiner Komponentenliste. Die drei kritischen Lücken dort hat Trivy gefunden, mehr dazu im nächsten Abschnitt. Next.js ist inzwischen ergänzt, und ich habe die Ergänzung an den echten Advisories geprüft. Für die alte Version hätte der Wächter alle drei kritischen Lücken gemeldet, für die neue meldet er keine.

Lehre: Ein Wächter ist nur so gut wie seine Komponentenliste. Jeder neue Dienst braucht einen Eintrag, sonst prüft ihn niemand, und das sieht man dem Wächter nicht an: Er meldet einfach nichts.

Die Update-Welle im Überblick

In drei Tagen kamen zehn Kerndienste auf neue Versionen. Jedes Update lief nach demselben Schema, das am Ende des Beitrags beschrieben ist: Baseline messen, sichern, Rollback-Punkt setzen, Breaking Changes lesen, eng umstellen, mit der Baseline vergleichen.

DienstVon → nachAnlassBesonderheit
Nextcloud (PHP)PHP 8.5.11 → 8.5.12PHP2Shell27 h gestoppt, kein DB-Upgrade nötig
mailcowRelease Mai → OktoberPHP2Shellfünf Releases, Postfix 3.10, Rspamd 4.2
Gitea1.26.3 → 1.27.3 → 28.2.0BSI-Meldung, dann 19 AdvisoriesHooks neu erzeugen, OpenID und SSH aus
Directus12.0.2 → 12.5.012 Advisories, eine kritischdrei Breaking Changes geprüft
Authentik2026.5.3 → 2026.5.88 Advisories, u. a. MFA-UmgehungPatch im selben Zweig
Wazuh4.10.4 → 4.14.926 Advisories, 8 kritischKonfigurationspfade verschoben
Traefik2.11.59 → 2.11.601 AdvisoryPatch
Docker Engine29.8.1 → 29.8.21 Advisorystartet alle Container neu, Reboot-Test
Qdrant1.18.2 → 1.19.2 → 1.20.03 Advisoriesnur Minor für Minor
Next.js (Website)16.3.3 → 16.4.13 kritische RCEs ohne AnmeldungDOMPurify fand nur npm audit

mailcow: fünf Releases auf einmal

Vor dem Update lag ein Vollbackup mit dem mitgelieferten Werkzeug von mailcow, dazu der Git-Stand und die Liste der laufenden Images. Die neuen Images habe ich mit --prefetch vorab geladen, während mailcow normal weiterlief. Das verkürzt den Ausfall und zeigt vorher, ob alle Images beziehbar sind. Das eigentliche Update dauerte dann rund zweieinhalb Minuten.

Bei mailcow prüfe ich nach jedem Update dieselben Punkte, weil jeder davon schon einmal still ausgefallen ist: Die Firewall-Regel von mailcow muss in IPv4 und IPv6 genau einmal an erster Stelle stehen, die Einstellungen und die Allowlist des Brute-Force-Schutzes müssen erhalten sein, und DKIM muss signieren. Dazu kam ein echter Mailtest. Die Testmail lag im Posteingang, war signiert und bekam dieselbe Spam-Wertung wie vor dem Update (0,40), jetzt schon vom neuen Rspamd 4.2.

Directus: drei Breaking Changes, keiner traf uns

Directus verlangt seit 12.4, dass öffentlich lesbare Sammlungen auch das Primärschlüsselfeld freigeben. Hätten unsere öffentlichen Rechte nur einzelne Felder ohne id erlaubt, wäre der Blog der Website leer geblieben. Sie enthielten aber id oder *. Die beiden anderen Änderungen (ein neuer Startbefehl im Image und strengere Prüfung von IP-Regeln) betrafen uns nicht. Als Nachweis habe ich die öffentliche API vor und nach dem Update per Prüfsumme verglichen: 83 Beiträge, About-Seite und Services waren identisch.

Wazuh: neue Pfade, bewusst nicht übernommene Standards

Von 4.10 auf 4.14 hat sich beim Indexer die Ablage der Konfiguration geändert. Zertifikate, die OpenSearch-Konfiguration und die internen Benutzer liegen jetzt unter einem config-Ordner. Hätte ich nur die Image-Version getauscht, wäre der Indexer ohne unsere Konfiguration gestartet. Ich habe die offizielle Upgrade-Anleitung mit dem Upstream-Diff zwischen beiden Versionen verglichen und nur die Pfade übernommen.

Drei neue Standardwerte habe ich bewusst nicht übernommen: einen neuen Cluster-Namen (unsere bestehenden Daten gehören zu einem anderen), ein Sitzungs-Timeout von 15 Minuten (das hätte SSO-Benutzer regelmäßig abgemeldet) und eine geänderte Rechteprüfung. Die Umstellung lief als losgelöstes Skript, damit ein Abbruch der SSH-Verbindung sie nicht halbfertig zurücklässt: stoppen, sichern, Konfiguration einspielen, starten. Der Ausfall dauerte etwa viereinhalb Minuten. Danach waren alle 285 Indizes da, der Cluster grün, und neue Alerts kamen an.

Docker Engine: ein Update, das alles neu startet

Docker-Updates sind bei uns heikel. Zwei frühere Vollausfälle gingen auf sie zurück: Einmal zeigte die Portweiterleitung für HTTPS danach auf das falsche Netz, einmal verlor der VPN-Dienst seine Schlüssel. Ohne live-restore startet ein Daemon-Neustart außerdem alle Container neu, praktisch ein Neustart des ganzen Stacks.

Die Pakete stehen deshalb bewusst auf „hold“. Für das Update habe ich die alten Pakete als Rollback bereitgelegt, einen Trockenlauf gemacht (genau drei Pakete, containerd blieb auf seiner Version), danach Container, Portweiterleitung, Firewall-Position von mailcow, Netzanbindungen und die VPN-Strecke gegen die Baseline verglichen und anschließend einen kontrollierten Neustart des Servers gemacht. Beim Boot traten wie immer sechs bekannte Netz- und Port-Fehler auf, die ein Wächter seit einigen Wochen automatisch behebt. Er hat alle sechs geheilt, die Prüfung nach dem Boot meldete „OK“, und alle 22 öffentlichen Endpunkte lieferten dasselbe wie vorher.

Next.js: die drei gefährlichsten Lücken der Woche

Bei einem Routine-Scan des Website-Images meldete Trivy drei kritische Lücken in Next.js 16.3.3, alle drei Code-Ausführung ohne Anmeldung: in der Bildoptimierung, in use cache und in next/og. Auf einer öffentlichen Website ist das die schlechteste denkbare Kombination. Behoben ist alles mit 16.4.1.

npm audit fand dabei etwas, das Trivy nicht meldete: Umgehungen der XSS-Filterung in DOMPurify. DOMPurify bereinigt bei uns das HTML der Blogbeiträge. Die Inhalte kommen zwar nur aus dem CMS, als Schutzschicht muss es aber stimmen. Seitdem laufen beide Prüfungen. Das neue Image zeigt bei Trivy 0 kritische und 0 hohe Funde, vorher waren es je 3.

Was beim Aufräumen hochkam

Die Updates waren absehbar. Die folgenden Funde waren es nicht. Sie kamen alle beim Prüfen nach einem Update hoch, und mehrere davon wogen schwerer als die Lücke, die das Update auslöste.

Passwörter im SIEM

Nach dem Wazuh-Update stieg die Zahl der Alerts von 319 auf 611 pro Viertelstunde. Die Ursache: Wazuh 4.14 meldet jedes exec_start-Ereignis von Docker als Alert, und das sind vor allem die Healthchecks aller Container, rund 1000 pro Stunde. In der Liste der ausgeführten Befehle standen zwei Passwörter im Klartext: das des Admin-Benutzers im Wazuh-Indexer und das des Redis-Dienstes von Authentik.

Das hat mit Wazuh selbst wenig zu tun. Docker Compose setzt ${VARIABLE} in einem Healthcheck beim Anlegen des Containers ein. Der Klartext steht danach in docker inspect, in jedem Docker-Ereignis und damit in jedem Werkzeug, das diese Ereignisse liest. Richtig ist $$VARIABLE in einem CMD-SHELL-Healthcheck, das erst zur Laufzeit im Container aufgelöst wird, oder ein Healthcheck, der gar kein Passwort braucht.

  1. Abfluss stoppen: eine eigene Wazuh-Regel setzt die neue Meldung auf Level 0. Das entspricht dem Verhalten vor dem Update, Shells in Containern erkennt weiterhin Falco.
  2. Die 465 betroffenen Alerts aus dem Index löschen. Der erste Versuch über alle Indizes scheiterte am Limit offener Scroll-Kontexte, gezielt auf den Tagesindex klappte es.
  3. Den Redis-Dienst von Authentik nicht rotieren, sondern stilllegen: Authentik nutzt seit Version 2025.10 Postgres für Cache und Aufgaben. Redis hatte keinen einzigen Client mehr, die 86 übrigen Einträge stammten von alten Versionen. Damit war das Passwort gegenstandslos.
  4. Das Indexer-Passwort rotieren, und zwar nur für diesen einen Benutzer über die REST-API, angemeldet mit dem Admin-Zertifikat. Ein komplettes Neueinspielen der Sicherheitskonfiguration wäre ein viel größerer Eingriff gewesen.
  5. Den Healthcheck des Indexers so umbauen, dass er kein Passwort mehr braucht: Antwortet der Dienst mit 200 oder 401, läuft er.

Bevor das Redis-Passwort wegfallen konnte, musste ich noch sechs Stellen anpassen, die den Dienst kannten: eine Prometheus-Regel, die einen fehlenden Container meldet, den Tagesreport, zwei Prüfungen im Update-Skript, die Backup-Liste und ein Rollback-Skript. Ohne diese Suche hätte die Stilllegung am nächsten Morgen drei Fehlalarme und einen abgebrochenen Backup-Lauf erzeugt.

Eine SQL-Injection im Chatbot

Bei einem Funktionstest des Chatbots schickte ich absichtlich eine Session-ID, die keine UUID war. Die Fehlermeldung aus der Datenbank zeigte den vollständigen SQL-Text, und darin stand meine Eingabe wörtlich. Die Postgres-Nodes des n8n-Workflows setzten Werte aus der Anfrage per '{{ … }}' direkt in den Abfragetext ein. Betroffen waren Session-ID, Zeitstempel und User-Agent. Der Proxy der Website prüfte die Session-ID nur darauf, ob sie überhaupt Text ist. Die Zieldatenbank war die von n8n selbst, mit Workflows, Benutzern und verschlüsselten Zugangsdaten.

Behoben ist das in zwei Schichten. Der Proxy akzeptiert nur noch eine UUID v4 als Session-ID, setzt den Zeitstempel selbst und begrenzt den User-Agent auf harmlose Zeichen. Dauerhaft sicher wird es aber erst in n8n: Alle fünf Abfragen übergeben ihre Werte jetzt als Parameter ($1, $2 …) über die Option „Query Parameters“ mit einem Array-Ausdruck. Das manuelle Verdoppeln von Apostrophen ist entfernt, sonst stünden sie künftig doppelt in der Datenbank.

Bevor ich den Produktions-Workflow anfasste, habe ich in einem separaten Test-Workflow geprüft, wie die Parameterübergabe mit Kommas, Apostrophen, Backslashes und JSON-Daten umgeht. Alles kam als Daten an. Den Beweis am Ende lieferte ein direkter Aufruf des Webhooks am Proxy vorbei mit der klassischen Eingabe x' OR '1'='1. Die Datenbank behandelte sie als einen einzigen Wert und lehnte ihn als ungültige UUID ab. Vorher hätte der Apostroph den SQL-String beendet.

n8n 2.x führt die veröffentlichte Version aus. Wer einen Workflow direkt in der Datenbank ändert, ändert nicht unbedingt, was läuft: Die aktive Fassung steht in der Workflow-Historie. Der saubere Weg ist Export, Änderung, Import, Veröffentlichen und ein Neustart von n8n. Das Flag, mit dem der Import den Aktiv-Status übernimmt, geht nur im Queue-Modus. Ohne es deaktiviert der Import den Workflow, und erst das Veröffentlichen aktiviert ihn wieder.

Ein leerer Wert schaltet den Schutz ab

n8n 2.x sperrt standardmäßig den Node „Execute Command“, mit dem ein Workflow Befehle im Container ausführt, und den Zugriff auf Umgebungsvariablen aus Code-Nodes und Ausdrücken. In unserer Konfiguration standen beide Schalter auf einem leeren Wert: NODES_EXCLUDE: "" und N8N_BLOCK_ENV_ACCESS_IN_NODE: "". Ein leerer Wert bedeutet dort aber nicht den Standard, er hebt die Sperre auf. Jeder n8n-Benutzer hätte damit Befehle ausführen und alle 38 Umgebungsvariablen lesen können, darunter das Datenbankpasswort und den Verschlüsselungsschlüssel von n8n.

Kein Workflow brauchte eine der beiden Funktionen. Ich habe das mit einer Gegenprobe abgesichert: Eine Textsuche nach $env fand nichts, dieselbe Suche nach $json fand 10 Workflows, die Suche funktionierte also. Der Kommentar in der Konfiguration, der Zugriff sei „für die API-Key-Prüfung des Chatbots“ nötig, war schlicht veraltet. Beide Sperren stehen jetzt ausdrücklich in der Konfiguration, statt sich auf den Standardwert zu verlassen.

Zusammen mit der SQL-Injection hätte das eine Kette ergeben: Wer in die Datenbank von n8n schreiben kann, kann Workflows ändern, und ein Workflow mit dem Befehls-Node führt Code aus. Die Kette ist an beiden Stellen unterbrochen.

Schlüssel im Klartext in Workflows

Beim Export der umgebauten Workflows schlug der Secret-Scan vor dem Commit an. Die HTTP-Nodes trugen den Schlüssel für das lokale LLM-Gateway, den Schlüssel der Vektordatenbank und den Webhook-Schlüssel als festen Text in ihren Parametern. Damit standen sie in jedem Export, in jeder Ausführungs-Historie und in jedem Backup von n8n. Ins Repository waren immer nur Fassungen mit Platzhaltern gegangen, auf dem Server selbst aber lagen sie überall.

Jetzt liegen sie in n8n-Credentials, die n8n verschlüsselt speichert. Den Webhook-Schlüssel prüft n8n direkt am Webhook-Node, ein falscher Schlüssel bekommt 403, und der Workflow startet gar nicht erst. Der frühere Prüfknoten im Workflow ist entfallen. Statt eines gemeinsamen Gateway-Schlüssels haben die Verbraucher auf dem Server jetzt je einen eigenen (Chatbot, Monitoring, eine weitere Anwendung), damit sich ein Schlüssel austauschen lässt, ohne die anderen anzufassen. Die Schlüssel für Vektordatenbank und Webhook sind neu, die alten werden abgelehnt.

Getestet habe ich den Umbau wieder zuerst in einer Kopie des Chatbots unter einem eigenen Webhook-Pfad. Die Kopie zu entfernen war dann aufwendiger als gedacht: Veröffentlichte Workflows hängen in n8n 2.x an einer weiteren Tabelle, und das Löschen in der falschen Reihenfolge scheitert an Fremdschlüsseln. Sauber ist zuerst unpublish:workflow über die CLI, danach das Löschen.

Ein Rate-Limit für die ganze Website

Bei einem Test bekam ich unerwartet „Zu viele Anfragen“. In der Rate-Limit-Tabelle des Chatbots standen 38 Einträge der letzten 24 Stunden, alle mit der IP unknown. Seit der Chat über einen serverseitigen Proxy der Website läuft (damit der Webhook-Schlüssel nicht im Browser landet), ruft der Proxy n8n intern auf, und n8n sieht keine Besucher-IP mehr. Das Limit von 25 Nachrichten pro Stunde galt damit für alle Besucher zusammen. Ob echte Besucher betroffen waren, lässt sich nicht mehr feststellen, weil diese Ablehnungen nicht protokolliert werden.

Der Proxy gibt die IP jetzt in einem eigenen Header weiter. Vertrauenswürdig ist der Wert, weil der Aufruf mit dem Webhook-Schlüssel authentisiert ist und Traefik ohne ausdrückliche Freigabe alle vom Client mitgeschickten X-Forwarded-*-Header verwirft. Ein Besucher kann seine IP also nicht selbst vorgeben. In der Tabelle stehen seitdem wieder echte Adressen.

Kleinere Funde

  • Die .gitignore des Repositories deckte nur Dateien ab, die mit .env beginnen, der Pre-Commit-Hook nur Dateien namens .env. Secret-Dateien wie gotify.env wären committbar gewesen. Beide fangen jetzt *.env ab, getestet mit einer Probedatei und erzwungenem git add -f.
  • Der Authentik-Worker schrieb jede Minute einen Fehler ins Log, weil er eine Docker-Verbindung überwachte, die der eingebettete Outpost gar nicht braucht. In der Datenbank lagen 722 fehlgeschlagene Läufe. Solches Dauerrauschen verdeckt echte Fehler, die Verbindung ist entfernt.
  • Die Website hatte 6 Lint-Fehler, 2 davon kamen mit der strengeren Regel-Version von Next.js 16.4. Behoben sind sie mit useSyncExternalStore für Browser-Zustände wie Cookie-Einwilligung und Media-Query, geprüft mit Playwright im echten Browser.

Der Chatbot als Nebenschauplatz

Nach der Rotation der Schlüssel stellte jemand dem Chatbot die Frage „was gibt es für Blogs zum Thema lokale LLM“. Die Antwort nannte zwei Beiträge, beide die ältesten zum Thema. Passend gewesen wären zehn. Die Rückmeldung dazu traf es: In letzter Zeit sei der Chat nur noch eine Suchfunktion für Blogbeiträge gewesen, statt Fragen zu beantworten.

Die Ursache lag nicht bei den Daten, die Wissensbasis war vollständig. Der Chatbot holte die sechs ähnlichsten Textabschnitte, und vier davon stammten aus demselben Beitrag. Ins Modell durften dann nur drei Quellen mit je 700 Zeichen. Diese Grenze stammte aus der Zeit, als das Modell mit 4k Kontext lief, heute sind es 64k. Und der Prompt drängte darauf, vor allem Links zu nennen.

Jetzt fasst die Vektordatenbank die Treffer nach Beitrag zusammen, Qdrant kann das direkt (search/groups). Außerdem unterscheidet der Chatbot zwei Arten von Fragen. Bei Listenfragen („Welche Beiträge gibt es zu …“) dürfen 29 neue Themenübersichten mitspielen, die bei jeder nächtlichen Aktualisierung der Wissensbasis automatisch entstehen. Bei Inhaltsfragen („Wie funktioniert …“) bleiben die Übersichten außen vor, es kommen bis zu zwei Abschnitte pro Beitrag in den Prompt, und die Antwort erklärt die Sache selbst, bevor sie einen Link nennt.

Dabei kam noch ein Fehler zum Vorschein. Auf „Was ist der neueste Beitrag?“ nannte der Chatbot einen Beitrag vom August. Das Übersichtsdokument mit den neuesten Beiträgen war wegen inzwischen längerer Titel in zwei Abschnitte zerfallen. Und ein Beitrag, der selbst über Veröffentlichungsdaten schreibt, war für diese Frage der stärkste Treffer. Die Übersicht passt jetzt wieder in einen Abschnitt, und bei Fragen nach dem Neuesten steht sie fest an erster Stelle. Ein Grenzfall bleibt: Das Modell vermischt gelegentlich Details und hat in einem Test einmal eine E-Mail-Adresse erfunden, die in keiner Quelle stand.

Meine eigenen Fehler

Diese Liste ist mir wichtiger als die der Updates. Keiner der Fehler hat Schaden angerichtet, aber mehrere wären unbemerkt geblieben, wenn ich nur auf einen Rückgabewert oder eine leere Ausgabe geschaut hätte.

Was passiert istWarum es auffiel
Das Backup-Werkzeug von mailcow mit --delete-days 0 aufgerufen. Die Option löscht alle Backups, die älter als N Tage sind, bei 0 also auch das gerade erzeugte.Das Protokoll listete die gelöschten Dateien auf. Ich habe das Backup ohne die Option wiederholt.
In der Gitea-Compose einen Umgebungsschlüssel ergänzt, der weiter unten schon stand. Die Datei war damit ungültig.docker compose config -q vor dem Neustart hat abgebrochen. Gitea lief durchgehend weiter.
Der erste Versuch, das Indexer-Passwort zu ändern, nutzte die falsche API-Operation.Das Skript prüfte die Antwort und brach ab, bevor etwas geändert war.
npm audit fix --omit=dev entfernte lokal die Entwicklungspakete. Der anschließende Typecheck lief gar nicht und sah aus wie ein grüner Typecheck.Ein bewusster Nachbau mit sauberer Neuinstallation. Danach scheiterte der Build noch an einem veralteten Cache, erst ohne Cache war er grün.
Eine einmalige Prüfung für den nächtlichen Lauf der Wissensbasis enthielt einen SQL-Fehler. Sie hätte still „kein Lauf“ gemeldet.Ich habe die Abfrage vorab einzeln getestet, und dann die Kopie des echten Skripts mit Ausgabe statt Mail.
In derselben Prüfung suchte ein Muster nur nach zwei von drei möglichen Collection-Namen. Die Mail zeigte ein leeres Feld.Die Mail sah unvollständig aus, die Daten in der Vektordatenbank waren richtig.
Den ersten öffentlichen Test nach einem n8n-Neustart zu früh gestartet. n8n meldet „bereit“, bevor die Webhooks registriert sind, der Proxy machte aus dem 404 einen 502.Ein direkter Aufruf zeigte, dass der Workflow funktionierte. Seitdem warte ich rund 20 Sekunden.
Lehre: Ein Test ohne Ausgabe ist kein grüner Test. Wo es ging, habe ich deshalb immer eine Gegenprobe eingebaut, die beweist, dass die Prüfung überhaupt etwas finden kann: die Suche nach $json neben der Suche nach $env, der alte Schlüssel neben dem neuen, eine Probedatei für den Commit-Schutz, die alte Version neben der neuen beim Wächter.

Das Verfahren, das sich bewährt hat

Alle Änderungen in diesen drei Tagen liefen nach demselben Ablauf. Er kostet Zeit, hat aber jede Überraschung vor dem Umschalten oder direkt danach sichtbar gemacht.

  1. Baseline messen, und zwar an dem, was nach außen zählt: Antwortcodes aller öffentlichen Endpunkte, Prüfsummen der öffentlichen API, Punktzahlen und ein Suchtest in der Vektordatenbank, OIDC-Endpunkte aller angebundenen Dienste.
  2. Sichern und einen Rollback-Punkt setzen: Datenbank-Dump, Volume-Archiv, das alte Image unter eigenem Tag. Ein Tag wie pre-update, den der Wochenlauf überschreibt, reicht dafür nicht.
  3. Breaking Changes lesen und gegen die eigene Konfiguration prüfen, nicht nur überfliegen. Bei Wazuh, Directus und Gitea hat genau das den Unterschied gemacht.
  4. Vorab testen, ohne die Produktion zu berühren: Trockenlauf bei Paketen, isolierte Test-Workflows, Testkopien mit eigenem Pfad, ein lokaler Build mit Browser-Test.
  5. Eng umstellen: nur den betroffenen Dienst, lange Abläufe als losgelöste Einheit, damit ein Verbindungsabbruch sie nicht mittendrin stoppt.
  6. Mit der Baseline vergleichen und erst danach die Sperre aufheben, die Testkopie löschen oder den nächsten Schritt beginnen.
  7. Dokumentieren, mit Ursache und Konsequenz, auch für die eigenen Fehler.

Checkliste für den eigenen Betrieb

Aus diesen drei Tagen sind ein paar Prüfpunkte geworden, die sich auf fast jedes selbst betriebene Setup übertragen lassen:

  • Gibt es eine Liste aller Komponenten mit ihrer laufenden Version, und gleicht sie jemand mit den Sicherheitsmeldungen der Hersteller ab? Ein Update-Benachrichtiger allein sagt nicht, ob ein Update dringend ist.
  • Werden Dienste, die bewusst nicht automatisch aktualisiert werden, trotzdem regelmäßig aktualisiert? Manuelle Updates veralten am leichtesten.
  • Enthält irgendein Healthcheck ein Passwort per ${…}? docker inspect zeigt es.
  • Stehen Schutzschalter auf einem leeren Wert, in der Annahme, das sei der Standard?
  • Setzen Workflows oder Skripte Benutzereingaben als Text in SQL ein?
  • Stehen Schlüssel als Literal in Workflows, Skripten oder Exporten?
  • Sieht ein Rate-Limit hinter einem Proxy noch die echte Client-IP, oder zählt es alle unter einer Adresse?
  • Deckt die .gitignore alle Dateien ab, in denen Secrets stehen, auch die mit unüblichen Namen?
  • Beweist jeder automatische Test, dass er überhaupt etwas finden kann?

Die BSI-Mail war am Ende der nützlichste Teil dieser Woche. Sie meldete eine Lücke in Gitea und zeigte damit, dass unsere Sicht auf den eigenen Stand ebenfalls eine hatte. Die ist jetzt kleiner, und die Komponentenliste des neuen Wächters ist der Ort, an dem sie klein bleiben muss.