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 请求,服务器对数组中的每个子请求独立完成以下步骤:
- 路由匹配 — 根据子请求的
path(如/wp/v2/posts)找到对应的处理函数 - 参数验证 — 将该子请求的参数与路由 schema 比对,拒绝未声明的参数
- 权限检查 — 根据处理函数的要求,决定是否需要登录态
- 执行并收集结果 — 按顺序执行所有子请求,汇总响应
整个过程设计为每个子请求独立校验,互不干扰。
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 | { |
外层 batch 中每个子请求前都有一个 /// primer——这是触发数组错位的关键。wp_parse_url() 会将 /// 解析为 host 段为空的 URL,返回失败,从而在 $validation 中产生一个错误条目,但 $matches 中不留空位,导致后续索引全部错位。
外层 batch 中第三个子请求(POST /batch/v1,body 为空 requests)是填充用的 spacer——它本身不携带有效载荷,作用是让外层 batch 在递归处理时保持正确的偏移量对齐。
1.4 完整调用链
1 | 外层 HTTP 请求 |
整个过程只需一次 HTTP 请求,不需要任何认证凭据。
二、漏洞分析
2.1 根因:serve_batch_request_v1() 中的数组偏移
理解这个漏洞的核心,就是理解一个简单的数组错位问题。
serve_batch_request_v1() 是处理 batch 请求的主函数。它收到一个包含多个子请求的数组后,对每个子请求做两件事:
- 解析路径,找到对应的处理函数(handler),存入
$matches数组 - 验证该子请求是否有权执行,存入
$validation数组
理想情况下,两个数组的索引一一对应:
1 | 索引 0 的 handler 处理 索引 0 的请求 |
问题出在哪:当某个子请求的路径格式不合法时(比如 ///),WordPress 的路由解析函数 wp_parse_url() 会返回失败。遇到这种情况,代码只把错误信息追加到 $validation 数组,但没有在 $matches 中为它留一个空位。
结果就是两个数组的长度不一致了——$validation 比 $matches 多了一个元素。从这一刻起,所有后续的索引都错位了:
1 | 本来应该是: |
用实际的请求序列演示:
攻击者发送 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 | $validation = [错误(请求0), 通过(请求1), 通过(请求2), 错误(请求3)] |
注意:$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 | 请求参数: author_exclude=1,2,3 |
漏洞情况下的参数绕过:
1 | 请求路径: GET /wp/v2/posts/999999?author_exclude=0) UNION SELECT ... |
注入成功后,攻击者获得三种数据读取能力:
| 模式 | 原理 | 速度 | 适用条件 |
|---|---|---|---|
| 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 | git clone https://github.com/Icex0/wp2shell-poc.git |
3.2 检测(check)
1 | python3 wp2shell.py check http://target |
check 发送 benign batch marker probe,不注入 SQL。若返回 HTTP 207 且三个标记全中,则目标存在路由混淆缺陷。
--confirm-sqli 会发送实际的 SQLi payload(先尝试 UNION,失败则回退到时间盲注),确认注入是否可到达数据库。
3.3 读取数据(read)
1 | python3 wp2shell.py read http://target --preset fingerprint # 服务器指纹 |
三种提取模式按优先级自动选择:UNION(1 请求/值)→ Error-based(15 字节/请求)→ Blind(8 请求/字符)。
3.4 RCE(shell)
1 | # 预认证模式:SQLi 直接创建管理员 → 上传 webshell → RCE |
不传 --user/--password 时,PoC 走 SQLi-to-Admin Bridge 路径:不提取已有管理员的哈希,而是直接利用 SQL 注入让 WordPress 自己创建一个全新的管理员账号。实测创建的管理员名为 wp2_af09462c36df 这种格式,密码随机生成。整个创建过程在一步内完成,完全不需要破解密码哈希。
webshell 部署在随机路径 + 随机 token 保护下,执行完毕后自动清理管理员账号和插件目录。
四、修复
4.1 升级
1 | wp core update --version=6.9.5 # 6.9.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 | add_action('rest_pre_dispatch', function ($response, $server, $request) { |
长期加固:
- 保持自动更新开启
- 禁用持久化对象缓存(如非必要)—— 可阻断 RCE 路径,但 SQL 注入仍成立
- 通过
rest_authentication_errors钩子限制匿名 REST API 访问 - 在
wp-content/uploads中阻止 PHP 执行 - 定期使用 WPScan 扫描漏洞
参考来源:CVE 官方记录 (cve.org)、GitHub Security Advisory GHSA-ff9f-jf42-662q、Cloudflare Blog、腾讯云安全通告、安恒信息 CERT、Aikido Security、WPSentry


