这是近十年来WordPress Core首个被确认在野外利用的预认证RCE漏洞链。整套攻击链由两个漏洞组合而成:
| 漏洞编号 | 漏洞类型 | 单独CVSS | 影响版本 |
|---|---|---|---|
| CVE-2026-63030 | REST API Batch路由混淆 | 7.5 (High) | 6.9.0-6.9.4, 7.0.0-7.0.1 |
| CVE-2026-60137 | WP_Query SQL注入 | 9.1 (Critical) | 6.8.0-6.8.5, 6.9.0-6.9.4, 7.0.0-7.0.1 |
单独来看,两个漏洞都无法实现未授权RCE;组合起来,攻击者无需任何凭证即可完全接管目标站点。
第一部分:SQL注入的根源 ------ CVE-2026-60137
1.1 漏洞位置
WP_Query 是 WordPress 构建数据库查询的核心类。当 author_exclude 参数被映射到 author__not_in 时,问题出现了。
1.2 源码级别的缺陷
关键代码位置 :class-wp-query.php 中的 author__not_in 处理逻辑:
php
// 问题代码(简化)
if (isset($query_vars['author__not_in'])) {
if (is_array($query_vars['author__not_in'])) {
// 只有数组才会被 absint() 处理
$author__not_in = implode(',', array_map('absint', $query_vars['author__not_in']));
} else {
// ★ 字符串直接拼接进 SQL!没有任何过滤!
$author__not_in = $query_vars['author__not_in'];
}
$where .= " AND {$wpdb->posts}.post_author NOT IN ($author__not_in) ";
}
缺陷本质 :author__not_in 参数只有在以数组形式传入时 才会被 absint() 转换为整数;如果传入的是字符串 ,该值会被直接拼接 进 SQL 的 NOT IN (...) 子句。
1.3 注入 Payload 示例
author_exclude=1) OR (SELECT 1 FROM (SELECT SLEEP(5))x)-- -
拼入 SQL 后:
sql
... AND post_author NOT IN (1) OR (SELECT 1 FROM (SELECT SLEEP(5))x)-- -) ...
第二部分:路由混淆 ------ CVE-2026-63030
2.1 漏洞位置
核心文件 :wp-includes/rest-api/class-wp-rest-server.php
受影响函数 :WP_REST_Server::serve_batch_request_v1()
2.2 漏洞机制:两个数组的错位
serve_batch_request_v1() 在处理批量请求时,会维护两个并行的数组:
| 数组 | 作用 |
|---|---|
$matches |
存储每个子请求匹配到的处理程序(handler) |
$validation |
存储每个子请求的校验结果 |
关键代码段(源码逻辑):
php
// 阶段一:校验循环
foreach ($requests as $single_request) {
if (is_wp_error($single_request)) { // ← 探针 http://: 解析失败
$validation[] = $single_request;
continue; // ★ 跳过,不进 $matches!
}
$match = $this->match_request_to_handler($single_request);
$matches[] = $match; // 正常请求才进 $matches
// ... 用"自己的路由"做校验 ...
}
// 阶段二:执行循环
foreach ($requests as $i => $single_request) {
$match = $matches[$i]; // ★ 按下标取,整体错位!
// 用错位的 handler 执行请求
}
漏洞触发点 :当一个子请求路径在 wp_parse_url() 阶段解析失败(如 http://:),它会被写入 $validation 但不会进入 $matches 。两个数组因此下标错位 ,后续所有子请求都被派发到错误的处理程序。
2.3 图解错位
请求数组(3个): index: 0 1 2 requests: [探针 http://:] , [受害者请求] , [供货商请求] 阶段一后: validation: [WP_Error] , [true] , [true] matches: , [victim_match] , [supplier_match] ← 长度=2,缺了index=0 阶段二执行: i=0 → matches[0] = victim_match → 探针拿到受害者的match i=1 → matches[1] = supplier_match → ★ 受害者拿到供货商的match! i=2 → matches[2] = Undefined → 供货商报错(攻击已完成)
第三部分:嵌套 Batch 请求的结构
3.1 为什么需要嵌套?
两个铁律的冲突:
| 铁律 | 说明 |
|---|---|
| ① | 注入必须用 GET 方法(author_exclude 在 URL 查询字符串中) |
| ② | /batch/v1 顶层子请求的 method 只能是 POST/PUT/PATCH/DELETE,不支持 GET |
解决方案 :将 GET 请求藏进一个 POST 请求的 body 中。
3.2 Payload 结构
{ "requests": [ ← 外层 batch {"method": "POST", "path": "http://:"}, ← 外层探针(制造错位) {"method": "POST", "path": "/wp/v2/posts", "body": { ← 受害者 "requests": [ ← 内层 batch(藏在 body 里) {"method": "GET", "path": "http://:"}, ← 内层探针 {"method": "GET", "path": "/wp/v2/categories?author_exclude=<SQL>"}, ← SQL注入 {"method": "GET", "path": "/wp/v2/posts"} ← 供货商 ] }}, {"method": "POST", "path": "/batch/v1"} ← 供货商(handler 被偷) ] }
3.3 为什么 body 能"藏"住 GET?
REST API 的 body 参数 schema 中,properties 为空数组,additionalProperties 为 true。因此外层 schema 校验不会深入检查 body 内容,内层的 GET 请求得以绕过顶层的 method 枚举限制。
第四部分:完整攻击链拆解
阶段 0:前置条件
攻击者需要目标站点至少有一篇已发布文章或页面 ------这为后续的 UNION SELECT 伪造 wp_posts 行提供了基准行数。
阶段 1:外层错位 ------ 让 POST 变成 Batch 派发器
目标 :让 POST /wp/v2/posts 获得 /batch/v1 的派发能力。
-
探针 (
POST http://:):wp_parse_url()解析失败 → 写入$validation,不进$matches→$matches缺一格 -
受害者 (
POST /wp/v2/posts):在阶段二取$matches[1]→ 拿到供货商的 match (/batch/v1的 handler) -
执行 :
POST /wp/v2/posts被当作 batch 派发器执行 → 其 body 中的内层 requests 被激活
结果:内层的 3 个 GET 请求现在被当作合法的 batch 子请求执行。
阶段 2:内层错位 ------ 让 Categories 获得 Posts Handler
目标 :让 GET /wp/v2/categories 获得 posts 控制器的 get_items handler。
-
内层探针 (
GET http://:):同样解析失败 → 又一次错位 -
内层受害者 (
GET /wp/v2/categories?author_exclude=<SQL>):-
阶段一:用 categories 自己的参数表 做校验 →
author_exclude不在 categories 的 12 个参数中 → 查无此人,跳过校验 -
阶段二:取
$matches[1]→ 拿到内层供货商的 match (/wp/v2/posts的 handler)
-
-
执行 :
GET /wp/v2/categories被posts控制器的get_items()执行
关键 :author_exclude 参数在 categories 的参数表中不存在,所以三道看门狗(has_valid_params、sanitize_params、absint)全部跳过。
阶段 3:SQL 注入执行
get_items() 将 author_exclude 映射到 author__not_in:
php
// class-wp-rest-posts-controller.php
$args['author__not_in'] = $request['author_exclude']; // 原始注入串
随后进入 WP_Query::get_posts():
-
is_array($query_vars['author__not_in'])返回false(因为是字符串) -
字符串直接拼接进 SQL
最终 SQL:
sql
... AND wp_posts.post_author NOT IN (1) OR (SELECT IF(ASCII(SUBSTRING(...))>X, SLEEP(0.5), 0)) ...
阶段 4:从 SQL 注入到 RCE
SQL 注入本身是只读的。完整 RCE 需要后续步骤:
-
信息泄露:通过时间盲注逐字符提取管理员密码哈希
-
UNION 伪造 :
UNION SELECT伪造wp_posts数据行 -
对象缓存污染 :伪造的行被 WordPress 当作真实
WP_Post对象缓存 -
Customizer 提权 :利用
customize_changeset工作流切换到管理员上下文 -
创建管理员 :以管理员身份调用
/wp/v2/users创建新管理员账号 -
上传插件:登录后台上传恶意插件,执行任意系统命令
第五部分:源码关键行号速查表
| 组件 | 文件 | 关键行/位置 | 说明 |
|---|---|---|---|
| Batch 路由定义 | class-wp-rest-server.php |
115-159 | /batch/v1 路由注册 |
| requests.method 枚举 | class-wp-rest-server.php |
131-135 | 只允许 POST/PUT/PATCH/DELETE |
| requests.body 透明 | class-wp-rest-server.php |
140-144 | properties=\[\],不深入校验 |
| Batch 派发入口 | class-wp-rest-server.php |
1714 | serve_batch_request_v1() |
| 探针失败点 | class-wp-rest-server.php |
1720 | wp_parse_url() 返回 false |
| 错位点 | class-wp-rest-server.php |
1841 | $match = $matches[$i] |
| 用邻居 handler 执行 | class-wp-rest-server.php |
1861 | respond_to_request() |
| 校验跳过点 | class-wp-rest-request.php |
834 | if (!isset($attributes['args'][$key])) continue; |
| 桥接点 | class-wp-rest-posts-controller.php |
270 | $args[$wp_param] = $request[$api_param] |
| 消毒失效点 | class-wp-query.php |
2404 | is_array() 检查失败 |
| SQL 拼装点 | class-wp-query.php |
2409 | 注入串拼入 NOT IN (...) |
| SQL 执行点 | class-wpdb.php |
3111 | get_col() 执行注入 |
第六部分:修复方案
官方修复
升级到安全版本:
-
WordPress 6.8.x → 6.8.6
-
WordPress 6.9.x → 6.9.5
-
WordPress 7.0.x → 7.0.2
临时缓解
-
在 WAF 上拦截指向
/wp-json/batch/v1与/?rest_route=/batch/v1的请求 -
通过
Disable WP REST API插件关闭未认证用户的 REST API 访问 -
部署 Searchlight Cyber 提供的
disable-batch-api-for-unauth.php插件,强制 REST Batch API 校验身份 -
排查服务器日志中针对
/batch/v1的可疑批量请求行为
总结
攻击者通过一个无法解析的探针请求让 REST API 的
$matches数组下标错位,使categories请求被posts控制器执行;由于author_exclude不在 categories 的参数表中,三道校验全部跳过,恶意 SQL 字符串直接拼入NOT IN (...)子句,形成时间盲注。整个漏洞链的本质是:校验阶段用"自己的身份证",执行阶段用"邻居的权限"------身份在校验和执行之间发生了分裂