WordPress 核心框架严重漏洞 wp2shell :从路由错位到 SQL 注入再到 RCE
原创 KCyber 2026-07-19 22:18 北京


最近 WordPress披露的wp2shell漏洞应该是近几年来WordPress核心框架 (非插件)中出现的最严重的漏洞了。通过组合CVE-2026-63030和CVE-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( $result, false )->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=payloadSQL注入
在 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。
由于传播、利用此文档提供的信息而造成任何直接或间接的后果及损害,均由使用本人负责,公众号及文章作者不为此承担任何责任。
