Beitragsbild zu einem FreeBSD-Bug: Laptop mit PHP- und lldb-Debug-Ausgaben, Signal-11-Core-Dump und Diagramm der Kausalkette von Nextcloud über proc_open und posix_spawnp bis zur rtld-Heap-Korruption.

Diese Geschichte fing als PHP-Problem an und endete mehrere Wochen später in einem Bug im FreeBSD-Basissystem, ganz unten im Runtime-Linker. Dazwischen liegen mindestens vier falsche Fährten, ein Crash, der bei jedem Lauf ein anderes Opfer suchte, und die schöne Erkenntnis, dass man eine Heap-Korruption nicht mit einzelnen Watchpoints fängt. Ich schreibe das bewusst mit allen Sackgassen auf, weil genau die der lehrreiche Teil sind. Wer nur die Auflösung will, springt ans Ende.

Das Symptom

PHP 8.4 auf FreeBSD 15 (amd64), im Zusammenspiel mit einer selbst gehosteten Nextcloud. Jeder occ-Aufruf und jeder Cron-Lauf lieferte sein Ergebnis korrekt ab und segfaultete danach. Signal 11, jedes Mal, mit schöner Regelmäßigkeit ein Core-Dump von rund 2,2 GB. Die Ausgabe stand vollständig da, bevor es knallte. Der Crash passierte erst im Module-Shutdown, also beim Aufräumen, nachdem die eigentliche Arbeit längst erledigt war.

Funktional war das harmlos. Ärgerlich war der Rest. Das dmesg füllte sich mit Zeilen der Sorte:

pid 12345 (php), jid 0, uid 80: exited on signal 11 (core dumped)

Die Platte lief mit 2,2-GB-Cores voll, und es gab einen unangenehmen Nebeneffekt: hängende Background-Jobs. Wenn PHP-FPM mitten in einem Nextcloud-Cron-Job segfaultet, wird das reserved_at in der Tabelle oc_jobs nie zurückgesetzt. Der Job gilt damit als dauerhaft in Bearbeitung und läuft nie wieder an. Aus einem kosmetischen Shutdown-Crash wurde so ein echtes Betriebsproblem.

Erste falsche Fährte: OPcache JIT

Ein Segfault in PHP, der frische JIT im Spiel: der erste Verdacht war schnell da. Also habe ich mich durch die JIT-Stufen gearbeitet. Tracing-JIT mit opcache.jit=1255, dann Function-JIT mit 1205, dann JIT komplett aus mit 0. Es crashte durch alle Stufen hindurch unverändert weiter.

JIT war auf FreeBSD 15 zwar tatsächlich für sich genommen kaputt und ist bei mir seitdem aus. Aber die Ursache für den Shutdown-Crash war er nicht. Erste Fährte verworfen.

Die Versions- und Build-Jagd

Nächster Verdacht: ein kaputter Build oder eine ABI-Unstimmigkeit zwischen dem PHP-Core und einer Extension. Also PHP komplett aus den Ports neu gebaut, damit Core und alle Extensions garantiert dieselbe Version tragen. Danach Symbol-Builds fürs Debugging. Und dann durch die Punktversionen gehangelt: 8.4.16, .18, .19, .20, .21, .22. Jede einzelne crashte gleich.

Damit war eine wichtige Sache geklärt: Build, Version und CFLAGS sind nicht der Unterschied. Was sich nicht ändert, wenn man alles daran ändert, liegt woanders.

Eine Lehre am Rande, die mich unnötig Zeit gekostet hat: --enable-debug wechselt das ABI-Verzeichnis der Extensions. Danach laden sämtliche als Paket installierten Extensions nicht mehr, weil sie im falschen Verzeichnis gesucht werden. Wer nur Debug-Symbole will, ohne das ABI zu verbiegen, baut so:

make CFLAGS+=" -g" STRIP=

Die Crash-Site per lldb aus dem Core

Das FreeBSD-Basissystem bringt kein gdb mit, dafür lldb. Aus dem Core kommt man so an den Backtrace:

lldb --batch -o "target create --core <core> <php-binary>" -o "bt all"

Der Stack sah beim ersten Lauf so aus:

_start → __libc_start1 → main → php_module_shutdown → zend_shutdown
  → zend_hash_graceful_reverse_destroy → destroy_zend_class +1228

Die crashende Instruktion war cmpq %rbx, 0x20(%r15). Der Offset 0x20 ist in zend_property_info das Feld ce, der Zeiger auf den Klassen-Eintrag. Das Register r15 stand auf 0x6b588e9c404, unaligned und außerhalb des Heaps. Das riecht nach einem Use-after-Free auf geteilte interne Klassen-Metadaten.

Ein genauerer Walk durch die Strukturen korrigierte meine erste Annahme. Der Offset 0x20 liegt nicht nur in zend_property_info, sondern genauso in zend_class_constant auf dem ce-Feld. Die crashende Schleife lief nicht über die Properties, sondern über die Klassen-Konstanten, also die constants_table. Die crashende Klasse war Pdo\Pgsql, eine der neuen internen Subklassen aus dem PHP-8.4-RFC zu den PDO-treiberspezifischen Subklassen, die von PDO erbt. Mein Verdacht drehte sich damit auf etwas 8.4-Spezifisches: Vererbung von internen Konstanten, vielleicht im Umfeld der Property Hooks.

Der Crash wandert

Und jetzt wurde es unangenehm. Die Crash-Site war nicht stabil. Von Lauf zu Lauf sah ich mal destroy_zend_class, mal zend_type_release, mal zend_interned_strings_dtor. Mal war das Opfer Pdo\Pgsql, mal ein arg_info von RedisCluster, mal ein zend_type, mal ein DateTimeZone.

Das ist das klassische Bild eines einzelnen korrumpierenden Schreibzugriffs mit wechselndem Opfer. Wer getroffen wird, hängt allein am Heap-Layout des jeweiligen Laufs. Das erklärt rückblickend, warum die vermeintlich genaue Klasse jedes Mal anders aussah. Ich hatte die ganze Zeit das Spätsymptom analysiert, nicht die Ursache. Als Beispiel eine ganz andere Crash-Site vom zweiten Rechner:

php_module_shutdown → zend_interned_strings_dtor
  → zend_hash_destroy +310 → _str_dtor → _efree +11

Das Opfer hier war ein permanenter interned String. Das sind die intern deduplizierten, prozessweit nur einmal abgelegten Zeichenketten, die PHP überall wiederverwendet, in diesem Lauf der Redis-Kommandoname zintercard. Sein Header war zerschossen, beim Freigeben faultet der Destruktor auf einem ZendMM-Block, der gar nicht mehr gemappt ist. Wieder ein anderer Tatort, dasselbe Muster: irgendwer schreibt einmal quer, und wer danach als Erstes über die zerstörte Stelle stolpert, nimmt den Fall.

Upstream-Issue GH-21995, und die Richtung dreht sich

An diesem Punkt habe ich das Ganze bei php-src als Issue GH-21995 aufgemacht. Zwei Reaktionen haben die Richtung gedreht.

Zuerst @iliaal, einer der PHP-Maintainer:

Cannot reproduce on Linux (ASAN, Valgrind all clean on 37 extension build), so if this is valid it might be FreeBSD specific.

ASAN und Valgrind sauber auf Linux ist ein starkes Indiz gegen einen klassischen Use-after-Free im Zend-Speichermanager. Ein solcher Fehler würde unter ASAN sofort auffliegen. Wenn er das nicht tut, sitzt das Problem woanders, vermutlich unterhalb von PHP.

Dann bestätigte @CamilleScholtz das Verhalten unabhängig, auf PHP 8.5.6, FreeBSD 15, und ausdrücklich nicht in einem Jail. Damit fielen zwei bequeme Ausreden weg: es war weder meine spezielle Konfiguration noch etwas, das in 8.5 schon behoben gewesen wäre.

Die VM reproduziert nicht, ein Heisenbug

Auf Bare-Metal crashte die unveränderte Paket-Installation praktisch bei jedem Lauf, gefühlt zu hundert Prozent. In einer VM dagegen kam ich auf rund 650 saubere Läufe, ohne einen einzigen Crash. Und sobald ich mit lldb und Watchpoints an das Objekt heranging, das ich für das Opfer hielt, verschob sich das Opfer. Die Beobachtung selbst veränderte das Heap-Layout und damit den Ausgang.

Das ist ein Heisenbug im Lehrbuchsinn. Ein einzelner Watchpoint auf ein einzelnes Objekt bringt hier nichts, weil der nächste Lauf ein anderes Objekt zerstört. Ich brauchte eine Messung, die gegen das Layout robust ist.

Messen statt raten: der Tabellen-Diff

Statt ein einzelnes Objekt zu beobachten, habe ich die ganze Tabelle der permanenten interned Strings an definierten Checkpoints verglichen. Ein eigenes lldb-Python-Skript zieht an jedem Checkpoint einen Snapshot der Tabelle und difft gegen den vorherigen. So ist es egal, welches konkrete Objekt in diesem Lauf getroffen wird, denn ich sehe jede Änderung an der ganzen Region.

Das Ergebnis war der erste harte Datenpunkt seit Wochen. Der korrumpierende Schreibzugriff passiert während des Spawns, genauer im Intervall zwischen posix_spawnp und posix_spawn_file_actions_destroy. Überschrieben wird ein zusammenhängender Block von rund 480 Byte, gefüllt mit 8-Byte-Zeigern. Das sieht aus wie Stack-Frames, die dort hingehören, wo sie nicht hingehören. Damit war klar: das ist keine PHP-interne Speicherverwaltung, das ist der Spawn.

Die Batterie: den Auslöser einkreisen

Jetzt konnte ich gezielt testen. Je 20 Läufe pro Kandidat. Nur proc_open crashte, und zwar 20 von 20. popen, exec, system, shell_exec, fopen, dazu Heap-Churn-Kandidaten wie str_repeat und range: alle 0 von 20. Es ging also nicht um fork und exec im Allgemeinen, auch nicht um Heap-Belastung, sondern spezifisch um proc_open.

Und dann entschied die Form des Aufrufs über Crash oder kein Crash:

AufrufSpawn-PfadCrash
proc_open(["true"], …) (relativ)posix_spawnp__libc_execvpe (PATH-Suche)ja
proc_open(["/usr/bin/true"], …) (absolut)posix_spawnpexecvPe direktnein
proc_open("true", …) (String)posix_spawn (/bin/sh -c) → _execvenein

Nur der relative Befehl ohne Schrägstrich im Namen crasht, weil nur der die PATH-Suche im Kind auslöst. Die Länge des PATH war dabei egal, auch mit einem einzigen Eintrag crashte es. Das grenzt es sauber gegen den alten Long-PATH-Overflow ab: es geht nicht um einen zu langen PATH, sondern um einen intrinsischen Stack-Verbrauch im no-slash-Suchzweig.

Das Minimal-Repro ist entsprechend kurz und kommt ganz ohne Framework aus:

php -r 'proc_open(["date"], [], $pipes);'   # → signal 11

Ein Detail fehlt noch, und es ist wichtig: der Crash braucht den vollen Satz geladener Extensions. Ein Minimalsatz von 17 Extensions reicht, aber das Entfernen irgendeiner einzelnen davon stoppt den Crash. Konkret dieser Satz: session, dom, iconv, imagick, intl, pdo, pgsql, phar, simplexml, sodium, xml, xmlwriter, zip, zlib, memcached, pdo_pgsql, redis. Viele geladene Shared Objects plus ein proc_open: beides zusammen ist nötig, keins allein reicht. Diese Beobachtung war später der Schlüssel zur Ursache, auch wenn ich das zu dem Zeitpunkt noch nicht wusste.

Runter in die libc-Quelle

Der Spawn führte mich in /usr/src/lib/libc/gen/posix_spawn.c. Auf amd64 startet do_posix_spawn das Spawn-Kind so:

rfork_thread(RFSPAWN, stack + stacksz, _posix_spawn_thr, &psa)

Der Stack für dieses Kind ist ein winziger, per malloc geholter Puffer:

#define _RFORK_THREAD_STACK_SIZE  4096
stacksz = 4096 + MAX(3, argc + 2) * sizeof(char *);   /* 16-Byte aligned */
stack   = malloc(stacksz);

Für ein {"true", NULL} sind das rund 4128 Byte. Das Entscheidende an RFSPAWN beziehungsweise rfork_thread: das Kind bekommt bis zum exec einen geteilten Adressraum, ähnlich wie bei vfork. Kind und Eltern arbeiten bis zum exec also auf demselben Speicher. Bei einem relativen Kommando läuft das Kind über __libc_execvpe in die PATH-Suche. Meine Hypothese an dieser Stelle war: das Kind erschöpft seine gut 4 KB Stack und schreibt in den direkt darunter liegenden Heap des Elternprozesses. Das würde exakt zu dem 480-Byte-Block aus Zeigern passen, den der Tabellen-Diff gesehen hatte.

Der Beweis: guardspawn

Eine Hypothese ist nur so gut wie ihr Experiment. Also habe ich guardspawn.c geschrieben, einen kleinen Interposer per LD_PRELOAD, der rfork_thread(RFSPAWN) abfängt und dem Kind einen selbst kontrollierten Stack unterschiebt. Zwei Varianten, zwei klare Antworten:

  • Gebe ich dem Kind 1 MB Stack, fällt der Crash auf 0 von 30. Baseline ohne Interposer war 30 von 30.
  • Gebe ich dem Kind wieder nur gut 4 KB, aber mit einer Guard-Page direkt darunter, stirbt das Spawn-Kind selbst mit SIGSEGV, unabhängig von der genauen Stelle.

Damit war die Kernaussage bewiesen: das Kind erschöpft den knapp 4 KB großen Spawn-Stack. Genauso ehrlich habe ich es aber auch in den Report geschrieben: welcher exakte Frame den Puffer überläuft, war zu dem Zeitpunkt nicht bewiesen. Ein alleinstehendes C-Programm triggerte den Fehler nicht, das Ganze hing an der Last des Prozesses. Mein Verdacht ging Richtung Runtime-Linker, aber das war noch eine Vermutung, kein Beweis.

Eine ehrliche Selbstkorrektur

Zwischendurch hatte ich mich verrannt und einen Stack-Underflow zu bestimmt behauptet. Ein zweiter, kritischer Blick von außen und ein eigener Read der Quelle korrigierten das: execvPe selbst verbraucht deutlich weniger als 4 KB, und absolute Kommandos laufen auch durch execvPe und crashen trotzdem nicht. Der Unterschied liegt also nicht in einem bewiesenen Overflow in execvPe, sondern im no-slash-Zweig der PATH-Suche. Ich habe das im Report deshalb als Lokalisierung formuliert, nicht als bewiesenen Mechanismus.

Dazu gehört auch das ehrliche Eingeständnis, dass alle meine früheren php-src-Hypothesen falsch waren: die Property Hooks, der vermeintliche Use-after-Free auf Klassen-Konstanten, die interned-String-Korruption, die pgsql-Verdächtigungen. Das war alles die wandernde Fault-Site, das Spätsymptom, nie die Ursache. Wer wochenlang das Symptom seziert, baut sich überzeugende Theorien über das Symptom. Das gehört in so einen Bericht hinein, nicht wegretuschiert.

Der Bugreport ans FreeBSD-Basissystem

Mit dieser Lokalisierung habe ich den Bug im FreeBSD-Basissystem eingereicht: Bug 295991. Das php-src-Issue GH-21995 habe ich als kein php-src-Bug geschlossen und beide Seiten miteinander verlinkt.

Wichtig war mir die Abgrenzung zu FreeBSD-SA-20:18 beziehungsweise CVE-2020-7458 von 2020. Das war der Long-PATH-Overflow an genau dieser Code-Stelle, längst behoben. Mein Fall ist die gleiche Gegend im Code, aber unabhängig von der PATH-Länge. Es ist bewusst keine Sicherheitsgeschichte, sondern ein Stabilitätsproblem, ausgelöst von völlig legitimem Code beim Aufräumen.

Praktischer Nebenbefund für alle, die sich an der Anubis-Sperre der FreeBSD-Bugzilla stören: den Status eines Bugs bekommt man ohne Browser bequem per REST:

curl -s "https://bugs.freebsd.org/bugzilla/rest/bug/295991"

Upstream pinnt die Ursache

Jetzt kam der Teil, für den sich die Mühe des sauberen Reports gelohnt hat. @bdrewery, FreeBSD-Committer, bestätigte und reproduzierte den Fehler noch bequemer als ich, direkt über den www/nextcloud-Port mit occ status in einer Schleife:

there is some random corruption that shows up with php on exit when loaded with many extensions. Raising the stack size in posix_spawn avoids the problem.

Zur Ehrlichkeit gehört der Seitenhieb, den ich mir dabei eingefangen habe: den Text meines Reports nannte er einen unreadable AI mess. Inhaltlich hat er den Fall getroffen, die Form hat genervt. Das war eine gute und verdiente Lektion über Report-Stil, auf die ich am Ende noch einmal zurückkomme.

@kevans hat den Mechanismus dann endgültig festgenagelt, und zwar an einer Stelle, an der ich nur einen Verdacht hatte. Nicht execvPe sprengt den Stack, sondern der Runtime-Linker beim Lazy-Binding der Symbole. Der Pfad ist _rtld_bindfind_symdefsymlook_defaultdonelist_init. Und donelist_init macht ein alloca, dessen Größe mit der Zahl der geladenen Shared Objects skaliert:

#define donelist_init(dlp) ((dlp)->objs = alloca(obj_count * sizeof(dlp)->objs[0]), assert((dlp)->objs != NULL), (dlp)->num_alloc = obj_count, (dlp)->num_used = 0)

Genau deshalb triggern schwer gelinkte Prozesse den Fehler und Spielzeug-Programme nicht. obj_count ist bei PHP mit dem vollen Extension-Satz groß, das alloca entsprechend fett, und auf dem gut 4 KB kleinen Spawn-Stack ist dann Schluss. Das deckt sich exakt mit meiner rtld-Vermutung aus dem Report und erklärt auch das 17-Extensions-Minimum: unter einer gewissen Zahl geladener Objekte bleibt das alloca klein genug.

Der Fix

Der Fix kam von @kib als Diff D57908. Die erste Revision regressierte und ließ eine www/onlyoffice-Umgebung crashen, mit ld-elf.so.1-Faults in beam.smp und x2t. Das war ein Multithreading-Problem, das kib noch vor dem Commit behoben hat. Danach ging es nach main:

  • 1e370f0 „rtld: stop using unbound alloca()“ vom 29. Juni 2026. Die alloca-Aufrufe in der DoneList und in map_object wandern in den Heap, sobald sie groß werden. Vermerk MFC after: 1 week.
  • 3de9dc5 vom 30. Juni 2026. Ein libc-Regressionstest, der eine Dummy-Shared-Library mehrfach mappt und mit einer Guard-Page arbeitet, um den Underflow zuverlässig zu triggern.

Beim Schreiben dieses Beitrags steht der MFC nach stable/15 an. Für ein 15.1-RELEASE kommt der Fix mit einem der künftigen 15.x-Patches. Bis dahin ist der Workaround simpel: absolute Pfade in proc_open vermeiden den crashenden no-slash-Zweig. Das ist Symptombekämpfung, kein Fix. Und wer nur das volllaufende Dateisystem im Blick hat, räumt die harmlosen Cores einfach weg.

Warum am Ende alles zusammenpasst

Das Schöne an der Auflösung ist, dass sie jedes einzelne der vielen Rätsel erklärt, die mich wochenlang in die Irre geführt haben:

  • Nur proc_open crasht, weil es das einzige PHP-Konstrukt ist, das posix_spawnp nutzt.
  • Nur relative Kommandos crashen, weil nur sie die PATH-Suche und damit das Lazy-Binding im Kind auslösen.
  • Nur FreeBSD auf amd64, weil der rfork_thread-Pfad mit dem kleinen malloc-Stack amd64- und i386-spezifisch ist.
  • ASAN und Valgrind sauber auf Linux, weil glibc posix_spawn ganz anders baut.
  • Der volle Extension-Satz nötig, weil viele Shared Objects das alloca im rtld erst groß genug für den Überlauf machen. Und die vielen permanenten interned Strings legen zusätzlich die späteren Opfer genau unter den Spawn-Puffer.

Zur Methode, und zum Report-Stil

Zwei Dinge nehme ich technisch mit. Erstens: eine Heap-Korruption mit wanderndem Opfer fängt man nicht mit einzelnen Watchpoints, weil das Beobachten das Layout verschiebt und damit das Opfer. Was funktioniert, sind layout-robuste Tabellen-Diffs an definierten Checkpoints. Nicht ein Objekt anstarren, sondern die ganze Region vorher und nachher vergleichen. Zweitens: ein LD_PRELOAD-Interposer mit Guard-Page ist ein billiges, definitives Ja-oder-Nein-Experiment für die Frage, ob ein Stack-Overflow vorliegt. Ein sauberes Experiment schlägt zehn plausible Theorien.

Und dann die Lektion, die mir @bdrewery verpasst hat. Ein Bugreport, der die ganze Hypothesenkette in den Body kippt, ist für den Leser eine Zumutung, egal wie korrekt die Analyse ist. Die richtige Form sind drei bis vier Sätze Kern ganz oben, das reproduzierbare Minimal-Beispiel gleich dahinter, und der ganze Ermittlungskrimi darunter für die, die ihn brauchen. Der Inhalt hat gestimmt, deshalb wurde der Bug gefixt. Aber die Form hätte den Committern viel Zeit gespart. Nächstes Mal Kern zuerst.

Ähnliche Geschichte im Notebook, im Basissystem festgefahren, oder einfach eine Meinung zum Report-Stil? Dann einfach fragen.

Fediverse-Reaktionen