
Analyse des causes racines, vérificateur de version passif et PoC de laboratoire pour CVE-2026-18322, une élévation de privilèges non authentifiée dans le plugin WordPress Smart Popup by Supsystic.
Recherche de sécurité sur CVE-2026-18322, une vulnérabilité d'élévation de privilèges
non authentifiée dans le plugin WordPress Smart Popup by Supsystic
(popup-by-supsystic). Ce dépôt contient une analyse de cause racine dérivée du diff
source amont, les correctifs extraits, un outil de détection (fingerprinter de version
passif) pour identifier les installations affectées et un laboratoire pour le PoC
d'exploitation.
La vulnérabilité est publique et corrigée. Ce travail est publié à des fins défensives : aider les opérateurs à trouver et remédier les hôtes affectés.
| Plage de versions | Statut |
|---|---|
< 1.13.0 | Vulnérable |
>= 1.13.0 | Corrigée |
1.13.0 (publiée le 31.07.2026) est la première version corrigée ; elle répare les trois maillons de la chaîne décrite ci-dessous. Voir §1 de l'analyse.
Trois faiblesses indépendantes se composent en une création d'administrateur non authentifiée :
1. Une collision de carte de permissions — havePermissions() combinait deux cartes de
permissions avec array_merge(). Les deux utilisent la clé chaîne PPS_USERLEVELS
('userlevels'), et array_merge() écrase les clés chaînes, donc la courte liste par
défaut du contrôleur de base remplaçait intégralement la liste du module popup — supprimant
silencieusement la restriction administrateur de save et de neuf autres méthodes :
$permissions = $mod->getController()->getPermissions(); // [... 'save' ...]
$permissionsBase = $mod->getController()->getBasePermissions(); // ['getListForTbl','removeGroup','clear']
$permissions = array_merge($permissions, $permissionsBase); // base wins — 'save' is gone
La vérification échoue en ouvert : une action absente de la carte n'est jamais refusée.
2. Un nonce réutilisable envoyé à des inconnus — save exigeait toujours un
pps_nonce, mais l'e-mail de confirmation d'abonnement intégrait exactement cette action
nonce. Pour les utilisateurs déconnectés, un nonce WordPress est indexé sur uid=0, donc le
jeton généré pour un abonné anonyme est valide pour n'importe quel attaquant anonyme.
3. Aucune liste blanche de rôles côté serveur — createWpSubscriber() passait le rôle
configuré directement à WP_User::set_role(). La liste de rôles sûrs du plugin (qui exclut
administrator) n'était appliquée que lors du rendu du menu déroulant d'administration — un
contrôle purement côté UI.
Enchaîné : s'abonner pour obtenir un nonce → le rejouer contre popup::save pour définir
sub_wp_create_user_role=administrator → déclencher le flux d'abonnement → compte
administrateur persistant.
Procédure complète avec références fichier/ligne : docs/ANALYSIS.md.
poc/cve_2026_18322_check.py détermine si un site exécute une version affectée. Il ne
tente jamais d'exploitation : il émet de simples GET HTTP et lit la version que
l'installation publie sur elle-même.
git clone <this-repo> && cd CVE-2026-18322
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
requests est recommandé mais optionnel — l'outil se rabat sur la bibliothèque standard.
# Single target
python3 poc/cve_2026_18322_check.py https://example.com
# With supporting evidence
python3 poc/cve_2026_18322_check.py https://example.com -v
# Many targets, concurrently, exporting results
python3 poc/cve_2026_18322_check.py -f targets.txt -t 20 --json results.json
python3 poc/cve_2026_18322_check.py -f targets.txt --csv results.csv --only-vulnerable
# Through a proxy, ignoring TLS errors (lab use)
python3 poc/cve_2026_18322_check.py https://staging.internal -k --proxy http://127.0.0.1:8080
Les cibles peuvent être fournies en arguments ou via -f (- lit stdin). Un nom d'hôte
nu est supposé être https://.
$ python3 poc/cve_2026_18322_check.py https://example.com -v
[VULNERABLE] https://example.com/ (Smart Popup by Supsystic 1.11.2) < 1.13.0 affected by CVE-2026-18322; upgrade to 1.13.0+
- asset-version: plugin asset enqueued with ?ver=1.11.2 (https://example.com/)
- homepage-reference: references /plugins/popup-by-supsystic/ (https://example.com/)
- readme: plugin readme.txt is publicly readable (https://example.com/wp-content/plugins/popup-by-supsystic/readme.txt)
- readme-stable-tag: Stable tag: 1.11.2 (.../readme.txt)
Codes de sortie : 0 = aucune cible vulnérable, 1 = au moins une vulnérable, 2 = erreur d'utilisation.
Deux signaux passifs, tous deux de simples GET HTTP sur des ressources publiques :
?ver=PPS_VERSION
(classes/frame.php:410,473), donc la page d'accueil divulgue souvent la version exacte.readme.txt — Stable tag: fait autorité et prend le pas sur un asset en cache
potentiellement obsolète ; le changelog sert de repli.Le scan de la page d'accueil découvre aussi les répertoires wp-content renommés (par ex.
/app/ de Bedrock) afin que la recherche du readme suive la disposition réelle du site.
L'outil de détection ne tente pas d'exploitation. Aucun exploit armé pour ce CVE n'est publié ici : le seul code qui exécute la chaîne complète est strictement restreint au laboratoire local (voir ci-dessous).
Le vérificateur passif n'effectue qu'un fingerprinting de version. Son HttpClient n'expose
aucun verbe HTTP autre que GET — imposé par construction et vérifié dans la suite de
tests, il ne peut donc pas émettre de requête modifiant l'état, même par erreur. Il n'appelle
jamais popup::save, n'envoie pas de paramètre sub_wp_create_user_role, ne soumet pas de
formulaire d'abonnement, ne déclenche pas d'e-mail de confirmation, et ne crée ni ne modifie
aucun utilisateur, popup ou réglage. Il lit deux ressources publiques — la page d'accueil et
readme.txt — et rien d'autre.
Cette sécurité a un coût, dit clairement : le verdict ne vaut que ce que valent les métadonnées de version que l'hôte publie. Compromis et modes de défaillance : §9.
lab/exploit_full_chain.py est le seul morceau de code ici qui réalise l'attaque réelle, et
il crée un administrateur WordPress. Il est publié pour que l'analyse puisse être
reproduite plutôt que prise sur parole — une affirmation sur un bug d'autorisation qui ne
peut être démontrée est une assertion, pas une découverte. Il est écrit pour la pile jetable
dans lab/ et pour rien d'autre :
--lab-confirm est obligatoire. Les deux vérifications s'exécutent dans
assert_lab_target() avant la première requête HTTP, donc une invocation rejetée ne touche
pas du tout la cible.pps_nonce
réutilisable depuis l'e-mail de confirmation d'abonnement via l'API Mailpit du laboratoire
(http://localhost:8025/api/v1/…). Il n'existe aucun chemin de code pour récupérer cet
e-mail ailleurs, donc la chaîne n'a pas de première étape contre un hôte hors du
laboratoire.lab/docker-compose.yml se lie à 127.0.0.1.Tel que livré, il ne peut donc pas être pointé vers un site réel — il s'arrête à
REFUSING TO RUN avant d'émettre une requête :
$ python3 exploit_full_chain.py https://example.com --lab-confirm
REFUSING TO RUN: example.com resolves to non-local address(es) 104.20.23.154, ...
This script creates a WordPress administrator and is for the local lab only.
To test a real site, use the non-destructive checker in ../poc/.
Ces garde-fous sont porteurs plutôt que décoratifs : le script est la chaîne réelle, et ce
qui en fait une démonstration est qu'il ne s'exécutera pas hors d'une pile locale jetable.
Veuillez ne pas les retirer, et voir SECURITY.md — les
modifications qui les affaiblissent, ou un exploit autonome destiné à s'exécuter hors du
laboratoire, sont hors périmètre pour ce dépôt. Pour savoir si un site réel est affecté,
utilisez poc/ — le vérificateur répond à la même question sans rien toucher.
Pour une réponse faisant autorité sur un hôte que vous contrôlez :
grep -n "array_merge(\$permissions, \$permissionsBase)" \
wp-content/plugins/popup-by-supsystic/classes/frame.php
Toute sortie signifie que l'hôte est vulnérable — cette ligne est précisément ce que 1.13.0 a remplacé.
À utiliser uniquement contre des systèmes que vous possédez ou êtes explicitement autorisé à tester. Voir SECURITY.md.
lab/ contient une pile Docker jetable qui exécute la vulnérabilité de bout en bout,
afin que l'analyse puisse être vérifiée plutôt que prise sur parole :
cd lab
./setup.sh # WordPress + plugin 1.12.0
../.venv/bin/python exploit_full_chain.py http://localhost:8080 --lab-confirm
./verify.sh # confirm from the database
./teardown.sh
Sa sortie la plus utile est la comparaison de versions — requêtes identiques contre trois versions :
./setup.sh --plugin-version 1.11.2 # exploit succeeds
./setup.sh --plugin-version 1.12.0 # exploit succeeds
./setup.sh --plugin-version 1.13.0 # exploit fails at phase 2
Le laboratoire est intentionnellement vulnérable et son script d'exploit crée un administrateur WordPress. Tout se lie à
127.0.0.1, et le script ne s'exécutera contre rien d'autre qu'un laboratoire local — voir le PoC du laboratoire ne s'exécute que dans le laboratoire. Ce n'est pas un scanner ; pour vérifier un site réel, utilisezpoc/.
sub_wp_create_user_role défini sur un rôle privilégié, et requêtes POST vers
admin-ajax.php portant pl=pps&mod=popup&action=save.Requêtes et greps de logs : §10.
.
├── README.md
├── SECURITY.md Scope, authorized-use policy, disclosure
├── LICENSE MIT
├── requirements.txt
├── docs/
│ └── ANALYSIS.md Full root-cause analysis and fix rationale
├── patches/
│ ├── 01-frame-permission-merge.diff array_merge() → _mergePermissions()
│ ├── 02-subscribe-role-allowlist-and-nonce.diff Role allowlist + dedicated nonce
│ └── 03-v1.12.0-confirmation-page-hardening.diff Unrelated hardening added in 1.12.0
├── poc/
│ └── cve_2026_18322_check.py Passive version fingerprint (GET only)
├── lab/ Disposable Docker lab — INTENTIONALLY VULNERABLE
│ ├── docker-compose.yml MariaDB + WordPress 1.12.0 + Mailpit (loopback only)
│ ├── setup.sh / teardown.sh Bring the lab up / destroy it
│ ├── exploit_full_chain.py Full attack chain — CREATES AN ADMIN; lab-gated
│ └── verify.sh Independent confirmation from the database
├── scripts/
│ └── fetch_versions.sh Export plugin versions from SVN and regenerate diffs
└── tests/
└── test_check.py Offline unit tests for the passive checker
pip install -r requirements.txt pytest
python3 -m pytest tests/ -v
La suite est entièrement hors ligne — elle utilise des fixtures enregistrées, jamais de cibles réelles. Elle couvre le triage de version à travers la frontière 1.13.0, la normalisation des cibles, et les règles de preuve qui décident de chaque statut.
Les propriétés de sécurité sont vérifiées, pas seulement documentées : qu'un scan complet
n'émet aucune requête modifiant l'état, et que HttpClient n'expose aucun verbe HTTP autre
que GET.
Deux régressions trouvées pendant le développement sont verrouillées : un readme.txt en
CRLF qui déjouait l'analyse ancrée sur les lignes, et une page d'erreur HTTP comptée à tort
comme preuve de plugin.
Pour reproduire l'analyse depuis les sources amont :
./scripts/fetch_versions.sh # exports 1.11.2 / 1.12.0 / 1.13.0 and regenerates patches/
MIT — voir LICENSE. Le plugin analysé est GPLv2-or-later et n'est pas
redistribué ici ; scripts/fetch_versions.sh le récupère depuis le dépôt SVN officiel de
WordPress.
| Statut | Signification |
|---|
VULNERABLE | Plugin détecté en < 1.13.0 |
NOT_VULNERABLE | Plugin détecté en >= 1.13.0 |
PLUGIN_DETECTED_VERSION_UNKNOWN | Plugin présent, version indéterminable — vérifier manuellement |
PLUGIN_NOT_DETECTED | Aucune preuve du plugin (pas une preuve d'absence) |
ERROR | Cible injoignable ou malformée |