WordPress 核心框架严重漏洞 wp2shell :从路由错位到 SQL 注入再到 RCE

· 2026-07-19 22:18 · 3 阅读

原创 KCyber 2026-07-19 22:18 北京

最近 WordPress披露的wp2shell漏洞应该是近几年来WordPress核心框架 (非插件)中出现的最严重的漏洞了。通过组合CVE-2026-63030CVE-2026-63137两个漏洞,可实现SQL注入。但是默认条件下该SQL注入只是读权限无法直接getshell,所以后续实现RCE 的过程也很精彩。

REST Batch 索引错位

REST server 构造函数中注册的内置终结点 /batch/v1 :

'/batch/v1' => array(
'callback' => array$this'serve_batch_request_v1' ),
'methods'  => 'POST',
    ...
)

serve_batch_request_v1 会遍历传入的 requests :

foreach ( $batch_request['requests'as $args ) {
$parsed_url = wp_parse_url$args['path'] );
if ( false === $parsed_url ) {
$requests[] = newWP_Error(
'parse_path_failed',
__'Could not parse the path.' ),
array'status' => 400 )
        );
continue;
    }
$single_request = newWP_REST_Request(
$args['method'] ?? 'POST',
$parsed_url['path']
    );
    ...
$requests[] = $single_request;
}

因此,只要构造一个无法被 wp_parse_url 正常解析的 path ,就可以让 $requests 中产生一个错误占位:

$requests[0] = WP_Error('parse_path_failed');

关键漏洞点在检验循环中,遇到 WP_Error 时,只写了 $validation,却没有同步写 $matches :

foreach ( $requestsas$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;
    ...
}

比如外层 batch 如下:

[
  {"method":"POST","path":"http://:"},
  {"method":"POST","path":"/wp/v2/posts","body":{"requests":[...]}},
  {"method":"POST","path":"/batch/v1"}
]

解析的结果如下:

$requests = [
0 => WP_Error('parse_path_failed'),
1 => WP_REST_Request('POST''/wp/v2/posts', body=inner_batch),
2 => WP_REST_Request('POST''/batch/v1'),
];

匹配的结果却变成:

$matches = [
0 => match('/wp/v2/posts'),
1 => match('/batch/v1'),
];

即 $requests 比 $matches 对了一个错误请求的元素。往后走执行阶段继续按照 $requests 的所有拉提取元素:

foreach ( $requests as $i => $single_request ) {
if ( is_wp_error$single_request ) ) {
$result      = $this->error_to_response$single_request );
$responses[] = $this->envelope_response$resultfalse )->get_data();
continue;
    }
    ...
$match = $matches$i ];
    ...
list$route$handler ) = $match;
    ...
$result = $this->respond_to_request(
$single_request,
$route,
$handler,
$error
    );
}

当外层执行到 $i=1 时,取值如下:

$requests[1] = /wp/v2/posts 请求对象
$matches[1]  = /batch/v1 的 handle

所以这个 POST 请求对象会被交给 serve_batch_request_v1 处理,只要 body 中放了 requests 属性,就会被当成嵌套的 batch 继续处理。这是第一层混淆的错误。

内层 batch 可以继续复用同一个错位问题:

[
  {"method":"POST","path":"http://:"},
  {"method":"GET","path":"/wp/v2/categories?author_exclude=<SQL>"},
  {"method":"GET","path":"/wp/v2/posts"}
]

内层解析:

$requests = [
0 => WP_Error('parse_path_failed'),
1 => WP_REST_Request('GET''/wp/v2/categories?author_exclude=<SQL>'),
2 => WP_REST_Request('GET''/wp/v2/posts'),
];

内层匹配如下:

$matches = [
0 => match('/wp/v2/categories'),
1 => match('/wp/v2/posts'),
];

$i=1 时,取值如下:

$requests[1] = categories 请求对象
$matches[1]  = posts collection handler

比如,类似如下的请求对象将会交给 WP_REST_Posts_Controller::get_items 处理:

/wp/v2/categories?author_exclude=payload

SQL注入

在 WP_REST_Posts_Controller::get_items 中存在参数到 WP_Query 的映射:

$parameter_mappings = array(
'author'         => 'author__in',
'author_exclude' => 'author__not_in',
'exclude'        => 'post__not_in',
'include'        => 'post__in',
    ...
);
foreach ( $parameter_mappingsas$api_param => $wp_param ) {
if ( isset$registered$api_param ], $request$api_param ] ) ) {
$args$wp_param ] = $request$api_param ];
    }
}

posts collection schema 中确实注册 author_exclude

$query_params['author_exclude'] = array(
'description' => __'Ensure result set excludes posts assigned to specific authors.' ),
'type'        => 'array',
'items'       => array(
'type' => 'integer',
    ),
'default'     => array(),
);

注意这里的 $registered 来自 posts controller 自身,而 $request 却是错位后的 categories 请求对象。只要 categories 请求的查询参数中带有 author_exclude ,posts handler 就会把它映射为:

$args['author__not_in'] = $request['author_exclude'];

而 WP_Query::get_posts 对 author__not_in 的处理如下:

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) ";

这里只有 is_array 分支内采用执行 absint ,如果输入的是字符串类型就会跳过清洗,最终原始的字符串会直接拼接进入 SQL 查询,这就是最终的 SQL 注入点。

目前的 SQL注入只有读权限,可以通过时间延迟注入进行利用,也可以利用union联合查询来控制WP_Query返回的数据集,但是没有写权限。原作者构造的利用链的精彩之处在于:没有强制将只读SQL注入来变成数据库写权限,也不是读取管理员hash来强制爆破,而是尝试利用WP自身的对象缓存、文章类型和权限切换流程,把伪造的查询结果变成应用层状态执行。这里利用到了Customizer changeset以及依赖父子关系触发等等。有了新建的管理员账户后,通过上传恶意插件就可以getshell 了。

修复方式

在 7.0.2 的修复版本中在两个地方做了修复。其中,在 WP_Error 分支补了 $matches

foreach ( $requestsas$single_request ) {
if ( is_wp_error$single_request ) ) {
$has_error    = true;
$matches[]    = $single_request// fix
$validation[] = $single_request;
continue;
     }

另外将 author__not_in 改成统一走 wp_parse_id_list

if ( ! empty$query_vars['author__not_in'] ) ) {
$author__not_in_id_list = wp_parse_id_list$query_vars['author__not_in'] );
if ( count$author__not_in_id_list ) > 0 ) {
sort$author__not_in_id_list );
$where .= sprintf(
" AND {$wpdb->posts}.post_author NOT IN (%s) ",
implode','$author__not_in_id_list )
        );
$query_vars['author__not_in'] = $author__not_in_id_list;
    }
 }

wp_parse_id_list 内部会调用 wp_parse_list,再对每个元素执行 absint。因此字符串型 SQL payload 不会再以 SQL 片段的形式进入 NOT IN

由于传播、利用此文档提供的信息而造成任何直接或间接的后果及损害,均由使用本人负责,公众号及文章作者不为此承担任何责任。

跳转微信打开