500 triệu - đây là số site Wordpress nhận được cảnh báo về chuỗi khai thác mới mang tên WP2shell. Wp2shell là sự kết hợp giữa CVE-2026-63030 và CVE-2026-60137, 2 lỗ hổng tồn tại trong source code của WP Core. Theo thông báo của Wordpress đây là lỗ hổng preauth RCE đầu tiên trên WP Core sau gần một thập kỷ.

- CVE-2026-60137: Lỗ hổng nằm trong cách WordPress xây dựng câu truy vấn SQL cho tham số author__not_in của WP_Query. Giá trị truyền vào được interpolate trực tiếp vào mệnh đề NOT IN(...) mà không qua prepared statement hay type casting, cho phép attacker inject SQL tùy ý. Trên WordPress 6.8.x, lỗ hổng này chỉ có thể khai thác nếu có plugin/theme chuyển tiếp tham số người dùng thẳng vào WP_Query. Từ 6.9.0 trở đi, lỗ hổng có thể reach unauthenticated thông qua REST API khi kết hợp với CVE-2026-63030.
- CVE-2026-63030: Lỗ hổng nằm trong hàm serve_batch_request_v1() tại class-wp-rest-server.php, được giới thiệu từ bản refactor trong WordPress 6.9.0. Khi một sub-request có path không hợp lệ (ví dụ ///), WP_Error được push vào mảng $validation[] nhưng bị bỏ qua với mảng $matches[], khiến hai mảng lệch offset . Hệ quả là server xảy ra nhầm lẫn khi gọi sai handler xử lý cho request, mở ra con đường cho attacker lồng GET request bên trong batch và bypass schema validation.
Trong chain khai thác WP2shell: CVE-2026-63030 đóng vai trò là “người mở đường” để đi vào khai thác tiếp CVE-2026-60137. Chain lỗ hổng này sau khi kết hợp trở thành lỗ hổng preauth RCE.
Với mức độ nguy hiểm như vậy, bài viết này sẽ đi sâu phân tích cơ chế kỹ thuật của cả hai lỗ hổng, dựng lab khai thác thực tế để minh họa toàn bộ attack chain từ batch route confusion đến SQL injection và remote code execution, đồng thời đưa ra các dấu hiệu nhận biết rõ ràng cùng tri thức rule phục vụ công tác giám sát - phát hiện và ứng phó sự cố.
Root Cause
CVE-2026-60137
Theo thông báo của WP, đã có các bản vá Patched versions: 6.8.6, 6.9.5, and 7.0.2.
Tải 2 bản 7.0.1 và 7.0.2 về diff xem dev đã patch những gì. Sau khi diff, phát hiện vị trí bug SQL Injection đã được patch:

Code lỗi: /wp-includes/class-wp-query.php (function get_posts() line:2399-2413 )
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) ";
} 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 {$wpdb->posts}.post_author IN ($author__in) ";
}Nhìn thẳng vào đoạn code, SQL Injection xảy ra khi giá trị biến $author__not_in được interpolate trực tiếp vào điều kiện where mà không có prepare statement. Tuy nhiên, trước khi được đưa vào query, bắt buộc $query_vars['author__not_in'] phải tồn tại dạng string để vượt qua is_array(). Nếu không, code xử lý:
$query_vars['author__not_in'] = array_unique( array_map( 'absint', $query_vars['author__not_in'] ) );sẽ đưa toàn bộ giá trị trong mảng $query_vars['author__not_in'] về mảng số nguyên không âm, SQL Injection sẽ không xảy ra.
Nhìn vào đoạn code patch lỗ hổng:
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 )
);
/** Update the query var for stable cache key generation in {@see self::generate_cache_key()}. */
$query_vars['author__not_in'] = $author__not_in_id_list;
}Hàm wp_parse_id_list() chịu trách nhiệm xử lý dữ liệu đến từ $query_vars['author__not_in'] đầu tiên khi đi qua điều kiện if():
function wp_parse_id_list( $input_list ) {
$input_list = wp_parse_list( $input_list );
return array_unique( array_map( 'absint', $input_list ) );
}
function wp_parse_list( $input_list ) {
if ( ! is_array( $input_list ) ) {
return preg_split( '/[\s,]+/', $input_list, -1, PREG_SPLIT_NO_EMPTY );
}
// Validate all entries of the list are scalar.
$input_list = array_filter( $input_list, 'is_scalar' );
return $input_list;
}Bất kể $query_vars['author__not_in'] là string hay array thì wp_parse_id_list() đều trả về 1 mảng các phần tử số nguyên không âm, patch thành công SQL Injection.
Giá trị author__not_in được mapping từ author_exclude:
wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php

Test thử trực tiếp với giá trị tham số truyền trực tiếp từ request xem có trigger lỗ hổng không.
GET method lấy post chứa tham số author_exclude là integer:

⇒ Giá trị 1 được mapping từ biến author_exclude → author__not_in và đi vào logic xử lý hàm get_posts().
Với request chèn thêm payload SQL Injection:


→ payload không vượt qua được hàm check integer.
Điều này giải thích tại sao bug SQLi không đứng một mình mà cần kết hợp thêm CVE-2026-63030.
CVE-2026-63030
Như công bố, đây là một lỗi REST API batch endpoint route confusion. Lỗi nằm tại logic xử lý hàm
serve_batch_request_v1(), file wp-includes/rest-api/class-wp-rest-server.php, line 1709-1867.
Xem bản vá tại hàm này:

Dev chỉ thêm một dòng duy nhất, vậy cơ chế lỗi khả năng đến từ $matches[].
Đầu tiên, serve_batch_request_v1() chịu trách nhiệm xử lý các batch request trong body của POST method với batch handler, api: /batch/v1.
public function serve_batch_request_v1( WP_REST_Request $batch_request ) {
$requests = array();
foreach ( $batch_request['requests'] as $args ) {
LOOP_0
}
$matches = array();
$validation = array();
$has_error = false;
foreach ( $requests as $single_request ) {
**LOOP_1**
}
foreach ( $requests as $i => $single_request ) {
LOOP_2
}
return new WP_REST_Response( array( 'responses' => $responses ), WP_Http::MULTI_STATUS );
}Đầu tiên khởi tạo 2 mảng:
$matches = array(); // chỉ lưu các request hợp lệ
$validation = array(); // lưu all request đã validate, kể cả các request error. Trong vòng lặp foreach LOOP_1:
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;
}Vòng lặp xử lý từng request đơn lẻ trong batch request, thực hiện check:
is_wp_error($single_request )- nếu là một request lỗi (vd: sai path), request sẽ được push vào mảng $validation[], sau đó nhảy tới duyệt request khác bằng continue, match[] bị skip.
- nếu request khớp match_request_to_handler( $single_request ), request được lưu vào mảng matches[].
Mình tiếp tục trace đến vòng lặp LOOP_2:
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();
}Điểm mấu chốt ở chỗ:
$match = $matches[ $i ];Theo kết quả phân tích ở trên mảng match[] chỉ push những request hợp lệ, vậy nếu tồn tại các request lỗi, thì việc gán như trên sẽ xảy ra sự sai lệch về index của mảng, khiến cho từng request sẽ bị xử lý sai và đây chính là confusion.
Hình dung dễ hiểu:

Vậy nguyên nhân lỗi đến từ việc lợi dụng confusion trong mảng matches[] để gọi nhầm handler xử lý request dẫn tới luồng đi vào hàm get_posts() có lỗi SQL Injection.
Ví dụ trên cho thấy: request[1] trong loop 2 sẽ được matches[i=1] là posts_handler xử lý, nhưng handler này đúng ra phải xử lý request[2].
Bug này thuần về logic một chút nên mình mất khá nhiều thời gian để hiểu ….
Exploit and Debug
Tóm tắt các bước từ khai thác batch route confusion → SQL Injection → RCE:

Batch route confusion + SQL Injection
Quan sát schema của endpoint /batch/v1:
'/batch/v1' => array(
'callback' => array( $this, 'serve_batch_request_v1' ),
'methods' => 'POST',
'args' => array(
'validation' => array(
'type' => 'string',
'enum' => array( 'require-all-validate', 'normal' ),
'default' => 'normal',
),
'requests' => array(
'required' => true,
'type' => 'array',
'maxItems' => $this->get_max_batch_size(),
'items' => array(
'type' => 'object',
'properties' => array(
'method' => array(
'type' => 'string',
'enum' => array( 'POST', 'PUT', 'PATCH', 'DELETE' ),
'default' => 'POST',
),
'path' => array(
'type' => 'string',
'required' => true,
),
'body' => array(
'type' => 'object',
'properties' => array(),
'additionalProperties' => true,
),
'headers' => array(
'type' => 'object',
'properties' => array(),
'additionalProperties' => array(
'type' => array( 'string', 'array' ),
'items' => array(
'type' => 'string',
),→ endpoint /batch/v1 chỉ cho phép POST method, tham số request trong body yêu cầu các method phải thuộc ('POST', 'PUT', 'PATCH', 'DELETE') và subbody được xem là object (đây là 1 điểm quan trọng).
Tuy nhiên, để kích hoạt SQL Injection, request phải đi vào get_posts(), schema yêu cầu GET method.
Từ các dữ liệu đã phân tích cũng như tham khảo một số nguồn tài liệu, giải pháp khai thác sẽ là lồng các batch request vào nhau gọi là outer batch và inner batch, lợi dụng cơ chế confusion để bypass. Request đơn giản, dễ hiểu nhất như sau:

- outer batch request: POST, POST, POST
- inner batch request: POST, GET, GET
Trong bài phân tích của nhóm tác giả tìm ra lỗ hổng, họ dùng widget làm carrier, cơ chế desync giống posts, nhưng có ưu điểm là tính ổn định khi schema validation. Tuy nhiên, ở bài phân tích này mình thử dùng posts cho khai thác confusion + SQL Injection và kết hợp thêm widget ở giai đoạn RCE để xem cơ chế có gì khác và bypass schema validation như thế nào.
Path “///” đóng vai trò là một path không hợp lệ khiến cho wp_parse_url trả về false. Đây là tiền đề để gây ra confusion khiến hai mảng $matches[] và $validation[] lệch offset.

Phân tích luồng xử lý của server với outer batch request:

Có thể thấy rõ thực tế 2 mảng này lệch index như thế nào:

→ index của hai mảng matches[] và validation[] bị lệch.
→ Khi xử lý request[1] (dẫn vào inner batch) handler gọi ra matches[1] lại là của request[2], chính là batch handler, handler này xử lý:
"method": "POST",
"path": "/wp/v2/posts",
"body": {
"requests": [{
"method": "POST",
"path": "///"},
{
"method": "GET",
"path": "/wp/v2/users?author_exclude=0%29%20AND%20%28ASCII%28SUBSTRING%28COALESCE%28%28SELECT%20%40%40version%29%2C%27%27%29%2C1%2C1%29%29%20%3E%200%29--%20-"},
{
"method": "GET",
"path": "/wp/v2/posts"
}]→ Đây là inner batch request, route /wp/v2/posts vượt qua cơ chế kiểm tra của schema khi route không phải là /batch/v1.
Tiếp theo, sau khi lợi dụng confusion để bypass check method và gọi batch handler để xử lý, phân tích tiếp luồng xử lý cho inner batch request:

Loop 1 chịu trách nhiệm match từng request với handler tương ứng và build 2 mảng song song là $matches[] và $validation[].
Loop 2 dùng chính index $i của $requests để lấy $matches[$i] làm handler dispatch. Vì $matches đã bị lệch 1, khi xử lý inner[1] (request của /wp/v2/users chứa PAYLOAD), đọc $matches[1] - tức là handler của posts get_items, không phải users. Kết quả: request của users được dispatch dưới posts handler, author_exclude không qua schema validation của users nên PAYLOAD được giữ nguyên, posts get_items map nó sang author__not_in và interpolate thẳng vào câu lệnh SQL, kích hoạt CVE-2026-60137 kích hoạt.
Phân tích đến đây thì mình có ý nghĩ sao không dùng luôn subrequest kiểu:

mà phải lợi dụng confusion lần 2 để đi vào get_posts().
Debug kỹ một chút thì mình phát hiện tham số author_exclude sẽ phải đi qua các hàm check:

Ta nhận thấy payload được gán cho tham số author_exclude không bypass được các hàm check, hơn nữa tham số này trong /wp/v2/posts yêu cầu kiểu dữ liệu array, dẫn đến khi chạy tới get_posts() sẽ không thành công kích hoạt SQL Injection như đã phân tích ở trên.
- Endpoint /wp/v2/users không hỗ trợ tham số author_exclude, nên không được các hàm check xử lý → bypass.
- Khi sử dụng /wp/v2/users thì cần handler của get_posts mới có thể đi vào code lỗi SQL Injection.
→ Do đó, cần confusion lần 2.
Kiểm tra handler của request chứa payload SQL Injection:

Đây là request gọi tới get_users(), tuy nhiên handler gọi ra lại là handler xử lý get_posts() có tham số author_exclude chứa payload. Như vậy bước confusion đã thành công.
Nhìn vào stack trace:

→ Đến đây author_exclude được map sang author__not_in và kiểu dữ liệu vẫn là string.
Sau đó, đi tới hàm get_posts():

Payload vẫn kiểu string → bypass hàm check is_array() , author__not_in được append vào biến $where sau đó interpolate vào câu lệnh truy vấn.
Đến đây, cơ bản khai thác batch route confusion + SQL Injection đã hoàn thành.

RCE
Theo báo cáo, đây là một chain bug preauth RCE, nên mục tiêu khai thác cuối cùng vẫn là thực thi mã từ xa.
Số đông trong chúng ta khi nghe đến SQL Injection sẽ nghĩ đến việc dump/crack password. Tuy nhiên, nhóm tác giả đã bỏ qua hướng đó vì việc crack password tốn thời gian và phụ thuộc vào độ mạnh yếu của mật khẩu. Thay vào đó, biến SQLi thành một object factory, tạo ra các record giả mạo trong memory đánh lừa các tiến trình nội bộ của WordPress rằng một changeset của Customizer đã được thiết lập lịch từ thời gian trong quá khứ, khiến WordPress tự động chuyển nó sang trạng thái publish.

Object Cache Poisoning

Với kịch bản trong lỗ hổng này, UNION được sử dụng với mục đích biến nó thành “object factory”. Với khả năng gộp các kết quả query, ta có thể chèn thêm điều kiện AND 1=0 để triệt tiêu kết quả của truy vấn gốc, ép cơ sở dữ liệu trả về các hàng dữ liệu giả mạo, biến chúng thành các đối tượng WP_Post độc hại nằm sẵn trong bộ nhớ đệm của WordPress(Object Cache Poisoning).
per_page=500 hoặc per_page=-1:
wp_using_ext_object_cache() || ( ! empty( $limits ) && $query_vars['posts_per_page'] < 500 )Thông thường, nếu truy vấn không có filter đặc biệt và giới hạn bài viết dưới 500 hoặc $limit rỗng, WP_Query sẽ tối ưu bằng cách đi vào nhánh “split path” và chỉ lấy cột wp_posts.ID trước, sau đó mới query chi tiết. Tuy nhiên, payload UNION cần trả về đầy đủ 23 cột giả mạo. Nó không thể nằm vừa trong một truy vấn chỉ có 1 cột ID.
Vậy mã khai thác lợi dụng logic xử lý này để chèn giá trị cho per_page và bypass:
- Dùng per_page=500, WP_Query vượt ngưỡng giới hạn < 500 của Split Query.
- Hoặc dùng per_page=-1, WP_Query hiểu là lấy toàn bộ (không có mệnh đề LIMIT), khiến $limits bị rỗng và cũng bypass được Split Query.
23 columns: Cấu trúc 23 cột dữ liệu của bảng wp_posts , tránh gặp lỗi với toán tử UNION.
6 fake rows: dữ liệu sinh ra từ UNION tương ứng với 6 đối tượng giả mạo cần thiết để xây dựng một đồ thị đối tượng (Object Graph) hoàn chỉnh nhằm thực thi chuỗi RCE.

Customize Changeset
Trong WordPress, Customize Changeset là một cơ chế hoạt động giống như một "bản nháp" dành cho các thiết lập giao diện (Appearance —> Customize). Tính năng này cho phép người dùng thay đổi màu sắc, bố cục, menu, widget và xem trước giao diện thực tế mà không làm ảnh hưởng đến website đang hoạt động, cho đến khi họ bấm nút Publish.
Dưới góc độ kỹ thuật, Customize Changeset không phải là một bảng dữ liệu độc lập mà có cấu trúc đặc thù:
- Custom Post Type: Nó thực chất là một bài viết ẩn được lưu trong bảng wp_posts với định dạng post_type là customize_changeset.
- Lưu trữ bằng JSON: Toàn bộ các thiết lập thay đổi của người dùng được đóng gói thành một chuỗi dữ liệu JSON và lưu trữ bên trong trường post_content.
- Hẹn giờ xuất bản: Giống như một bài đăng blog thông thường, Changeset có thể được đặt trạng thái future (lên lịch) và sẽ tự động chuyển thành publish khi mốc thời gian đến hạn.
post_type = customize_changeset
post_status = future
post_date_gmt = 2020-01-01 00:00:00Liên hệ với WP2Shell: từ SQL Injection có thể nạp một changeset giả mạo vào bộ nhớ đệm, status: future, ngày giờ từ quá khứ và chứa user_id của Admin thật. WordPress Customizer đọc user_id và kiểm tra:
wp_set_current_user( $user_id ); // wp-includes/user.php line 3832thấy user_id là của Admin và ngày xuất bản đã qua nên lập tức chuyển nó thành publish, kích hoạt hàm đổi quyền và biến request thành request sở hữu đặc quyền quản trị cao nhất.
Admin user_id
Để lấy được user_id của Admin, ta cần hiểu WordPress lưu quyền user trong bảng wp_usermeta dưới dạng PHP serialized string, không phải plain text. Muốn biết user_id của admin phải JOIN wp_users với wp_usermeta , tìm user có meta_key = 'wp_capabilities' và meta_value chứa chuỗi s:13:"administrator";b:1; . Kết quả được wrap vào CONCAT(0x7c7c, HEX(...), 0x7c7c) → reflect về trong post_title của fake row dưới dạng ||31|| → decode hex → admin ID = 1.
- Fake row: Gửi một query thăm dò với một post giả mạo mang ID rác (ví dụ 999999). Nội dung phản hồi của bài viết này sẽ chứa kết quả chuỗi hex.

Reentrant REST dispatch
Reentrant REST dispatch có thể hiểu là trong khi WordPress đang xử lý một REST API request (dispatch hiện tại), quá trình xử lý đó lại kích hoạt một lần REST dispatch khác ngay bên trong, trước khi lần dispatch ban đầu hoàn tất.
- Ngòi nổ từ Hook động: Trong quá trình WordPress tự động sửa lỗi cấu trúc vòng lặp, đối tượng giả mạo mang post_type = request và post_status = parse bị cập nhật. Quá trình này luôn kích hoạt một hook chuyển đổi trạng thái động: do_action("parse_request"). WP Core có đoạn code liên kết hook này với API:
add_action( 'parse_request', 'rest_api_loaded' ); //default-filters.php - line 535- Khi hàm rest_api_loaded() chạy, nó thấy global route của request ban đầu (vẫn đang là /batch/v1) nên nó gọi hàm WP_REST_Server::serve_request('/batch/v1') một lần nữa. Đáng chú ý là tại các phiên bản bị lỗi, WordPress không có cơ chế chặn việc khởi động mộtREST API request mới trong khi REST server vẫn đang trong quá trình dispatch request cũ.
- Việc gọi lại này diễn ra đồng bộ (synchronously) ngay bên trong hàm save() của Customizer. Lúc này, lệnh khôi phục danh tính của user thật vẫn chưa chạy, đồng nghĩa với việc REST API request mới sẽ bắt đầu với danh tính đang nằm trong bộ nhớ là Admin.
Tuy nhiên, khi REST API request mới khởi động, nó phải đi qua các logic kiểm tra của Wordpress. Vậy nó vượt qua như thế nào:
function rest_cookie_check_errors( $result ) {
if ( ! empty( $result ) ) {
return $result;
}
global $wp_rest_auth_cookie;
/*
* Is cookie authentication being used? (If we get an auth
* error, but we're still logged in, another authentication
* must have been used).
*/
if ( true !== $wp_rest_auth_cookie && is_user_logged_in() ) {
return $result;
}Nếu xác thực bằng cookie không được sử dụng (true !== $wp_rest_auth_cookie) và hệ thống nhận thấy user hiện tại đã đăng nhập thành công (is_user_logged_in()), nó sẽ lập tức trả về kết quả mà không xử lý gì thêm.
→ Thành công gọi lại context Admin do hàm wp_set_current_user của Customizer vừa thiết lập trong bộ nhớ trước đó và gọi POST /wp/v2/users tạo user Admin mới.
Create Admin user và Upload Plugin
Khi request tạo user (POST /wp/v2/users) được gọi tiếp ngay trong cùng một vòng đời xử lý (reentrant request), hàm current_user_can('create_users') kiểm tra xem user hiện tại.
function create_item_permissions_check() {
if ( ! current_user_can( 'create_users' ) ) {
return new WP_Error( 'rest_cannot_create', ... );
}
return true;
}Vì current_user đã bị đổi thành Admin ID, hàm trả về true. Kết quả là tài khoản Admin mới được ghi thẳng vào database thật.

Từ đây, mọi thứ trở nên đơn giản với role admin. Đơn giản có thể xác thực và upload một plugin độc hại chứa webshell sau đó RCE.

→ Kết quả trả về cho thấy, RCE thành công.
Dấu hiệu nhận biết
Dấu hiệu tấn công
Khi xảy ra tấn công, hệ thống sẽ nhận hàng loạt request được gửi đến endpoint /wp-json/batch/v1:

Đặc điểm request trong từng giai đoạn khai thác của attacker:
Giai đoạn 1 - Reconnaissance: Attacker bắt đầu bằng các request thăm dò sơ bộ. GET /wp-json/ trả về field version xác nhận WordPress 7.0.0, 7.0.1,… trong phạm vi dễ bị tấn công. Tiếp theo, probe batch endpoint với body rỗng - HTTP 207 xác nhận endpoint mở và không cần xác thực. Cuối cùng, boolean differential (AND 1=1 vs AND 1=0) xác nhận SQLi CVE-2026-60137.
Attacker recon route format:

Kiểm tra route cho phép truy cập mà không cần xác thực:

Các request để xác nhận sự tồn tại của lỗ hổng SQL Injection. Payload SQL được đặt trong tham số author_exclude.

Ở giai đoạn thăm dò ban đầu này, các dấu hiệu thường chưa thực sự nổi bật vì mức độ tác động lên hệ thống còn thấp. Access log chủ yếu ghi nhận các request GET và POST cơ bản với kích thước payload khá nhỏ (Content-Length chỉ từ vài byte đến khoảng 500 bytes).
Giai đoạn 2 - Thu thập thông tin DB, fake admin account:
Lúc này, quá trình khai thác thực sự bắt đầu. Kẻ tấn công dùng SQL Injection để thu thập table prefix (wp_), Admin ID,… sau đó lợi dụng cơ chế Customizer để mượn quyền quản trị viên nhằm tạo tài khoản mới. Về mặt log mạng, giai đoạn này xuất hiện hàng chục request POST nhắm thẳng vào endpoint /batch/v1. Đặc điểm nhận diện rõ nét nhất là sự gia tăng đột biến về kích thước dữ liệu: các request chứa payload SQL thăm dò có dung lượng khoảng ~1KB, trong khi request dùng để tạo tài khoản Admin có Content-Length lên tới ~5KB.

Giai đoạn 3 - RCE:
Sau khi tạo được tài khoản admin, attacker ngừng khai thác lỗ hổng batch route confusion và SQL Injection ở endpoint /batch/v1 và chuyển sang đăng nhập, xác thực để upload plugin độc hại và RCE.

→ Có thể thấy sau khi login bằng tài khoản admin, attacker đã upload plugin độc hại và thực thi mã từ xa.
Dựa trên dấu hiệu từng giai đoạn tấn công, có thể xâu chuỗi hành vi để xác định được cuộc tấn công đã và đang xảy ra trên hệ thống của mình.
Logs
Apache/Nginx access log:


→ Quan sát access log cho thấy lượng lớn truy cập đến endpoint /batch/v1 từ cùng một nguồn. Trình tự log thu được khớp với chuỗi hành vi tấn công.

→ Đây là log access cho chuỗi hành vi log in admin account, upload webshell và RCE.
Dấu hiệu trên ứng dụng Wordpress
Dấu hiệu xuất hiện rõ ràng trên ứng dụng WP trong giai đoạn RCE.
Account Admin mới được tạo:

Xuất hiện các fake post trong DB, các post xuất hiện liên tục có post_type dạng:

Plugin độc hại chứa webshell:

File Webshell:

How to Mitigate
- Update ngay các bản vá 6.8.6, 6.9.5, 7.0.2 tương ứng.
- Xóa ngay các plugin độc hại trên hệ thống nếu có, dựa trên các dấu hiệu ở trên.
- Kiểm tra các kết nối ra bên ngoài (reverse shell), hunting malware trên máy,… nếu phát hiện hệ thống đã bị tấn công.
- Tạo rule giám sát - phát hiện hành vi tấn công.
Rule Detection
Logic rule phát hiện batch route attack:
POST /wp-json/batch/v1
body chứa "path": path không hợp lệ (ex: "///", ":") ← primer tạo WP_Error
body chứa requests lồng nhau (nested "requests" trong "body")Logic rule bắt SQL Injection + route item attack:
/wp/v2/posts/?author_exclude=...&orderby=none&per_page=500
Bất thường:
- : là giá trị post_id giả mạo, không có thật (ex:0 hoặc 999999).
- orderby=none không phải giá trị hợp lệ của WordPress
- per_page>=500 vượt maximum=100 or per_page=-1 Logic rule nhận biết create admin account:
POST /wp/v2/users
body chứa "roles":["administrator"]Nên kết hợp các rule trên thành 1 rule tương quan để bắt hành vi tấn công này 1 cách chính xác nhất:
1 = POST /batch/v1 có body chứa "requests"
→ batch route detection
2 = POST /batch/v1 có body chứa "UNION SELECT"
→ SQLi: các phase thăm dò DB
3 = POST /batch/v1 có body chứa "UNION ALL SELECT"
AND body chứa "customize_changeset"
4 = POST /wp/v2/users có body chứa
"roles":["administrator"]
→ fake admin account creationLưu ý: Trong thực tế khai thác WP2Shell như thực nghiệm ở trên, request mang payload SQLi đi qua batch_handler, và payload nằm bên trong body JSON của request POST /batch/v1, chứ không nằm trên URL của request thông thường. Việc tạo rule bắt các query string trên URL có thể không bắt được các cuộc tấn công bọc payload bên trong mảng requests của /batch/v1. Rule teams nên lưu ý để tạo rule bao quát được cả trường hợp này.
Kết luận
CVE-2026-63030 và CVE-2026-60137 là minh chứng điển hình cho kiểu tấn công theo chain - mỗi lỗ hổng đơn lẻ có mức độ ảnh hưởng giới hạn, nhưng khi kết hợp lại tạo thành một vector tấn công preauth RCE hoàn chỉnh trên WordPress Core. CVE-2026-63030 xuất phát từ một lỗi logic nhỏ trong quá trình refactor - chỉ một dòng code bỏ qua việc đồng bộ mảng nhưng hệ quả là toàn bộ cơ chế route dispatch bị phá vỡ, tạo tiền đề để CVE-2026-60137 có thể reach unauthenticated. Điểm đáng chú ý nhất của chain này là kỹ thuật leo quyền không cần crack hash - thay vì đọc password hash rồi brute force, attacker lợi dụng chính cơ chế hợp lệ của WordPress Customizer để mượn identity admin thật, tạo tài khoản mới hoàn toàn qua application layer. Điều này cho thấy ranh giới giữa tính năng hợp lệ và attack surface ngày càng mờ nhạt trong các hệ thống phức tạp.
By @prototw from PurpleTeam.
