IT security, FreeBSD, Linux, mail server hardening, post-quantum crypto, DNS, retro computing & hands-on hardware hacks. Privater Tech-Blog seit 2003.

Schlagwort: Debugging

QUIC auf Dual-Stack-Sockets: das DF-Bit, das nginx für IPv4-Clients nie setzt

nginx auf FreeBSD mit QUIC/HTTP/3: IPv6 ohne Router-Fragmentierung und IPv4 mit fehlendem DF-Bit auf einem Dual-Stack-Socket.

Ein Listener für IPv4 und IPv6 zusammen klingt nach der besseren Lösung. Ein Socket statt zwei, eine Konfigurationszeile weniger, ggf. ein Firewall-Regelwerk weniger zu pflegen. Bei QUIC auf diesem Blog lief das genau so, seit hier HTTP/3 aktiviert ist. Am 9. September 2026 ist mir aufgefallen, dass genau dieser eine Socket seit längerem dafür sorgt, dass nginx für die Hälfte der Clients ein Bit im IP-Header nie setzt, das QUIC eigentlich zwingend braucht. Kein Absturz, keine Fehlermeldung, kein Eintrag im Error-Log. Nur ein messbar höherer Anteil langsamer Verbindungen, der sich erst beim genauen Hinsehen als das entpuppt, was er ist.

Dieser Beitrag ist eine klassische Root-Cause-Analyse. Bug gefunden, Ursache im nginx-Quellcode verifiziert, Ursache zusätzlich im FreeBSD-Kernel nachvollzogen, gefixt, mit echten Produktionsdaten so gut wie möglich belegt.

Ein Socket für beides, das ist doch die naheliegende Wahl

Wer nginx mit QUIC betreibt, kennt die Direktive üblicherweise so:

listen [::]:443 quic reuseport ipv6only=off default_server;

Ein einziger IPv6-Socket mit ipv6only=off nimmt sowohl echte IPv6-Verbindungen als auch IPv4-Verbindungen an, dank IPv4-mapped-IPv6-Adressen (::ffff:a.b.c.d) nach RFC 3493. Ein Socket statt zwei, eine Zeile Konfiguration weniger. Genau deshalb ist das die naheliegende Wahl, wenn man von TCP kommt, wo dieser Trick seit Jahrzehnten problemlos funktioniert.

Bei QUIC ist genau dieser eine Socket ein Problem, und zwar eines, das man im laufenden Betrieb nicht sieht. Zumindest nicht sofort und nicht überall. Es müssen schon ein paar Dinge zusammen kommen um das Problem selbst zu fühlen. Fühlen? Eine lllaaaaannnngggggssssaaaaammmmeeeee Verbindung über IPv4.

Warum QUIC bei Fragmentierung empfindlicher ist als TCP über TLS

RFC 9000, Abschnitt 14, formuliert das ziemlich hart:

UDP datagrams MUST NOT be fragmented at the IP layer. In IPv4, the Don’t Fragment (DF) bit MUST be set if possible, to prevent fragmentation on the path.

Das ist keine Empfehlung, das ist ein MUST NOT direkt neben einem MUST. QUIC ermittelt seine eigene passende Paketgröße für einen Netzwerkpfad, entweder über klassisches PMTUD (das auf ICMP-Antworten angewiesen ist und auf der IP-Ebene ansetzt, das DF-Bit ist Teil genau dieses Mechanismus) oder über DPLPMTUD nach RFC 8899, das mit eigenen Sondierungspaketen arbeitet und ganz ohne ICMP auskommt. Wichtig ist hier nicht, welcher der beiden Mechanismen die Größe ermittelt, sondern was beide voraussetzen: ein zu großes Paket muss sauber verworfen werden, mit oder ohne ICMP-Antwort, statt fragmentiert beim Empfänger anzukommen. Wer sich jetzt an ICMPv6 für IPv6 erinnert fühlt, japp 🙂

Genau das geht in vielen Netzen schief, sobald ein Paket doch fragmentiert wird. Fragment-Filterung ist eine verbreitete Firmen- und Provider-Härtung, gerade weil fragmentierte Pakete historisch für allerlei Unfug missbraucht wurden. Solche Netze werfen fragmentierte QUIC-Pakete einfach weg, ohne jede Rückmeldung. QUICs eigene Verlusterkennung kann das von gewöhnlichem Paketverlust nicht unterscheiden, im schlechtesten Fall wird daraus eine Serie von Retransmissions und Timeouts statt eines schnellen, sauberen Fehlers. Bei TCP über TLS würde ein fragmentiertes Paket in den allermeisten Netzen einfach ankommen und zusammengesetzt werden, das ist dort seit jeher normaler Betrieb und niemanden juckt das DF-Bit besonders. Bei QUIC ist Fragmentierung der Fall, den das Protokoll bewusst ausschließen will, und genau der tritt hier auf.

Die Ursache, gefunden im nginx-Quellcode

Der Fund steckt in src/core/ngx_connection.c, in der Funktion ngx_configure_listening_sockets() (nicht zu verwechseln mit ngx_open_listening_sockets(), einer anderen Funktion in derselben Datei, die die Sockets öffnet, aber nicht die MTU-Discovery-Optionen setzt). Diese Funktion läuft beim Start und bei jedem Restart über alle bereits gebundenen Listen-Sockets und setzt dort die passenden Socket-Optionen. Der entscheidende Ausschnitt, wörtlich aus nginx 1.30.4:

#if (NGX_HAVE_IP_MTU_DISCOVER)

        if (ls[i].quic && ls[i].sockaddr->sa_family == AF_INET) {
            value = IP_PMTUDISC_DO;

            if (setsockopt(ls[i].fd, IPPROTO_IP, IP_MTU_DISCOVER,
                           (const void *) &value, sizeof(int))
                == -1)
            {
                ngx_log_error(NGX_LOG_ALERT, cycle->log, ngx_socket_errno,
                              "setsockopt(IP_MTU_DISCOVER) "
                              "for %V failed, ignored",
                              &ls[i].addr_text);
            }
        }

#elif (NGX_HAVE_IP_DONTFRAG)

        if (ls[i].quic && ls[i].sockaddr->sa_family == AF_INET) {
            value = 1;

            if (setsockopt(ls[i].fd, IPPROTO_IP, IP_DONTFRAG,
                           (const void *) &value, sizeof(int))
                == -1)
            {
                ngx_log_error(NGX_LOG_ALERT, cycle->log, ngx_socket_errno,
                              "setsockopt(IP_DONTFRAG) "
                              "for %V failed, ignored",
                              &ls[i].addr_text);
            }
        }

#endif

#if (NGX_HAVE_INET6)

#if (NGX_HAVE_IPV6_MTU_DISCOVER)

        if (ls[i].quic && ls[i].sockaddr->sa_family == AF_INET6) {
            value = IPV6_PMTUDISC_DO;

            if (setsockopt(ls[i].fd, IPPROTO_IPV6, IPV6_MTU_DISCOVER,
                           (const void *) &value, sizeof(int))
                == -1)
            {
                ngx_log_error(NGX_LOG_ALERT, cycle->log, ngx_socket_errno,
                              "setsockopt(IPV6_MTU_DISCOVER) "
                              "for %V failed, ignored",
                              &ls[i].addr_text);
            }
        }

#elif (NGX_HAVE_IP_DONTFRAG)

        if (ls[i].quic && ls[i].sockaddr->sa_family == AF_INET6) {
            value = 1;

            if (setsockopt(ls[i].fd, IPPROTO_IPV6, IPV6_DONTFRAG,
                           (const void *) &value, sizeof(int))
                == -1)
            {
                ngx_log_error(NGX_LOG_ALERT, cycle->log, ngx_socket_errno,
                              "setsockopt(IPV6_DONTFRAG) "
                              "for %V failed, ignored",
                              &ls[i].addr_text);
            }
        }

#endif
#endif

Beide Zweige prüfen ls[i].sockaddr->sa_family, also die Adressfamilie des Listen-Sockets selbst, fest zum Zeitpunkt des Socket-Setups. Nicht die Familie des Ziels, an das ein bestimmtes Paket später geht. Für listen [::]:443 quic ... ipv6only=off; ist sa_family immer AF_INET6, egal ob ein konkretes Paket an einen echten IPv6-Host oder an eine IPv4-mapped-Adresse geht. Der AF_INET-Zweig, der IP_DONTFRAG fürs echte IPv4-Datagramm setzen würde, wird für diesen Socket schlicht nie erreicht.

Ein kleines Detail für alle, die selbst nachlesen: der IPv6-Fallback-Zweig (das #elif (NGX_HAVE_IP_DONTFRAG) im letzten Block) ist mit demselben Feature-Makro abgesichert wie der IPv4-Zweig, nicht mit dem separat getesteten NGX_HAVE_IPV6_DONTFRAG. Auf FreeBSD ändert das am Ergebnis nichts, hier sind beide Makros gesetzt, aber es ist eine kleine Ungenauigkeit im nginx-Quellcode selbst. So zumindest meine Interpretation, korrigiert mich gerne!

Der eigentliche Denkfehler dahinter: IPv6 hat gar kein DF-Bit

Wer IPV6_DONTFRAG im Code sieht, könnte annehmen, dass damit dasselbe Problem für IPv6 gelöst wird wie IP_DONTFRAG für IPv4. Ist es nicht, und der Unterschied ist der eigentliche Kern dieses Bugs.

Der IPv6-Header hat schlicht kein DF-Bit, weil er keines braucht. RFC 8200 verbietet Routern im Pfad die Fragmentierung von IPv6-Paketen grundsätzlich, das steht wörtlich in Abschnitt 4.5 (der auf Abschnitt 5 verweist): „unlike IPv4, fragmentation in IPv6 is performed only by source nodes, not by routers along a packet’s delivery path“. IPV6_DONTFRAG ist die tatsächliche RFC-3542-Option, Abschnitt 11.2 der Spezifikation, aber sie steuert nur, ob der sendende Host selbst über einen Fragment-Extension-Header fragmentieren darf. Ein Problem, das aus Sicht von IPv6 gar nicht existiert, sobald man es nicht selbst provoziert.

IP_DONTFRAG auf der IPv4-Seite ist dagegen keine RFC-3542-Option, sondern eine BSD-Socket-Erweiterung außerhalb dieses Standards. Zwei Optionen, die im Code wie ein symmetrisches Paar aussehen, lösen in Wirklichkeit zwei völlig verschiedene Probleme.

Und jetzt der Punkt, an dem beide Welten aufeinandertreffen. Ein send() auf diesem IPv6-Socket an eine IPv4-mapped-Zieladresse bringt am Ende ein echtes IPv4-Datagramm auf die Leitung, das ist Standardverhalten von Dual-Stack-Sockets nach RFC 3493. Dieses Datagramm braucht ein gesetztes DF-Bit im IPv4-Header, ein komplett anderes Feld als alles, was IPV6_DONTFRAG je anfasst. Der oben gezeigte Code-Pfad kommt an dieses Feld nie heran, weil er für einen AF_INET6-Socket ausschließlich die IPv6-Variante der Option setzt. Kann man das also auch irgendwie ein legacy IPv4 Problem nennen?

Warum ein einfacher Zusatz-Aufruf auf FreeBSD gar nicht helfen würde

Die naheliegende Reaktion: warum ruft nginx auf diesem Socket nicht einfach zusätzlich setsockopt(IPPROTO_IP, IP_DONTFRAG) auf? Ich bin dem im FreeBSD-Kernel-Quellcode nachgegangen, und die Antwort ist, dass das auf FreeBSD gar nicht funktionieren würde.

Der Options-Handler für einen AF_INET6-Socket ist ip6_ctloutput() in sys/netinet6/ip6_output.c, und der lehnt Optionen außerhalb von IPPROTO_IPV6 rundweg ab:

level = sopt->sopt_level;
...
if (level != IPPROTO_IPV6) {
    error = EINVAL;
    ...
}

Ein setsockopt(fd, IPPROTO_IP, IP_DONTFRAG, ...) auf einem AF_INET6-Socket landet also mit EINVAL im Nichts. Das allein wäre schon Antwort genug, aber der zweite Teil ist genauso wichtig: wo genau setzt der Kernel das DF-Bit für einen IPv4-mapped-Send, der über einen IPv6-Socket rausgeht? In udp_usrreq.c, in der Funktion, die das eigentliche IPv4-UDP-Paket zusammenbaut:

if (inp->inp_flags & INP_DONTFRAG)
    ((struct ip *)ui)->ip_off |= htons(IP_DF);

Das DF-Bit wird nur gesetzt, wenn am PCB (dem Protocol Control Block der Verbindung) das Flag INP_DONTFRAG hängt. Und dieses Flag setzt ausschließlich ip_ctloutput() in ip_output.c, wenn IP_DONTFRAG auf IPPROTO_IP-Ebene erfolgreich gesetzt wurde, also genau der Aufruf, der auf einem AF_INET6-Socket mit EINVAL scheitert. Der IPv4-mapped-Sendepfad selbst führt übrigens tatsächlich über genau diese Funktion: udp6_send() in udp6_usrreq.c erkennt eine v4-mapped-Zieladresse auf einem Dual-Stack-Socket, gibt die eigenen Locks frei und ruft direkt udp_send(), also dieselbe IPv4-UDP-Ausgabefunktion, die auch ein echter AF_INET-Socket benutzt.

Damit schließt sich der Kreis / Beisst die Katze sich in den Schwanz, ihr wisst schon. INP_DONTFRAG kann nur über einen erfolgreichen IP_DONTFRAG-Aufruf auf einem echten AF_INET-Socket gesetzt werden, und ein AF_INET6-Socket kann diesen Aufruf laut ip6_ctloutput() gar nicht erst absetzen. Der Socket-Split ist auf FreeBSD damit nicht nur der pragmatische, sondern der einzig mögliche Fix. Ein hypothetischer nginx-Patch, der einfach den fehlenden setsockopt-Aufruf ergänzt, würde am Kernel selbst scheitern, bevor er überhaupt etwas bewirkt.

Was sich zwischen FreeBSD und Linux unterscheidet, und was nicht

Der Bug, wenn ich das so nennen darf, selbst sitzt im nginx-Quellcode und ist plattformunabhängig. Dieselbe sa_family-Abfrage wird auf Linux und FreeBSD gleich kompiliert und ist auf beiden gleichermaßen falsch.

Was sich unterscheidet, ist welche Socket-Option-API überhaupt zur Verfügung steht. IP_MTU_DISCOVER mit dem Wert IP_PMTUDISC_DO ist eine Linux-Erweiterung (auch musl bringt das Makro mit, nicht nur glibc), auf FreeBSD schlicht nicht deklariert. FreeBSD bietet stattdessen IP_DONTFRAG und IPV6_DONTFRAG. nginx testet im configure-Lauf beide Varianten (auto/unix, Feature-Tests für NGX_HAVE_IP_MTU_DISCOVER, NGX_HAVE_IP_DONTFRAG, NGX_HAVE_IPV6_DONTFRAG) und wählt zur Kompilierzeit die passende Variante. Auf dieser FreeBSD-Installation landet man im IP_DONTFRAG/IPV6_DONTFRAG-Zweig, und genau der ist von dem oben beschriebenen Bug betroffen.

Was ich nicht verifiziert habe, weil mir dafür ein Linux-Vergleichssystem fehlt (ich zu faul war eines aus dem Glas zu ziehen): ob sich das praktische Fehlerbild zwischen den beiden Kerneln tatsächlich unterscheidet, also wie stark Retransmissions ausfallen oder wie der jeweilige Kernel beim v4-mapped-Sendepfad mit dem DF-Äquivalent umgeht. Linux‘ PMTUD-Verhalten für genau diesen Fall könnte sich abweichend verhalten. Ehrliches Fazit an dieser Stelle: der nginx-Bug ist universell im Quellcode, das konkret beobachtete Fehlerbild ist hier ausschließlich für FreeBSD dokumentiert. Eine Aussage wie „genauso kaputt auf Linux“ würde ich ohne echten Beleg, also Paketmitschnitt oder Kernel-Quellcode-Analyse auf einem Linux-System, nicht treffen. Vielleicht mache ich mir die Mühe noch…. Vielleicht 😀

Der Fix: zwei Listener statt einem

Die Lösung ist, den einen Dual-Stack-Listener in zwei eigenständige Listener zu splitten, damit IPv4-Clients einen echten AF_INET-Socket bekommen, für den der obere Codepfad tatsächlich greift:

listen [::]:443 quic reuseport ipv6only=on default_server;
listen 443 quic reuseport default_server;

Ein Stolperstein dabei, den man sich leicht selbst stellt: ein einfaches zusätzliches listen 443 quic; neben einem unveränderten Dual-Stack-Socket kollidiert mit „Address already in use“, weil der alte Socket die IPv4-Wildcard-Adresse über ipv6only=off ja schon abdeckt. ipv6only muss auf dem IPv6-Listener also explizit auf on gesetzt werden, sonst gibt es keinen sauberen Split, sondern nur einen Fehlstart. Je nachdem, wie „ordentlich“ seine Konfiguration ist, kann es einen Moment dauern, alle diese Stellen zu finden, denn das muss für die komplette nginx Konfiguration angepasst sein!

Erster Stolperstein: ein reload reicht nicht

Eine reine ipv6only-Änderung an einem bestehenden QUIC-Socket erkennt nginx‘ Socket-Reuse-Logik bei einem reload nicht als neuen Socket. Bei mir blieb der alte Dual-Stack-Socket nach einem reinen reload laut sockstat weiter als udp46 aktiv, trotz fehlerfrei geprüfter Konfiguration (nginx -t) und trotz eines nginx -T-Dumps, der die neue Konfiguration bereits korrekt anzeigte. Ist bei solchen Dingen hin und wieder so, machen andere Dienste ja auch. Große Dinge wie Socket, wird oft nur bei einem echten Neustart vom Deinst gesetzt.

nginx -t prüft Syntax und versucht, referenzierte Dateien zu öffnen. Es sagt nichts darüber aus, ob ein bereits offener, laufender Listen-Socket bei einem Reload tatsächlich neu gebunden wird. Erst ein vollständiger service nginx restart hat die getrennten Sockets tatsächlich neu gebunden:

sockstat -4 -l | grep 443   # nur udp4
sockstat -6 -l | grep 443   # nur udp6

Nach dem Restart tauchte in der PROTO-Spalte ausschließlich udp4 und udp6 auf, kein einziges udp46 mehr auf Port 443. Das ist der eigentliche Beleg für den Reload-Stolperstein, keine Datei-Deskriptor-Analyse, nur der Blick auf einen laufenden Prozess. Das reiht sich in ein Muster ein, das mir auf dieser Infrastruktur schon öfter untergekommen ist: ein neues geladenes Modul, ein neuer server_name in einer bestehenden reuseport-Gruppe, jetzt ein ipv6only-Wechsel. Immer wieder dasselbe Grundmuster, ein Reload reicht bei bestimmten Änderungen an Listen-Sockets schlicht nicht. Aber ich wiederhole mich, hm?

Zweiter Stolperstein: jeder vHost braucht den Split

Der erste Durchlauf hat nur den Default-Server-Socket gesplittet. Alle anderen vHosts mit eigenem listen [::]:443 quic;, aber ohne eigenes listen 443 quic;, hingen danach nur noch am neuen IPv6-only-Socket. IPv4-QUIC-Verbindungen zu diesen Domains landeten beim Default-vHost, weil die SNI-Zuordnung auf dem neuen IPv4-Socket schlicht ins Leere lief. Sichtbar wurde das am falschen ausgelieferten TLS-Zertifikat und an einer Default-Antwort statt der eigentlich erwarteten.

Die Lehre daraus: bei einem reuseport-Split müssen alle Server-Blöcke der Adressgruppe angepasst werden, nicht nur der Default-Server. Der IPv4-Listener eines jeden weiteren vHosts docken danach ohne reuseport und ohne default_server einfach an die schon bestehende Gruppe an:

listen [::]:443 quic;
listen 443 quic;

Was im Error-Log nicht zu finden ist

Das nginx-Error-Log auf Level info, die komplette 15-Tage-Rotation samt .bz2-Archiven, enthält keine einzige [warn], [error] oder [crit]-Zeile mit QUIC-Bezug, weder vor noch nach dem Fix. Eine gezielte Suche nach den vier exakten Alert-Meldungen, die der oben zitierte Code beim Fehlschlagen eines setsockopt-Aufrufs loggen würde, kommt in der gesamten Historie null Mal vor. Vielleicht sollte ich das noch im Logging aufbohren, wenn möglich?!

Das ist methodisch wichtig: diese Alert-Meldungen enthalten das Wort „quic“ gar nicht. Ein reines grep -i quic im Error-Log hätte einen echten Fehlschlag dieser Aufrufe also gar nicht gefunden, selbst wenn es einen gegeben hätte. Erst die gezielte Suche nach dem exakten Meldungstext schließt aus, dass die Socket-Optionen fehlgeschlagen sind. Sie wurden erfolgreich gesetzt, nur eben die falsche Option für die falsche Adressfamilie. Kein Aufruf-Fehler, sondern genau der beschriebene Bug.

Warum das Error-Log strukturell nicht mehr hergibt: nginx sendet das UDP-Datagramm erfolgreich an den Kernel, die Fragmentierung passiert danach im Netz, an einem Router oder einer Middlebox, oder schon beim sendenden Host selbst, falls das ausgehende Interface-MTU überschritten wird. Davon bekommt der laufende nginx-Prozess in keinem Fall etwas mit. Wer nach diesem Bug im Error-Log sucht, wird nichts finden. Das ist erwartbar, nicht beruhigend, und ein blindes grep quic hätte selbst einen echten Konfigurationsfehler an dieser Stelle übersehen. Genau das ist der Punkt. Man sieht es am Server fast nur, wenn man auch einen tcpdump macht und auswertet. Denn die UDP Pakete gehen ja auf die Reise, sind sie nicht als „unzerteilbar“ markiert, kann und wird auf dem Weg aber bestimmt irgendein Router oder eine Firewall anfangen die Pakete klein zu hacken und dann ist es „kaputt“.

Verifikation über das Access-Log, und ihre Grenzen

nginx zeigt IPv4-Clients, die über einen Dual-Stack-Socket hereinkommen, im Access-Log als IPv4-mapped-Adresse (::ffff:a.b.c.d). Nach dem Fix kommen IPv4-QUIC-Clients über einen echten AF_INET-Socket herein und erscheinen als reine IPv4-Adresse ohne dieses Präfix. Diese Darstellung allein beweist schon, durch welchen Socket eine Verbindung lief, unabhängig von jeder Performance-Messung, aber eben auch unabhängig von jeder direkten Fragmentierungs-Messung.

Ausgewertet habe ich das Access-Log des Blogs (Format goa_ext, enthält $request_time), 13 Tage vor dem Fix plus den laufenden Tag danach, alle HTTP/3.0-Requests, aufgeteilt nach Adressdarstellung:

ZeitraumRequestsØ request_timeMaxAnteil über 1 s
vorher, IPv4 über kaputten Dual-Stack-Socket12.5370,461 s130,3 s5,33 %
nachher, IPv4 über eigenen udp4-Socket9460,037 s1,4 s0,42 %
Kontrollgruppe, natives IPv6, vorher17.3200,113 s67,3 s1,86 %
Kontrollgruppe, natives IPv6, nachher1.1680,082 s5,7 s2,14 %

Die betroffene IPv4-Gruppe lag vorher bei 5,33 Prozent über einer Sekunde, fast das Dreifache der IPv6-Baseline von 1,86 Prozent, und fällt nach dem Fix auf 0,42 Prozent, unter die IPv6-Baseline von 2,14 Prozent im selben Zeitraum. Genau dieser Kontrollgruppen-Vergleich macht die Zahlen für mich aussagekräftig, als Korrelation, die zur quellcode-basierten Root-Cause-Analyse passt, nicht als eigenständigen Fragmentierungsbeweis.

Eine zeitliche Punktprobe passt ins Bild: Restart am 9. September 2026 um 18:38:31 Uhr, letzter Request über den kaputten Dual-Stack-Pfad um 17:56:46 Uhr, erster Request über den neuen Pfad um 18:41:36 Uhr. Seit dem Restart lief keiner der 946 IPv4-HTTP/3.0-Requests mehr über die alte Darstellung. Das liegt plausibel am Restart-Zeitpunkt, mit einer Lücke ohne Traffic dieser Art von 42 Minuten davor und 3 Minuten danach bis zum ersten neuen Request, aber es ist kein exakter Sekundentreffer und ich beschreibe es auch nicht so.

Genauso wichtig wie die Zahlen sind für mich ihre Grenzen, deshalb ein eigener Absatz dafür statt eines Nebensatzes:

  • Ein fehlendes DF-Bit erlaubt Fragmentierung, es erzwingt sie nicht. Ein Datagramm, das auf jedem Hop unter dem dortigen MTU bleibt, ist unabhängig vom DF-Bit unproblematisch. Das kann zu Fragmentierung führen, es führt nicht automatisch dazu.
  • Die Adressdarstellung im Access-Log beweist, welchen Socket nginx verwendet hat. Sie sagt nichts über den DF-Bit-Zustand, die tatsächliche Paketgröße, die reale PMTU oder echte Fragmente auf der Leitung aus. Das gilt hier ohne Einschränkung, weil dieser vHost direkt am Internet hängt, ohne vorgeschaltete Proxy- oder Load-Balancer-Schicht, die $remote_addr umschreiben könnte.
  • Der Vergleich von 13 Tagen vorher gegen einen Teil eines Tages nachher ist kein kontrollierter Leistungsvergleich. Trafficmix, Client-Geografie, Cache-Zustand, Backend-Last und Tageszeit unterscheiden sich zwangsläufig. Die Zahlen sind ein starkes Indiz, kein experimenteller Beweis.
  • Die IPv6-Kontrollgruppe ist nicht vollständig unverändert geblieben. Nur der Anteil über einer Sekunde bleibt in vergleichbarer Größenordnung. Mittelwert und Maximum verschieben sich durchaus, vermutlich schlicht durch allgemein geringere Last oder einen anderen Trafficmix zum jeweiligen Zeitpunkt.
  • Eine Retransmission-Spirale statt eines schnellen Fails ist ein plausibles, aber kein garantiertes Symptom. Wie sich ein verlorenes, fragmentiertes QUIC-Paket konkret auswirkt, hängt von der Verlusterkennung und der PMTU-Implementierung der beteiligten QUIC-Stacks ab, auf Client- wie auf Serverseite.
  • Ein wirklich abschließender Beweis bräuchte einen Vorher- und Nachher-Paketmitschnitt, der das DF-Bit direkt zeigt, im Idealfall mit einem kontrollierten Test gegen ein reduziertes MTU. Ohne den betrachte ich die Produktionszahlen als starke, durch eine Kontrollgruppe abgesicherte Korrelation, die zur quellcode-basierten Root-Cause-Analyse passt, nicht als direkten Fragmentierungsbeweis. Ein Mitschnitt von der Vorher-Seite liegt inzwischen vor, dazu der nächste Abschnitt.

Ein Paketmitschnitt vom Tag vor dem Fix schließt einen Teil der Lücke

Nachtrag vom 12. September 2026: Der vorherige Abschnitt musste ohne einen echten Paketmitschnitt auskommen. Der liegt inzwischen vor. Zwei rund 41 Sekunden lange Aufzeichnungen vom 8. September 2026, einen Tag vor dem Fix, eine direkt auf dem Server, die andere gleichzeitig auf einem Notebook, das die Seite über ein anderes Netz aufgerufen hat. Beide zusammen zeigen den echten HTTP/3-Verkehr eines einzelnen Seitenaufrufs.

Ausgewertet mit tcpdump -r <Datei> -n -v, ein tshark stand mir nicht zur Verfügung. Jedes Paket trägt im IP-Header ein Identification-Feld, und das ist in beiden Mitschnitten innerhalb des Beobachtungsfensters durchgehend eindeutig, keine einzige Dopplung unter den mehr als dreitausend vom Server verschickten Paketen. Darüber lassen sich einzelne Datagramme zwischen den beiden Aufzeichnungen zweifelsfrei einander zuordnen, nicht nur über Summen vergleichen.

RichtungBeim Server verschicktBeim Notebook angekommenAbgleich
Notebook zu Server1.147, alle mit DF-Bit1.147, alle mit DF-Bitidentisch, per ID geprüft
Server zu Notebook, klein genug fürs MTU898, kein DF-Bit898, dieselben IDs0 Prozent Verlust
Server zu Notebook, fragmentiert, kein DF-Bit2.162 erste Fragmente878, per ID zugeordnet1.284 fehlen, 59,4 Prozent

Kein einziges der 1.147 Pakete vom Notebook zum Server fehlt das DF-Bit, kein einziges der 3.060 Pakete in der Gegenrichtung trägt eines. Das bestätigt den Fund aus dem Quellcode jetzt direkt auf der Leitung, nicht nur über die Adressdarstellung im Access-Log. 2.162 dieser Server-Pakete sind schon beim Verlassen des Servers fragmentiert, jedes erste Fragment exakt 1500 Byte lang, also an der Ethernet-MTU gekappt. Von diesen 2.162 erscheinen im Notebook-Mitschnitt nur 878 wieder, eins zu eins über die ID zugeordnet. 1.284 fehlen komplett, das sind 59,4 Prozent. Die 898 kleinen, unfragmentierten Antworten kommen dagegen alle an, ebenfalls per ID geprüft und nicht bloß über die Summe verglichen. Über die ganzen 41 Sekunden verteilt liegt der fehlende Anteil in jedem Fünf-Sekunden-Fenster zwischen 51 und 68 Prozent. Kein Ausreißer am Anfang oder Ende der Aufzeichnung, der auf einen Zeitversatz zwischen den beiden Mitschnitten hindeuten würde.

Die zweite Hälfte jedes fragmentierten Datagramms taucht in keinem der beiden Mitschnitte auf. Vermutlich wurde mit einem Filter auf den UDP-Port aufgezeichnet, und ein IP-Fragment ohne den ersten Teil trägt keinen UDP-Header, an dem so ein Filter ansetzen könnte. Das lässt offen, ob einzelne der 878 angekommenen ersten Fragmente trotzdem nie vollständig zusammengesetzt werden konnten, weil ihre zweite Hälfte fehlte. 59,4 Prozent sind deshalb eine gemessene Untergrenze für den tatsächlichen Verlust, keine Obergrenze. Und es bleibt eine einzelne Sitzung, ein Client, ein Pfad, eine Zeitspanne von 41 Sekunden, kein Beleg für eine feste Verlustquote im ganzen Netz.

So sieht der Unterschied im Rohformat aus, Adressen durch Platzhalter ersetzt:

12:01:29.758438 IP (id 0, offset 0, flags [DF], length 1280)
    CLIENT.35577 > SERVER.443: UDP, length 1252
12:01:29.771757 IP (id 51997, offset 0, flags [+], length 1500)
    SERVER.443 > CLIENT.35577: UDP, length 1480

Dieser Mitschnitt stammt vom Tag vor dem Fix, ein gepaarter Mitschnitt von danach existiert aus dem echten Betrieb nicht. Der Versuch direkt nach dem Fix blieb leer, weil der dafür verwendete Client kein echtes HTTP/3 sprach und der Mitschnitt dadurch keinen QUIC-Verkehr enthielt. Die Vorher-Nachher-Zahlen aus dem Access-Log oben bleiben deshalb weiterhin die Grundlage für den gemessenen Effekt des Fixes. Was dieser Mitschnitt zusätzlich liefert, ist der direkte Beleg für den Mechanismus dahinter: das fehlende DF-Bit und die Fragmentierung selbst sind jetzt nicht mehr nur aus dem Quellcode abgeleitet, sondern auf der Leitung beobachtet.

Fazit

Ein einzelner Dual-Stack-Socket für QUIC ist offenbar kein Einzelfall, sondern eher ein wiederkehrendes Footgun-Muster über verschiedene Implementierungen hinweg. quic-go, die von Marten Seemann entwickelte QUIC-Bibliothek, musste DPLPMTUD auf älteren macOS-Versionen abschalten, weil sich das DF-Bit auf einem Dual-Stack-Socket dort schlicht nicht setzen ließ, und dokumentiert das offen. Ein separates Issue im selben Projekt beschreibt ein anderes, aber verwandtes Dual-Stack-Problem gerade auf FreeBSD (fehlgeschlagenes sendmsg für IPv4-mapped-Adressen), mit einem Verweis auf FreeBSDs eigene inet6(4)-Manpage, die für AF_INET6-Sockets ganz grundsätzlich empfiehlt, zwei getrennte Sockets zu verwenden, aus Sicherheitsgründen, unabhängig von QUIC. Die Empfehlung, IPv4 und IPv6 getrennt zu halten, existierte auf dieser Plattform also schon, bevor QUIC überhaupt erfunden wurde. Für den konkreten Fall hier heißt das, dass der Socket-Split kein Workaround für einen einzelnen nginx-Bug ist, sondern die Rückkehr zu der Architektur, die FreeBSD für genau diese Situation ohnehin vorsieht.

Ein Socket für beides bleibt bei TCP über TLS ein legitimer, funktionierender Kompromiss. Bei QUIC ist er, zumindest auf dieser Kombination aus nginx und FreeBSD, ein stiller Bug, der sich weder im Error-Log noch im laufenden Betrieb meldet, sondern nur über einen erhöhten Anteil langsamer Verbindungen bemerkbar macht. Wer selbst QUIC auf einem Dual-Stack-Listener betreibt, findet die Antwort auf die Frage, welchen Socket ein Client gerade benutzt, mit dem Access-Log-Trick oben in wenigen Minuten selbst heraus.

Siehe auch:

Fragen, Einwände oder eigene Erfahrungen mit QUIC auf Dual-Stack-Sockets? Dann darfst du mich sehr gerne fragen.

Wenn PHP beim Aufräumen stirbt: ein FreeBSD-rtld-Bug hinter posix_spawn

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_spawnp → execvPe 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_bind → find_symdef → symlook_default → donelist_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.

© 2026 -=Kernel-Error=- — RSS

Theme von Anders Norén — Hoch ↑