
Avis technique et preuve de concept pour CVE-2026-97286, une XSS stockée authentifiée dans le plugin WordPress Strong Testimonials, avec chaîne d'attaque, PoC et analyse du correctif.
Un Contributeur authentifié peut stocker des métadonnées de photo de plateforme contrôlées par l'attaquant dans l'extension WordPress Strong Testimonials. Avant la version 3.3.12, l'extension rendait la valeur stockée à l'intérieur d'un attribut src d'image sans échappement contextuel. Après qu'un administrateur publie le témoignage piégé et un article qui l'affiche, la charge utile s'exécute pour les visiteurs qui ouvrent cet article, y compris les visiteurs non authentifiés.
Cette recherche a été menée dans un laboratoire local autorisé et divulguée via Patchstack avant la publication publique.
| Champ | Valeur |
|---|---|
| CVE | CVE-2026-97286 |
| Produit | Strong Testimonials |
| Éditeur | WP Chill |
| Slug de l'extension | strong-testimonials |
| Vulnérabilité | Cross-site scripting stocké authentifié |
| Versions affectées | <= 3.3.11 |
| Version corrigée | 3.3.12 |
| Privilège requis | Contributeur ou supérieur |
| CWE | CWE-79 : Neutralisation incorrecte des entrées lors de la génération de pages web |
| CVSS v3.1 | 6.5 Moyen — CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:L/I:L/A:L |
| Chercheur | Rully Miftahur Rozaq |
| Signalé | 16 septembre 2026 |
| Divulgation publique | 30 septembre 2026 |
Patchstack classe l'avis en priorité faible bien que la sévérité de base CVSS v3.1 attribuée soit Moyenne.
L'éditeur de témoignages accepte un tableau de requête custom et persiste chaque clé soumise en tant que métadonnée d'article. Les clés absentes de la définition de champ personnalisé configurée passent par sanitize_text_field(). Cela permet à un Contributeur de soumettre des métadonnées réservées à l'extension telles que platform et platform_user_photo lors de l'édition d'un témoignage qu'il possède.
Pour les plateformes sociales prises en charge, l'extension récupère platform_user_photo, vérifie que l'URL renvoie une réponse HTTP 200, puis interpole la chaîne stockée d'origine dans un attribut <img src="https://raw.githubusercontent.com/rully2212/cve-2026-97286/main/...">. La validation du statut HTTP ne rend pas une chaîne sûre pour un attribut HTML. Un guillemet dans le fragment d'URL peut terminer src et introduire un gestionnaire d'événements.
La chaîne d'attaque vérifiée est la suivante :
L'injection est authentifiée. Le visiteur qui déclenche la charge utile déjà publiée n'a pas besoin d'être authentifié.
Une exploitation réussie permet une exécution persistante de JavaScript dans l'origine WordPress affectée sur les pages qui affichent le témoignage piégé via le chemin de vignette vulnérable. Le code s'exécute avec les privilèges du navigateur et la session WordPress du visiteur, le cas échéant.
Le chemin reproduit nécessite une publication par un administrateur, ce qui explique la métrique CVSS UI:R. Cette recherche n'a pas démontré d'injection non authentifiée, d'exécution automatique sur toutes les pages du site, d'élévation de privilèges, de vol de cookies ni de compromission complète du site.
La preuve de concept a été reproduite avec la configuration suivante :
| Composant | Configuration |
|---|---|
| WordPress | 7.1 |
| Strong Testimonials | 3.3.7, édition gratuite |
| Thème | Twenty Twenty-Five |
| Modèle d'affichage | default-theme |
| Vignette | Activée |
| PHP / Apache | PHP 8.3.33 / Apache 2.4.68 |
| Base de données | MariaDB 11.4 |
| Rôle de l'attaquant | Contributeur |
| Capacités de l'attaquant | edit_posts=true, publish_posts=false, unfiltered_html=false |
La version 3.3.7 était la version exacte utilisée dans le laboratoire. L'enregistrement CVE public identifie toutes les versions jusqu'à 3.3.11 comme affectées et la version 3.3.12 comme corrigée.
Dans admin/class-strong-testimonials-post-editor.php, save_details() parcourt chaque entrée du tableau custom soumis. Une clé non reconnue atteint la branche de nettoyage générique puis est stockée avec update_post_meta() :
$custom = $_POST['custom'];
foreach ( $custom as $key => $value ) {
// ...
$sanitized_value = sanitize_text_field( $value );
update_post_meta( $post_id, $key, $sanitized_value );
}
Le gestionnaire réel effectue des vérifications de nonce et une assainissement spécifique à chaque champ. La découverte ne repose pas sur le contournement de ces vérifications ni sur la modification du témoignage d'un autre utilisateur. Le Contributeur modifie légitimement son propre témoignage mais fournit les clés réservées platform et platform_user_photo.
Dans les versions vulnérables, includes/functions-image.php lit platform_user_photo, effectue une vérification du statut HTTP via wpmtst_is_valid_image_url(), et insère la chaîne d'origine dans un attribut HTML :
$platform_user_photo = get_post_meta( $post_id, 'platform_user_photo', true );
if ( ! $platform_user_photo || ! wpmtst_is_valid_image_url( $platform_user_photo ) ) {
return $img;
}
return sprintf(
'<img src="https://raw.githubusercontent.com/rully2212/cve-2026-97286/main/%25s" %s %s/>',
$platform_user_photo,
$width ? "width='{$width}'" : '',
$height ? "height='{$height}'" : ''
);
La partie fragment d'une URL n'est pas envoyée au serveur HTTP, mais elle reste dans la chaîne ensuite rendue par PHP. Le serveur peut donc voir une URL inoffensive renvoyant HTTP 200 tandis que le navigateur reçoit une valeur contenant une séquence d'évasion d'attribut.
Le template default-theme appelle wpmtst_get_thumbnail() et émet directement le balisage d'image renvoyé :
<figure class="wp-block-image wpmtst-testimonial-avatar">
<?php echo $wpmtst_avatar_html; ?>
</figure>
Le modèle suppose que l'assistant a déjà produit du HTML sûr. Dans les versions affectées, l'assistant photo de la plateforme n'échappait pas l'URL pour son contexte src.
Ne reproduisez ce problème que sur un système que vous possédez ou pour lequel vous êtes explicitement autorisé à effectuer des tests.
<= 3.3.11 sur un site WordPress de test autorisé.default-theme avec les vignettes activées.Connectez-vous en tant que Contributor et créez un nouveau brouillon de témoignage.
Capturez la requête d'enregistrement légitime POST /wp-admin/post.php à l'aide d'un proxy d'interception. Conservez son corps de formulaire complet, son cookie de session, un nonce WordPress frais, le nonce du plugin et l'ID du témoignage appartenant au Contributor.
Ajoutez ces paramètres au corps du formulaire :
custom[platform]=facebook
custom[platform_user_photo]=http://wordpress/index.php?rest_route=/#" onerror="alert(1)" x="
fragment de formulaire encodé en URL :
&custom%5Bplatform%5D=facebook&custom%5Bplatform_user_photo%5D=http%3A%2F%2Fwordpress%2Findex.php%3Frest_route%3D%2F%23%22%20onerror%3D%22alert%281%29%22%20x%3D%22