Fang mit dem Patch an, der nicht funktioniert hat. In GiveWP 4.16.6 hat sich jemand hingesetzt, den Bug verstanden, eine rekursive Prüfung geschrieben, die das Ergebnis von unserialize() durchläuft und nach __PHP_Incomplete_Class sucht, und dann, beim Fund, den rohen String $data zurückgegeben. Erkennung: korrekt. Reaktion: dem Aufrufer die Bytes des Angreifers direkt in die Hand drücken. Bei diesem Satz sollst du kurz sitzen bleiben, denn er ist die ganze Schwachstelle im Kleinformat. Eine sanitisierende Funktion, die es nicht über sich bringt, Daten zu vernichten, hat nichts sanitisiert, sie hat die Explosion nur einen Stack Frame weiter nach unten verschoben. 4.16.7.2 gibt stattdessen false zurück, und dieser Unterschied von einem Wort ist der Unterschied zwischen einem Fix und einem Kommentar.

src/Helpers/Utils.php (4.16.7.2)
if (self::containsPhpIncompleteClass($unserializedData)) {
    // 4.16.6 returned $data here, which re-armed the payload
    return false;
}

Wie landet ein erfahrenes Team dort? Weil die PHP-API dazu einlädt. safeUnserialize() in src/Helpers/Utils.php ruft unserialize() mit ['allowed_classes' => false] auf, was sich liest wie ein Not-Aus und ausdrücklich als etwas ganz anderes dokumentiert ist. Das Manual sagt es unverblümt: Die Option verhindert die Objekterzeugung nicht. Du bekommst einen Platzhalter vom Typ __PHP_Incomplete_Class, der den ursprünglichen Klassennamen und jede einzelne Property brav aufbewahrt. Lies ihn, und nichts passiert. Serialisiere ihn erneut, und PHP spuckt exakt die Bytefolge aus, mit der du angefangen hast. Das Flag schützt den aktuellen Lesevorgang, und nur den.

Der Pfad in diesem CVE ist fast langweilig, und genau deshalb lohnt es sich, ihn auswendig zu können. Ein Angreifer legt über profile.php eine serialisierte Gadget-Payload in das Feld last_name seines eigenen Kontos. Beim Absenden einer Spende baut includes/process-donation.php user_info aus diesen Kontodaten zusammen, jagt jedes Feld durch den Helper und schreibt das entstehende Array in die Tabelle wp_give_sessions. Der nächste Request liest diese Zeile und ruft unserialize() ganz ohne Schutz auf, und das Objekt wacht richtig auf. Nichts im Request-Body war jemals auf eine Art bösartig, die ein Validator bemerkt hätte. Deine eigene Datenbank ist User-Input mit besserem Leumund.

Das ehrliche Gegenargument lautet: false zurückzugeben ist destruktiv, und destruktive Änderungen erzeugen Support-Tickets. Irgendwo da draußen gibt es einen Spender, dessen Firmenname tatsächlich containsSerializedDataRegex auslöst, und der Ansatz aus 4.16.6 war, wenn man die Augen zusammenkneift, konservativ: Daten des Nutzers erhalten, keine Spende verlieren. Ich nehme trotzdem jedes Mal das false. Wäge die beiden Fehlermodi ehrlich ab. Auf der einen Seite fällt eine Session-Zeile weg und ein Spender füllt ein Formular erneut aus. Auf der anderen Seite steht unauthentifiziertes system() als Webserver-User, in einem Plugin mit mehr als 100.000 Installationen, in jeder Version bis 4.16.7.1, CVSS 10.0. Das ist keine knappe Entscheidung, und wer sie als knapp behandelt, schreibt am Ende genau die Prüfung, deren einziger Effekt der Beweis ist, dass man das Problem gesehen hat.

Was ich an 4.16.7.2 wirklich schätze: Es tut nicht so, als wüsste es, welche Schicht die entscheidende war. Der Schreibpfad lehnt die Spende jetzt ab, wenn ein Namensfeld serialisierte Daten enthält. Drei getrennte Lese-Senken haben explizite allowed_classes-Schutzmaßnahmen bekommen: der Session-Getter in class-give-session.php, der Lesezugriff auf die Session-Tabelle in class-give-db-sessions.php und die Donor Wall in class-give-donor-wall.php, die sich als für anonyme Besucher erreichbar herausstellte, über den öffentlichen Shortcode [give_donor_wall], ganz ohne Cookie. Und ProviderForwarder::__call() prüft inzwischen, ob der aufgelöste Provider den Contract implementiert, bevor überhaupt etwas aufgerufen wird. Vier unabhängige Bruchstellen in einer Kette sind keine Paranoia, sondern das Eingeständnis, dass die Person, die einen Deserialisierungs-Bug behebt, nicht alle Senken aufzählen kann. Gut so.

Die Gadget-Hälfte verdient ihre eigene unbequeme Frage. Die Kette läuft durch TCPDF, das das Plugin für die PDF-Erzeugung mitliefert, weiter in Give\TestData-Klassen, die es gibt, um Demodaten zu generieren. __call() hat einen Namen in loadedProviders nachgeschlagen, einer schlichten Array-Property ganz ohne Typ, und was auch immer dort stand an call_user_func_array() übergeben. Setz diese Property beim Deserialisieren auf den String system, lass TCPDF::__destruct() seine Arbeit tun, und du hast eine Shell. Ich tue nicht so, als würde ich mein eigenes vendor-Verzeichnis darauf auditieren. Aber wenn Seeding- und Testfixture-Code zusammen mit einer PDF-Bibliothek in Produktion ausgeliefert wird, die magische Methoden im Destruktor-Pfad hat, dann sind die beiden ab jetzt Kollegen, ob du sie einander vorgestellt hast oder nicht.

Noch etwas, das in den nächsten zwei Wochen viele falsch machen werden: Der Versionssprung ist nicht die Behebung. Wenn eine Payload vor dem Update platziert wurde, sitzt sie weiterhin in deinen Tabellen. Deshalb bringt 4.16.7.2 eine Migration namens SanitizeSerializedObjectPayloads mit, über usermeta, give_donormeta, give_donationmeta und give_sessions hinweg, die verschachtelte Objekte durch leere Strings ersetzt. Prüf nach, ob sie tatsächlich gelaufen ist. Und beachte, dass give_action=user_register auch in 4.16.7.2 noch die Option users_can_register ignoriert, Accounts also weiterhin frei zu haben sind, selbst auf Sites mit abgeschalteter Registrierung. Patchstack führt das jetzt, wo die RCE-Kette gebrochen ist, als eigenes Access-Control-Problem, was fair ist, aber die WAF-Regel schreibst du.

Und darüber würde ich in den Kommentaren gern streiten. Deserialisierst du überhaupt irgendetwas, das aus deiner eigenen Datenbank kommt, und wenn ja, was passiert mit einer Zeile, die die Prüfung nicht besteht? Still verworfen, laut in eine Dead-Letter-Tabelle geschoben oder für einen Menschen markiert? Ich saß in solchen Meetings auf der Fail-closed-Seite und habe mehr als einmal verloren, weil jemand vernünftigerweise gefragt hat, wie viele legitime Datensätze wir dabei schreddern. Wenn du eine Variante gefunden hast, die der Betrieb mitträgt, wie hast du sie formuliert?