# Felderfahrungsbericht — CVE-2026-111497

Ergebnisse des Einsatzes des Werkzeugsatzes auf drei Produktionshosts unter Linux und die
Defekte, die dieser Einsatz aufgedeckt hat. Die Hosts sind anonymisiert: es wird keine IP-Adresse,
kein Hostname und kein Kundenname veröffentlicht.

## 1. Zusammenfassung

| Host | Betriebssystem | Rolle | Installiert (verwundbar) | Korrigiert | Ergebnis |
|---|---|---|---|---|---|
| A | Ubuntu 26.04 LTS | Hosting-Panel, 10 Websites | `libbrotli1 1.2.0-3build1` | `1.2.0-3ubuntu0.1` (`resolute-security`) | behoben, verifiziert |
| B | Debian 12 (bookworm) | Anwendungsserver, Desktop-Sitzung vorhanden | `libbrotli1 1.0.9-2+b6` | `1.0.9-2+deb12u1` (`bookworm-security`) | behoben, verifiziert |
| C | Ubuntu 24.04 LTS | Proxy- und Anwendungsserver, VPN-Client vorhanden | `libbrotli1 1.1.0-2build2` | `1.1.0-2ubuntu0.1` (`noble-security`) | `VERIFY: PASS`, vollständiger Zyklus |

Alle drei sind Hybridhosts: eine Serverlast plus eine grafische Sitzung. Alle drei hatten einen
offiziellen Backport in `-security`; das Gate zur Echtheitsprüfung (Changelog des Kandidaten mit
Nennung der CVE) bestand vor jeder Installation.

## 2. Was die Prüfung tatsächlich ergab

### 2.1 Der Decode-Pfad war libcurl, nicht der Webserver

Auf Host A erwies sich die Web-Ebene als reine Kompression: nginx nutzte `ngx_brotli` zur
Komprimierung von Antworten, Apache hatte `mod_brotli` verfügbar, aber deaktiviert, und keine
Komponente dekodierte Anfragekörper. Der Hauptvektor für Remote-Code-Execution über
`Content-Encoding: br` war damit nicht erreichbar.

Der erreichbare Pfad war ausgehendes HTTP aus PHP:

- `wp-includes/Requests/src/Transport/Curl.php` setzt `CURLOPT_ENCODING` auf eine leere
  Zeichenkette, wodurch libcurl jede Kodierung akzeptiert, mit der es gebaut wurde —
  einschließlich `br`;
- `wp-includes/SimplePie/src/File.php` tut dasselbe für den Abruf von Feeds;
- Guzzle-Factories, die in Plugins mitgeliefert werden, tun dasselbe für Anbieter-APIs.

Das Inventar zählte **164 PHP-Dateien** mit `CURLOPT_ENCODING` oder `CURLOPT_ACCEPT_ENCODING`.
libcurl meldete `brotli/1.2.0` unter seinen Features, sodass jeder `wp_remote_*`-Aufruf, jeder
RSS-Abruf und jeder Anbieteraufruf `br` über den verwundbaren Decoder dekomprimierte.

### 2.2 Kandidaten, die passend aussehen und es nicht sind

Bevor die Sicherheitsablage aktualisiert war, war der einzige `libbrotli1`-Kandidat die
installierte `1.2.0-3build1` — dieselbe Upstream-Version, neu gebaut. Nach `apt-get update`
erschien ein Kandidat `1.2.0-3ubuntu0.1`. Der Endpunkt für das Changelog der Distribution
antwortete in diesem Moment mit 404, weil das Paket wenige Stunden zuvor veröffentlicht worden
war; das Versionssuffix allein war damit kein Beweis.

Der Beweis kam aus dem Paket selbst: das Sicherheits-`.deb` herunterladen, entpacken und
`usr/share/doc/libbrotli1/changelog.Debian.gz` lesen zeigte den Eintrag, der CVE-2026-111497
nennt und die Änderung am Decoder beschreibt. Diese Prüfung wurde zum Gate der Echtheitsprüfung
von `remediate.sh` — eine Versionszeichenkette entscheidet nie für sich allein.

### 2.3 Konsumenten, die nicht neu gestartet werden dürfen

Die Bindung von `libbrotlidec` ist nicht auf Server beschränkt. Auf den Hybridhosts bindet der
Desktop-Stapel dieselbe Bibliothek über freetype und fontconfig ein: GNOME Shell, Xorg,
NetworkManager, `fwupd`, `packagekitd`, `gsd-*`-Helfer und der VPN-Client. Ein Neustart würde die
Sitzung und den Tunnel beenden, ohne einen Beitrag zur Angriffsfläche des Servers zu leisten.

Sie werden daher als `deferred` eingestuft: der Bericht nennt sie, es wird kein Neustart
versucht, und die gepatchte Bibliothek wird bei der nächsten Anmeldung oder beim nächsten
Neustart geladen.

### 2.4 Panel-Daemons sind ein eigener Neustartmechanismus

Auf Host A hielten zwei `core`-Daemons von ISPmanager nach jedem `systemctl`-Neustart den alten
Inode. Sie werden vom Starter des Panels über transiente Scopes mit `systemd-run` gestartet, sind
also keine Kinder des Webservers und haben keine eigene Unit. Sie per PID abzuschießen riskiert
eine Korruption des Panel-Zustands; der unterstützte Weg ist das Steuerwerkzeug des Panels:

```bash
mgrctl -m core --restart srvparam
mgrctl -m ispmgr --restart srvparam
```

Beide Daemons wurden auf der gepatchten Bibliothek neu gestartet, und der Scan auf veraltete
Inodes lieferte danach null.

### 2.5 Ein vorbestehender Fehler, der der Änderung nicht zugerechnet werden darf

Unmittelbar nach der Behebung führte `systemctl --failed` auf Host A `logrotate.service` auf. Der
Zeitstempel zeigte, dass der Dienst aus seinem täglichen Timer an einer unabhängigen
Website-Konfiguration gescheitert war (doppelte Logeinträge plus ein Berechtigungsfehler). Der
Fall wurde protokolliert und unberührt gelassen: eine unabhängige Reparatur in eine
Sicherheitsänderung zu mischen macht die Änderung unüberprüfbar.

## 3. Im Feld aufgedeckte Defekte und ihre Korrekturen

Neun Defekte wurden durch das Ausführen des Agenten gefunden, nicht durch das Lesen. Jede Zeile
ist aus der Abfolge in Abschnitt 4 reproduzierbar.

| # | Defekt | Symptom im Feld | Korrektur |
|---:|---|---|---|
| 1 | `set -o pipefail` mit Pipelines, die früh enden | Exit 141 (SIGPIPE), obwohl die Prüfung erfolgreich war | `pipefail` entfernt; die Korrektheit ruht auf ausdrücklichen Prüfungen |
| 2 | `set -euo` mit einem verwaisten `-o` | die Shell gab ihre Optionsliste auf stdout aus | `set -eu` |
| 3 | State-Datei als Shell eingebunden | Syntaxfehler: der Pretty-Name des Betriebssystems enthält Klammern | Werte über den Schlüssel mit `state_get` lesen |
| 4 | `state.after` definiert `installed` neu | falsches `FAIL` im Vergleich vor/nach | getrennte Variablen `OLD_` und `AFTER_` |
| 5 | `printf '%s'` ohne Zeilenumbruch in der cgroup-Schleife | Unit-Namen verschmolzen zu einer einzigen unsinnigen Unit, sodass der Neustart stillschweigend nichts bewirkte | `printf '%s\n'` |
| 6 | Inode aus einem Glob erfasst | der Inode des Symlinks wurde erfasst, sodass die Prüfung auf veraltete Bindung fälschlich bestand | `find -type f -printf '%i'` |
| 7 | PID-Schleife ohne `\|\| true` | `set -e` beendete mit 1, nachdem das Paket bereits installiert war | Schleife mit `\|\| true` abgesichert |
| 8 | cgroup-Regex blind für verschachtelte Slices und Template-Instanzen | `revoap-worker@1` blieb auf der alten Bibliothek | das innerste `.service`-Segment mit `awk` entnehmen |
| 9 | Desktop- und Infrastruktur-Konsumenten als verwundbar markiert | Rauschen im Bericht, das den eigentlichen Befund verdeckte | `DESKTOP_INFRA_PAT` plus Status `deferred` |

Zwei davon verdienen eine ausdrückliche Darstellung, weil beide ein *falsches Grün* erzeugten:

- Defekt 5 bedeutete, dass der Neustartabschnitt lief, nichts Beunruhigendes ausgab und jeden
  Dienst auf der verwundbaren Bibliothek beließ. Die Unit-Liste war korrekt aufgebaut und dann zu
  einer einzigen Zeichenkette zusammengefallen, sodass `systemctl restart` mit einem nicht
  existierenden Unit-Namen aufgerufen wurde und dessen Fehlschlag nicht fatal war.
- Defekt 6 bedeutete, dass die Prüfung „kein Prozess bindet den alten Inode ein“ gegen den Inode
  des Symlinks verglich, den konstruktionsbedingt kein Prozess einbindet. Die Prüfung meldete
  Erfolg auf einem Host, der nicht neu gestartet worden war.

Keiner der beiden war am Exit-Code zu erkennen. Beide wurden gefunden, indem die Behauptung nach
dem Lauf auf dem Host gegen `/proc/*/maps` gegengeprüft wurde.

## 4. Ablauf wie ausgeführt

```text
host A  detect -> install -> restart nginx, php-fpm pools, pdns, zabbix-agent, ihttpd
        -> mgrctl --restart core/ispmgr -> stale scan 0 -> 10/10 sites HTTP 200 -> decode round-trip
host B  detect -> remediate (defects 1..5 surfaced) -> restart apache2, php-fpm x2, zabbix x2,
        fastpanel apps, revoap-worker instances -> stale scan: server consumers 0
host C  detect -> remediate (defects 6..9 surfaced) -> verify -> VERIFY: PASS
```

## 5. Nachweise der Verifikation

| Host | Paket danach | Veraltete Server-Bindungen | Fehlgeschlagene Units | Funktionale Prüfung |
|---|---|---|---|---|
| A | `1.2.0-3ubuntu0.1` | 0 (das Panel wurde auf dem gepatchten Inode neu gestartet) | leer | nginx lieferte `content-encoding: br`; `curl --compressed` erzeugte gültiges HTML; 10 von 10 Websites HTTP 200 |
| B | `1.0.9-2+deb12u1` | 0 bei den Server-Konsumenten; Desktop zurückgestellt | leer | Decode-Rundlauf über den lokalen Endpunkt |
| C | `1.1.0-2ubuntu0.1` | 0 bei den Server-Konsumenten; Desktop und VPN zurückgestellt | leer | vollständiger Zyklus endete mit `VERIFY: PASS` |

Auf Host B und Host C startete der Paket-Hook von `needrestart` einen Teil der Serverdienste
während der Installation von selbst neu; der Bericht weist diese Neustarts dem Hook zu, und der
ausdrückliche Neustartschritt hatte für diese Units anschließend nichts mehr zu tun.

## 6. Lehren, die das Werkzeug geprägt haben

1. **Eine Versionszeichenkette ist kein Beweis.** Das Gate liest das mitgelieferte Changelog, weil
   ein Neubau und eine echte Korrektur im Versionsfeld identisch aussehen, solange die Revision
   nicht betrachtet wird.
2. **Eine gepatchte Datei ist kein gepatchter Prozess.** Jede Aussage über den Effekt wird in
   `/proc/*/maps` geprüft, nie aus `dpkg` gefolgert.
3. **Ein stillschweigendes Nichts ist schlimmer als ein Fehler.** Zwei Defekte erzeugten „Erfolg“,
   während sie nichts taten; das Werkzeug gibt jetzt aus, was es neu gestartet hat, und prüft
   danach den Inode erneut.
4. **Hybridhosts brauchen eine Sperrliste, keinen pauschalen Neustart.** Die Server-Oberfläche und
   die Desktop-Oberfläche müssen ausdrücklich getrennt werden, sonst reißt die Behebung die
   Sitzung mit.
5. **Panel-Software hat ihren eigenen Lebenszyklus.** Als transiente Scopes gestartete Daemons
   werden über das Steuerwerkzeug des Panels neu gestartet, nie mit einem Signal.
