Commençons par le correctif qui ne corrigeait rien. Dans GiveWP 4.16.6, quelqu'un s'est assis, a compris le bug, a écrit une vérification récursive qui parcourt le résultat désérialisé à la recherche de __PHP_Incomplete_Class, puis, en en trouvant un, a renvoyé la chaîne $data brute. Détection : correcte. Réponse : rendre les octets de l'attaquant directement à l'appelant. C'est cette phrase que je veux te laisser digérer, parce qu'elle contient toute la vulnérabilité en miniature. Une fonction de nettoyage qui n'arrive pas à se résoudre à détruire de la donnée n'a rien nettoyé du tout, elle a juste déplacé l'explosion d'un cadre de pile plus bas. La 4.16.7.2 renvoie false à la place, et cette différence d'un seul mot est la différence entre un correctif et un commentaire.

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;
}

Comment une équipe expérimentée en arrive-t-elle là ? Parce que l'API de PHP l'y invite. safeUnserialize() dans src/Helpers/Utils.php appelle unserialize() avec ['allowed_classes' => false], ce qui se lit comme un coupe-circuit et n'est documenté comme rien de tel. Le manuel est net : l'option n'empêche pas la création d'objet. Tu récupères un placeholder __PHP_Incomplete_Class qui conserve fidèlement le nom de la classe d'origine et chacune de ses propriétés. Tu le lis, il ne se passe rien. Tu le resérialises, et PHP recrache la séquence d'octets identique à celle du départ. Le flag protège la lecture en cours, et uniquement la lecture en cours.

Le chemin d'exploitation de cette CVE est presque ennuyeux, et c'est justement pour ça qu'il mérite d'être retenu. Un attaquant place une charge utile sérialisée de type gadget dans le champ last_name de son propre compte, via profile.php. À la soumission d'un don, includes/process-donation.php assemble user_info à partir de ces données de compte, fait passer chaque champ dans le helper, et écrit le tableau obtenu dans la table wp_give_sessions. La requête suivante relit cette ligne et appelle unserialize() sans le moindre garde-fou, et l'objet se réveille pour de bon. Rien dans le corps de la requête n'était malveillant d'une manière qu'un validateur aurait pu repérer. Ta propre base de données, c'est de l'entrée utilisateur avec une meilleure réputation.

L'objection honnête, c'est que renvoyer false est destructif, et qu'un changement destructif génère des tickets de support. Il existe forcément quelque part un donateur dont le nom d'entreprise déclenche vraiment containsSerializedDataRegex, et l'approche de la 4.16.6 était, si tu plisses les yeux, prudente : préserver la donnée de l'utilisateur, ne pas perdre un don. Je prends quand même le false, à chaque fois. Pèse les deux modes de défaillance honnêtement. D'un côté, une ligne de session est jetée et un donateur resoumet un formulaire. De l'autre, system() non authentifié sous l'utilisateur du serveur web, sur une extension à plus de 100 000 installations, toutes versions jusqu'à 4.16.7.1, CVSS 10.0. Ce n'est pas un arbitrage serré, et le traiter comme tel, c'est exactement comme ça qu'on finit par écrire une vérification dont le seul effet est de prouver qu'on avait vu le problème.

Ce que j'admire vraiment dans la 4.16.7.2, c'est qu'elle ne prétend pas savoir quelle couche comptait. Le chemin d'écriture refuse désormais le don si un champ de nom contient de la donnée sérialisée. Trois points de lecture distincts ont reçu des gardes allowed_classes explicites : le getter de session dans class-give-session.php, la lecture de la table de sessions dans class-give-db-sessions.php, et le mur des donateurs dans class-give-donor-wall.php, qui s'est avéré atteignable par un visiteur anonyme via le shortcode public [give_donor_wall], sans même un cookie. Et ProviderForwarder::__call() vérifie maintenant que le provider résolu implémente le contrat avant d'appeler quoi que ce soit. Quatre ruptures indépendantes dans une même chaîne, ce n'est pas de la paranoïa, c'est reconnaître que la personne qui corrige un bug de désérialisation ne peut pas énumérer tous les points d'arrivée. Tant mieux.

La moitié gadget mérite sa propre question dérangeante. La chaîne passe par TCPDF, que l'extension embarque pour la génération de PDF, puis par des classes Give\TestData qui existent pour produire des données de démonstration. __call() cherchait un nom dans loadedProviders, une simple propriété tableau sans aucun typage, et passait ce qu'il y trouvait à call_user_func_array(). Tu positionnes cette propriété sur la chaîne system pendant la désérialisation, tu laisses TCPDF::__destruct() faire son travail, et tu as un shell. Je ne vais pas prétendre que j'audite mon propre répertoire vendor pour ça. Mais si du code de seeding et de fixtures de test part en production aux côtés d'une bibliothèque PDF dont le destructeur emprunte des méthodes magiques, ces deux-là sont collègues désormais, que tu les aies présentés l'un à l'autre ou non.

Encore un point que beaucoup vont rater dans les quinze prochains jours : la montée de version n'est pas la remédiation. Si une charge utile a été déposée avant ta mise à jour, elle est toujours posée dans tes tables. C'est pour ça que la 4.16.7.2 embarque une migration SanitizeSerializedObjectPayloads sur usermeta, give_donormeta, give_donationmeta et give_sessions, qui remplace les objets imbriqués par des chaînes vides. Vérifie qu'elle s'est réellement exécutée. Et note que give_action=user_register ignore toujours l'option users_can_register en 4.16.7.2, donc les comptes restent en libre-service sur les sites où l'inscription est désactivée. Patchstack le classe maintenant comme un problème de contrôle d'accès à part entière puisque la chaîne RCE est cassée, ce qui se défend, mais la règle WAF, c'est à toi de l'écrire.

Voilà donc ce dont j'aimerais qu'on débatte en commentaires. Est-ce que tu désérialises quoi que ce soit qui sorte de ta propre base, et si oui, qu'advient-il d'une ligne qui échoue à la vérification ? Jetée en silence, jetée bruyamment dans une table de rebut, ou signalée à un humain ? J'ai été du côté fail-closed de cette réunion et je l'ai perdue plus d'une fois, parce que quelqu'un demandait, raisonnablement, combien d'enregistrements légitimes on allait broyer. Si tu as trouvé une formulation que les gens de l'exploitation acceptent, je veux savoir comment tu l'as tournée.