WordPress REST batch 路由错位到 wp2shell:一次 pre-auth SQLi/RCE 链路深度分析
说明:本文基于本地 WordPress 7.0.1 源码与
wp2shell-poc还原,重点分析漏洞为什么出现、PoC 如何运行、为什么 blind SQLi 不等于 getshell,以及 7.0.2 做了哪些修复。
1. 先给结论
这不是一个单纯的 SQL 注入漏洞,而是一条完整的组合链:
WP_REST_Server::serve_batch_request_v1()的数组错位造成 route confusion- 错位后的请求把 item route 的 query 参数送进 collection handler
author_exclude -> author__not_in落到WP_Query的 SQL 拼接点- PoC 通过 blind / error / UNION 三种方式读库
- UNION 伪造
WP_Post后,进一步借 WordPress 的对象缓存、oEmbed、Customizer、动态 hook 和正常后台上传路径完成 getshell
也就是说:
blind SQLi 负责“读”,UNION 负责“快读”,getshell 依赖的是后续的 WordPress 状态机桥接。
这张图把“HTTP 无状态”和“应用层上下文”分开看:真正被利用的不是协议,而是 WordPress 在单次请求里维护的运行时状态。
2. PoC 的程序结构
PoC 入口很清晰:
wp2shell.py和wp2shell/__main__.py只是启动器cli.py提供check/read/shellclient.py负责构造 nested batch payloadsqli.py负责 blind / error / UNION 读取exploit.py负责 SQLi-to-admin bridgeshell.py负责登录、上传插件 webshell、执行命令
从设计上看,它不是“单个 payload 工具”,而是三段式:
- 可达性和路由错位确认
- SQLi 读库
- SQLi-to-admin 到 shell
3. 漏洞根因:batch 处理时的状态不同步
/batch/v1 的处理器在解析每个子请求后,会维护三组平行状态:
$requests$matches$validation
7.0.1 的问题在于,解析失败的子请求只进了 $requests 和 $validation,没有进 $matches。
后续 dispatch 时又按同一个 $i 同时读取三组数组,于是出现错位:
- 第 i 个请求的验证结果,可能被第 i+1 个请求的 handler 使用
- 最终执行的 callback,不一定对应原始 path
补丁在 7.0.2 里非常直接:错误分支也同步 append $matches[] = $single_request。
4. PoC 为什么要嵌套 batch
PoC 的 client.py 里有一颗专门的“错位种子”:
_DESYNC_PRIMER = {"method": "POST", "path": "///"}
这个 path 的作用不是访问任何网络资源,而是让 wp_parse_url() 失败,制造 WP_Error,从而打乱平行数组。
第一层错位
PoC 把一个 POST /wp/v2/posts 包进 outer batch。
这个请求在 validation 阶段看起来是 posts 请求,但 dispatch 时可能被当成 batch handler 执行,于是 body.requests 不再受 batch schema 的 method 限制。
第二层错位
再在 inner batch 里放:
GET /wp/v2/posts/999999?author_exclude=...
这个 path 先按 item route 校验,item route schema 不认识 author_exclude;但错位后,它会被 posts collection handler 的 get_items() 消费。
5. SQLi 从哪里进入
posts controller 的映射关系如下:
author_exclude->author__not_inexclude->post__not_inparent_exclude->post_parent__not_in
问题在 WP_Query:
if ( ! empty( $query_vars['author__not_in'] ) ) {
if ( is_array( $query_vars['author__not_in'] ) ) {
$query_vars['author__not_in'] = array_unique( array_map( 'absint', $query_vars['author__not_in'] ) );
sort( $query_vars['author__not_in'] );
}
$author__not_in = implode( ',', (array) $query_vars['author__not_in'] );
$where .= " AND {$wpdb->posts}.post_author NOT IN ($author__not_in) ";
}
只清洗 array,不清洗 scalar。
而 REST 这边又因为 route confusion 把本不该出现的参数放进来了。
所以 SQLi 的本质是:
schema gap + handler confusion + scalar 拼接 sink
6. 盲注、错误回显和 UNION 的分工
sqli.py 提供了三种 extractor。
6.1 BlindSQLi
通过 0) OR SLEEP(n)-- - 做时间确认,通过 -1) AND (<condition>)-- - 做布尔真/假判断。
这里的关键 oracle 不是响应 body,而是 X-WP-Total。
为什么?
因为这条链打到的是 posts collection,WP_REST_Controller 会把 WP_Query->found_posts 放进响应头。
6.2 ErrorBasedSQLi
当目标暴露 MySQL 错误时,PoC 通过 EXTRACTVALUE() / UPDATEXML() 走 error-based 读取。
这条路径更快,但依赖环境。
6.3 UnionSQLi
UnionSQLi 伪造一整行 wp_posts,把表达式值放进 post_title。
它之所以能成立,是因为 PoC 还额外做了两件事:
orderby=none,避免全局ORDER BY破坏 UNIONper_page=500,尽量让结果保持完整行模式
这一步不是为了写入数据库,而是为了让 WordPress 运行时接受一条“假的 post row”。
7. 为什么 UNION 行还能影响后续逻辑
这里最容易被误解。
PoC 并不是只想把值显示出来,它更关心的是:
- 这条假行能否被
get_post()接受 - 这条假行会不会被放进当前请求的 post cache
- 这条假行能否继续参与 oEmbed / Customizer / menu item 的处理链
WordPress 的 WP_Query 在结果集上会调用 update_post_caches() 和 _prime_post_caches(),底层会把对象放进运行期 cache。
而 update_post_cache() 甚至会把 WP_Post 规范化后塞进 wp_cache_add_multiple( ..., 'posts' )。
所以,PoC 的“假对象”在这个请求里并不只是字符串,它会成为一个可被后续代码消费的运行时对象。
8. SQLi 怎么桥到管理员创建
这条链的后半段是 WordPress 内部机制的组合拳。
8.1 先种 oEmbed cache
WP_Embed 在处理 [embed] 时,会根据 URL 生成缓存;在某些路径下,会直接创建 oembed_cache post。
PoC 利用 UNION 伪造的 post 去触发这一步,然后再用 SQL 读出真实的 oembed_cache post ID。
8.2 再构造 Customizer / Menu Item / Request 形状
PoC 把若干真实 post ID 重新安排成一条“对象图”:
customize_changesetnav_menu_itemrequestfuturedraft
这些字段不是为了好看,而是为了触发 WordPress 在 transition_post_status 里挂的默认行为。
8.3 利用动态 hook 触发 parse_request
wp_transition_post_status() 会拼出动态 hook:
{$new_status}_{$post->post_type}
当 new_status = parse、post_type = request 时,就会形成 parse_request。
WordPress 的 REST bootstrap 正是挂在 parse_request 上的:
add_action( 'parse_request', 'rest_api_loaded' );
这就解释了为什么 PoC 能在同一次请求中把一个看似普通的 post/status 转换,变成 REST 再入场。
8.4 临时管理员上下文
WP_Customize_Manager::_publish_changeset_values() 会在保存某些 setting 时临时切换当前用户:
wp_set_current_user( $setting_user_ids[ $setting_id ] )
PoC 正是利用这个“按 setting 用户切换上下文”的机制,把后续 REST 创建用户请求放进管理员身份里执行。
9. getshell 是怎么落地的
WP_REST_Users_Controller::create_item_permissions_check() 需要 current_user_can( 'create_users' )。
一旦前面的桥把当前用户切成了管理员,这个限制就自然通过。
然后 PoC 登录这个新管理员,走后台插件上传:
plugin-install.php?tab=uploadupdate.php?action=upload-plugin
上传接口本身要求 nonce 和 upload_plugins capability,因此这里已经不是 pre-auth 了,而是一个标准后台管理动作。
PoC 只是把这个动作自动化了,并把插件里的 webshell 做成随机路径和 token 防护,避免一落地就裸露。
10. 7.0.2 的修复为什么足够关键
7.0.2 的修复不是一刀,而是三刀一起下:
serve_batch_request_v1()的错位修正author__not_in的 ID 列表规范化serve_request()/rest_api_loaded()的嵌套 REST 周期阻断
这意味着:
- route confusion 先被掐掉
- SQL sink 的标量入口被清洗掉
- 嵌套 dispatch 的桥也被关掉
所以 git checkout 7.0.2 之后,这条 PoC 链整体上就断了,不是“换个 payload 还能绕过去”的那种单点修补。
11. 这种问题为什么容易漏
因为它不是单个函数的 bug,而是三个层面叠在一起:
- 分发层:请求和 handler 错位
- 参数层:schema A 校验,handler B 消费
- 数据层:array 分支清洗,scalar 分支拼接
如果只做 SQL 审计,你会看到 NOT IN (...)。
如果只做 REST 审计,你会看到 batch 分发。
真正的漏洞出现在中间那层“本不该传过去的数据,最后还是传过去了”。
12. 如何审计同类问题
我建议把 WordPress REST 审计分成四步:
12.1 先找分发器
搜所有:
rg -n "serve_batch_request|batch|match_request_to_handler|respond_to_request|allow_batch|dispatch\\("
重点看:
- 请求数组是否每个分支都同步 append
- 校验、权限、执行是否属于同一个 request
- 有没有 top-level REST 递归进入的可能
12.2 再找参数映射
rg -n "register_rest_route|permission_callback|callback|args|allow_batch"
重点看:
- item route 和 collection route 是否共享参数
- 未注册参数是否会被静默丢弃
- 同一个参数在不同 handler 下语义是否变化
12.3 再找 SQL sink
rg -n "NOT IN \\(|IN \\(|author__not_in|post__not_in|post_parent__not_in|__not_in|implode\\(.*\\(array\\)"
重点看:
is_array()分支和 scalar 分支是否一致- 是否存在
(array)包装后直接拼接 - 是否遗漏
wp_parse_id_list()/absint()
12.4 最后找状态机桥
rg -n "wp_cache_add_multiple|update_post_cache|_prime_post_caches|get_post\\(|wp_set_current_user|transition_post_status|parse_request"
重点看:
- 假对象能否进入缓存
- 动态 hook 能否触发意外状态切换
- 运行时当前用户是否会被临时替换
13. 适合写进 skill 的审计规则
如果要把这类问题长期固化进 skill,我会把规则写成三句:
- 验证的 route 和执行的 route 必须是同一个。
- 数组/标量分支必须共享同一套规范化逻辑。
- 任何 request-local 假对象进入 cache 后,都要检查它能否影响后续权限、hook 和状态转换。
14. 总结
这个漏洞最危险的地方,不在于 SQL 拼接本身,而在于它把 WordPress 的几层“看起来都合理”的机制连成了一条攻击链:
- batch 分发
- schema 校验
- query var 映射
- SQL 拼接
- 对象缓存
- oEmbed 缓存
- Customizer 发布
- 动态 hook
- 后台用户创建
- 插件上传
单看每一层都像正常功能,连起来才是漏洞。
这也是为什么它值得单独写一篇深度分析:它不是一个点,而是一条链。