แกะ Exploit Chain ของช่องโหว่ wp2shell: WordPress RCE ที่ถูกพบโดย GPT5.6
What is wp2shell
wp2shell คือ exploit chain ที่พบใน WordPress Core version 6.9.0 - 6.9.4 และ 7.0.0 - 7.0.1 ที่ Adam Kues จาก Searchlight Cyber ค้นพบโดยใช้ GPT5.6 Sol Ultra โดยช่องโหว่นี้ทำให้ unauthenticated attacker สามารถขยับจาก REST API ไปจนถึงการ remote code execution บนเซิร์ฟเวอร์ได้ หรือที่เรียกว่า pre-authentication RCE
ในตอนแรกหากดูผิวเผินจะเข้าใจว่า exploit chain เกิดจากช่องโหว่สองรายการ:
- CVE-2026-63030: Batch API Route Confusion ทำให้ validation กับ handler จับคู่ผิดกัน
- CVE-2026-60137: SQL injection ใน
author__not_inของWP_Query
ผู้โจมตีจะใช้ช่องโหว่ Batch API Route Confusion เพื่อ bypass validation และทำให้เกิด SQLi ขึ้นได้ แต่ปัญหาก็คือ SQL injection ที่พบเป็น read only SQL injection แล้วช่องโหว่นี้สุดท้ายแล้วสามารถสร้าง admin account ขึ้นมาใหม่ได้ยังไง
เมื่อลองทำความเข้าใจดูแล้วพบว่า exploit chain นี้เชื่อมช่องโหว่ทั้งสองเข้ากับ WordPress Feature อีกหลายอย่าง ตั้งแต่ Batch API, SQLi, object cache, oEmbed, changeset, post hierarchy ไปจนถึง hook เพื่อ escalate pre-auth read-only SQLi จนได้สิทธิ์ WordPress Admin
TLDR
Exploit chain ของ wp2shell เป็นการ chain ช่องโหว่ Route Confusion (CVE-2026-63030) และ SQLi (CVE-2026-60137) เข้ากับ WordPress Feature ต่าง ๆ จนสามารถได้สิทธิ์ admin ชั่วคราวเพื่อสร้าง admin account ใหม่ได้สำเร็จ (ได้ WordPress admin account ก็ได้ RCE จากการ upload malicious Plugin)
1. Batch desync เพื่อ bypass Batch method schema และเปิดทางให้ใช้ method GET ใน batch request
2. Inner desync เพื่อ bypass `author_exclude` schema และส่ง string เข้า `author__not_in` เพื่อโจมตี SQLi
3. UNION-based SQLi เพื่อ poison post objects ใน in-memory cache
4. ใช้ oEmbed cache flow โดยให้ `wp_insert_post()` seed records จริง และให้ `wp_update_post()` merge poisoned fields เพื่อเริ่มสร้าง fake posts ใน database
5. ใช้ Post Hierarchy/Cycle Detection เพื่อ trigger `wp_update_post()` ที่ override แค่ `ID` กับ `post_parent` ทำให้ `post_content` ปลอมจาก object cache ถูกเขียนลง database
6. ปลอม post ให้เป็น type `customize_changeset` พร้อมกำหนด `post_status` เป็น `future` และใส่วันที่ในอดีต เพื่อให้ WordPress normalize status เป็น `publish`
7. ใช้ changeset ที่กำหนด `user_id` ของ admin เพื่อให้ WordPress เปลี่ยน current user เป็น admin ชั่วคราวระหว่าง apply changeset
8. ใช้ Customizer setting ที่มี save callback สำหรับ publish draft post เพื่อ trigger `wp_update_post()` ขณะที่ WordPress ยังอยู่ใน admin context
9. ใช้ Post Hierarchy/Cycle Detection เพื่อสร้าง post ปลอมที่มี `post_status` เป็น `parse` และ `post_type` เป็น `request`
10. ใช้ Post Status Hook เพื่อประกอบชื่อ hook ให้กลายเป็น `parse_request`
11. ใช้ `parse_request` trigger batch request เดิมซ้ำขณะที่ current user ยังเป็น admin
12. เพิ่ม request ซ่อนไว้ใน batch เพื่อสร้าง admin account
13. ใช้ admin account login เข้า WordPress และอัปโหลด plugin ที่มี PHP code เพื่อให้ WordPress execute PHP code จาก plugin และทำให้ attacker ได้ RCE
บทความนี้จะพาไปดูตั้งแต่ต้นจนจบว่าเกิดอะไรขึ้นบ้างใน wp2shell exploit chain ทำอย่างไรถึงสามารถ escalate pre-auth read-only SQLi จนกลายเป็น pre-auth RCE ได้
Batch API Route Confusion
เริ่มจาก WordPress มี REST API แบบ batch สำหรับรวมหลาย request ไว้ใน HTTP request เดียว เช่น แก้ชื่อ post หลายตัวพร้อมกัน แทนที่จะยิง request แยกทีละ post
ปกติ request นึงจะผ่านขั้นตอนประมาณนี้:
- ตรวจว่า parameter ครบและ data type ถูกต้องด้วย has_valid_params()
- sanitize ข้อมูลด้วย sanitize_params()
- ทำ permission check
- เรียก handler ของ endpoint นั้น
แต่ Batch API แบ่ง loop check เป็นสองรอบ รอบแรก validate request ทุกตัว แล้วรอบสองค่อยเรียก handler ทุกตัว เพื่อเก็บผลการ validate และ handler ที่จับคู่ไว้ ซึ่งโดยปกติ WordPress จะใช้ array สองชุดที่มี index ตรงกันเสมอ
- For each request ใน batch request:
- ตรวจว่า parameter ครบและ data type ถูกต้องด้วย has_valid_params()
- sanitize ข้อมูลด้วย sanitize_params()
- For each request ใน batch request:
- ตรวจว่า validation ผ่าน
- ทำ permission check
- เรียก handler ของ endpoint นั้น
ปัญหาเกิดเมื่อ batch มี request ที่ malformed
logic ส่วนที่มีปัญหาอยู่ใน WP_REST_Server::serve_batch_request_v1() รอบแรกจะสร้าง $matches สำหรับเก็บ route/handler และ $validation สำหรับเก็บผลการ validate:
$matches = array();
$validation = array();
foreach ( $requests as $single_request ) {
if ( is_wp_error( $single_request ) ) {
$has_error = true;
$validation[] = $single_request;
continue; // ไม่มี $matches[] สำหรับ index นี้
}
$match = $this->match_request_to_handler( $single_request );
$matches[] = $match;
// has_valid_params() และ sanitize_params() ถูกเรียกตรงนี้
$validation[] = $error ? $error : true;
}
เมื่อ $single_request เป็น WP_Error ตัว logic จะเพิ่ม element ให้แค่ $validation แล้ว continue โดยไม่ได้เพิ่ม element ให้ $matches ผลก็คือ array สองชุดเหลื่อมกันหนึ่งตำแหน่ง
แต่ว่ารอบ execution ของทั้งสอง array ยังใช้ $i จาก $requests ชุดเดิม:
foreach ( $requests as $i => $single_request ) {
if ( is_wp_error( $single_request ) ) {
continue;
}
$match = $matches[ $i ];
$error = is_wp_error( $validation[ $i ] )
? $validation[ $i ]
: null;
list( $route, $handler ) = $match;
$result = $this->respond_to_request(
$single_request,
$route,
$handler,
$error
);
}
สมมุติว่าถ้า batch มี 3 requests และ request แรก malformed array ทั้งสามชุดจะวางตัวแบบนี้:
| Index | $requests |
$validation |
$matches |
|---|---|---|---|
| 0 | error | error ของ request 0 | handler ของ request 1 |
| 1 | request 1 | ผ่านตาม schema ของ request 1 | handler ของ request 2 |
| 2 | request 2 | ผ่านตาม schema ของ request 2 | - |
execution loop ข้าม index 0 เพราะเป็น error จากนั้นที่ index 1 มันจะนำ ข้อมูลของ request 1 ไปทำงานกับ handler ของ request 2 ส่วน $matches[0] ซึ่งเป็น handler ที่ถูกต้องของ request 1 จะไม่เคยถูกเรียกใช้งาน
ลองนึกภาพเป็นครูมีเอกสารสองกอง:
- กองแรกคือ "คำตอบของนักเรียน"
- กองสองคือ "ผลตรวจว่าคำตอบผ่านหรือไม่"
โดยเอกสารเรียงตามเลขที่ของนักเรียน ถ้ากระดาษหนึ่งใบหายจากกองแรก แต่กองสองยังอยู่ครบ จากนั้นครูจับคู่เพื่อตรวจตามลำดับเหมือนเดิม ผลตรวจของนักเรียนคนหนึ่งจะถูกเอาไปใช้กับคำตอบของอีกคน
สิ่งที่เกิดขึ้นใน WordPress ก็คล้ายกัน request ที่เหลื่อมกันสามารถถูก validate ด้วย schema ของ endpoint หนึ่ง แต่กลับถูกนำไป execute ด้วย handler ของอีก endpoint หนึ่ง ช่องโหว่ประเภทนี้เรียกว่า route confusion หรือ validation/execution desynchronization
ช่องโหว่นี้เป็นส่วนสำคัญที่ไว้ใช้ bypass validation ต่าง ๆ ใน exploit chain นี้
Route Confusion to SQL injection
ใน API ค้นหา post endpoint GET /wp/v2/posts มี parameter ชื่อ author_exclude สำหรับ exclude post ของผู้เขียนบาง ID ออกจาก query result โดย schema ระบุชัดว่าต้องเป็น array ของ integer:
$query_params['author_exclude'] = array(
'type' => 'array',
'items' => array(
'type' => 'integer',
),
'default' => array(),
);
เมื่อผ่าน validation แล้ว controller จะ map ชื่อไปเป็น query var author__not_in:
$parameter_mappings = array(
'author' => 'author__in',
'author_exclude' => 'author__not_in',
// ...
);
foreach ( $parameter_mappings as $api_param => $wp_param ) {
if ( isset( $registered[ $api_param ], $request[ $api_param ] ) ) {
$args[ $wp_param ] = $request[ $api_param ];
}
}
ค่า author__not_in ถูกส่งต่อมาถึง WP_Query::get_posts():
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) ";
}
ช่องโหว่อยู่ที่ type-dependent sanitization:
- ถ้าค่าเป็น array ทุกค่าจะผ่าน
absint() - ถ้าค่าเป็น scalar หรือ string จะข้าม
array_map()ไป (array)ก่อนimplode()เป็นเพียงการ cast data type ไม่ได้ sanitize content- string ที่เหลือจึงถูกประกอบเข้า SQL statement
NOT IN ($author__not_in)โดยตรง
ตามการใช้งานปกติ ช่องโหว่นี้ไม่ควรเกิด เพราะ REST schema จะกัน string ออกก่อนถึง WP_Query แต่การป้องกันตรงนี้หายไปทันทีเมื่อมีช่องโหว่ route confusion ทำให้ request ผ่านการตรวจ schema ของคนละ endpoint
attacker สามารถทำให้ input ถูกตรวจด้วย schema ของ endpoint ที่ไม่รู้จัก parameter นี้ แล้วค่อยส่งไป execute ที่ endpoint ค้นหา post เท่ากับข้าม validation ซึ่งเคยเป็นส่วนป้องกัน SQLi ในจุดนี้ แต่ว่ายังมีอุปสรรคอีกชั้นก็คือ Batch API ไม่อนุญาตให้ใช้ method GET ใน batch แต่ endpoint ที่ต้องการโจมตีใช้ GET
ข้อจำกัดนั้นอยู่ใน schema ของ Batch API เอง:
'method' => array(
'type' => 'string',
'enum' => array( 'POST', 'PUT', 'PATCH', 'DELETE' ),
'default' => 'POST',
),
วิธี bypass ข้อจำกัดนี้คือเรียก Batch API ซ้อนอยู่ข้างใน Batch API อีกที เพื่อที่จะให้ batch request ชั้นนอกใช้ desync เพื่อข้าม schema ของ method ส่วน batch request ชั้นในใช้ desync รอบที่สองเพื่อส่ง author_exclude ที่เป็น string ไปยัง GET /wp/v2/posts
Exploit flow จนถึง SQLi ได้ดังนี้
1. Batch desync เพื่อ bypass Batch method schema เปิดทางให้ใช้ method GET ใน batch request
2. Inner desync เพื่อ bypass `author_exclude` schema ส่ง string เข้า `author__not_in` เพื่อโจมตี SQLi
ผลก็คือสามารถทำ SQL injection ได้โดยไม่ต้อง login และสามารถใช้ปลอม query result ให้มี data shape เหมือน query result ของ wp_posts ได้ตามต้องการ
Read-only SQLi จะไปถึง RCE ได้อย่างไร
ถ้าอ่านมาถึงตรงนี้ หลายคนน่าจะคิดว่าแค่ dump ตารางผู้ใช้ เอา password hash ไป crack แล้ว login เป็น admin ก็จบ แค่ในปัจจุบันมันไม่แน่นอนขนาดนั้น password อาจแข็งแรง token สำคัญถูก hash และเราไม่ควรสร้าง exploit chain ที่ฝากความหวังไว้กับ admin ตั้ง password ว่า P@ssw0rd
ขั้นตอนต่อไปของ exploit chain คือใช้ SQLi สร้าง "post ปลอม" ใน query result ผ่าน UNION-based SQLi แม้ว่า post เหล่านี้ไม่ได้ถูกเขียนลง database จริง แต่ WordPress มองว่า query เพิ่งอ่านเจอ จึงนำ object ของ post ไปเก็บใน in-memory cache ตลอดอายุของ request
หลัง WP_Query ได้ query result มันแปลงแต่ละ record เป็น WP_Post แล้ว update post cache:
$this->posts = array_map( 'get_post', $this->posts );
if ( $query_vars['cache_results'] ) {
update_post_caches(
$this->posts,
$post_type,
$query_vars['update_post_term_cache'],
$query_vars['update_post_meta_cache']
);
}
update_post_cache() ใช้ ID ของแต่ละ record เป็น cache key:
File: https://github.com/WordPress/wordpress-develop/blob/7.0.1/src/wp-includes/post.php#L7688-L7701
function update_post_cache( &$posts ) {
$data = array();
foreach ( $posts as $post ) {
$post = sanitize_post( $post, 'raw' );
$data[ $post->ID ] = $post;
}
wp_cache_add_multiple( $data, 'posts' );
}
ส่วนการอ่าน post ครั้งถัดไปเลือก cache ก่อน database:
$_post = wp_cache_get( $post_id, 'posts' );
if ( ! $_post ) {
$_post = $wpdb->get_row(
$wpdb->prepare(
"SELECT * FROM $wpdb->posts WHERE ID = %d LIMIT 1",
$post_id
)
);
}
ดังนั้นถ้าใช้ UNION-based SQLi return record ที่มี column ครบตาม shape ของ wp_posts มันสามารถกำหนด ID, post_type, post_status, post_parent, timestamps และ post_content ของ object ปลอมได้ เมื่อส่วนอื่นเรียก get_post(ID) ภายใน request เดียวกัน ก็จะเจอ object จาก cache ก่อนข้อมูลจริงใน database
รายละเอียดสำคัญคือ cache ใช้ add ไม่ใช่ set จึงไม่ทับ key ที่มีอยู่แล้ว exploit chain นี้จึงจะเลือกสร้าง post ID ที่สูงและยังไม่ถูกใช้ เพื่อลดโอกาสชนกับ post จริงหรือ cache entry ที่ถูกสร้างมาก่อน
oEmbed สะพานจาก in-memory cache ไป database
WordPress รองรับการ embed content จาก URL ลงใน post และมี embed-result cache เพื่อไม่ต้องดึงหรือประมวลผลใหม่ทุกครั้ง cache type นี้ไม่ได้อยู่แค่ใน in-memory cache แต่ถูกบันทึกเป็น record ของ post type oembed_cache ในตาราง wp_posts ด้วย
Exploit chain นี้จึงใช้ post ปลอมจาก SQLi ในขั้นตอนก่อนหน้าสั่งให้ WordPress ประมวลผล local embed ไปยัง post ID ที่เลือกไว้ จากนั้น WordPress จะสร้าง record oembed_cache จริงขึ้นมาให้ใน database ทั้งที่ post ต้นทางเป็นเพียงข้อมูลปลอมใน in-memory cache
ใน WP_Embed::shortcode() ถ้า embed นั้นไม่ได้ผูกกับ post จริงผ่าน $post_id ระบบจะเก็บ cache เป็น post type oembed_cache:
$insert_post_args = array(
'post_name' => $key_suffix,
'post_status' => 'publish',
'post_type' => 'oembed_cache',
);
if ( $html ) {
if ( $cached_post_id ) {
wp_update_post(
wp_slash(
array(
'ID' => $cached_post_id,
'post_content' => $html,
)
)
);
} else {
wp_insert_post(
wp_slash(
array_merge(
$insert_post_args,
array( 'post_content' => $html )
)
)
);
}
}
ตอนที่ wp_insert_post() ถูกเรียกสิ่งที่ได้คือ record จริงใน wp_posts ซึ่งทำหน้าที่เป็น foothold ให้ ID ที่เลือกมีตัวตนใน database ส่วนตอน update โค้ดส่งค่าไปเพียง ID และ post_content
ปกติการส่ง field มาไม่ครบไม่ใช่ปัญหา เพราะ wp_update_post() จะอ่านค่าเดิมแล้ว merge กับค่าที่ส่งมา:
File: https://github.com/WordPress/wordpress-develop/blob/7.0.1/src/wp-includes/post.php#L5230-L5295
function wp_update_post(
$postarr = array(),
$wp_error = false,
$fire_after_hooks = true
) {
// อ่านค่าเดิมผ่าน get_post() ซึ่งเชื่อ object cache ก่อน DB
$post = get_post( $postarr['ID'], ARRAY_A );
$post = wp_slash( $post );
// ค่าใหม่ทับเฉพาะ field ที่ระบุ
$postarr = array_merge( $post, $postarr );
// จากนั้นส่ง object ที่ merge แล้วไปเขียนกลับ database
return wp_insert_post( $postarr, $wp_error, $fire_after_hooks );
}
แต่ใน request ที่ post cache ถูก poison คำว่า "ค่าเดิม" ไม่ได้แปลว่าค่าจาก database มันคือ object ปลอมจาก cache ทำให้ field ที่ caller ไม่ได้ระบุ เช่น post_type, post_status, post_parent และ timestamp ไหลจาก cache เข้า array_merge() แล้วถูกเขียนกลับลง record จริงได้
จากนั้นใช้ SQLi ปั้น object ของ ID เดียวกันใน in-memory cache ให้มี type และ status ต่างจาก row ใน database เมื่อ WordPress พบว่าข้อมูลสองฝั่งไม่ตรงกัน ระบบจะพยายาม reconcile หรืออัปเดตข้อมูลให้ตรง
จุดสำคัญคือ exploit chain ไม่ได้เลือกใช้ wp_insert_post() หรือ wp_update_post() แค่ตัวใดตัวหนึ่ง แต่ต้องใช้ทั้งสอง function เพื่อสร้าง attacker-controlled post record ใน database:
wp_insert_post()สร้างoembed_cacherecord จริงให้มี ID อยู่ใน database ก่อน- SQLi ปั้น fake post object ด้วย ID เดียวกันไว้ใน in-memory cache
wp_update_post()โหลด object จาก cache มา merge แล้ว update record จริง
Exploit flow จนถึงตอนนี้
1. Batch desync เพื่อ bypass Batch method schema และเปิดทางให้ใช้ method GET ใน batch request
2. Inner desync เพื่อ bypass `author_exclude` schema และส่ง string เข้า `author__not_in` เพื่อโจมตี SQLi
3. UNION-based SQLi เพื่อ poison post objects ใน in-memory cache
4. ใช้ oEmbed cache flow โดยให้ `wp_insert_post()` seed records จริง และให้ `wp_update_post()` merge poisoned fields เพื่อเริ่มสร้าง fake posts ใน database
customize_changeset ทางลัดสวมสิทธิ์ admin ชั่วคราว
เวลาเราแก้ theme ของเว็บใน WordPress แล้วเก็บไว้เป็น draft ระบบต้องมีที่สำหรับบันทึกการเปลี่ยนแปลงเหล่านี้ WordPress จึงสร้าง record พิเศษในตาราง wp_posts โดยกำหนด post_type เป็น customize_changeset
โดย changeset แต่ละ entry จะระบุได้ว่ากำลังเปลี่ยนค่าอะไร ค่าใหม่คืออะไร และให้ใช้ permission ของ user ID ไหนตอนนำ change ไป apply ใช้
ถ้า WordPress กำลัง apply changeset ของ admin มันจะสลับ current user เป็น admin ชั่วคราว เพื่อให้การเปลี่ยนแปลงนั้นผ่าน permission check
โครงสร้างใน post_content มีหน้าตาประมาณนี้:
{
"nav_menus_created_posts": {
"value": [123456],
"type": "option",
"user_id": 1
}
}
ในตัวอย่าง post ID 123456 ส่วน user_id: 1 คือ default admin account ที่มักถูกสร้างเป็นคนแรกในการติดตั้ง WordPress
ในขณะที่ publish changeset WP_Customize_Manager จะนำค่า user_id ของแต่ละ setting มาสลับเป็น current user นั้นชั่วคราวด้วย wp_set_current_user() ก่อนเรียก save():
$original_user_id = get_current_user_id();
foreach ( $changeset_setting_ids as $setting_id ) {
$setting = $this->get_setting( $setting_id );
if ( isset( $setting_user_ids[ $setting_id ] ) ) {
wp_set_current_user(
$setting_user_ids[ $setting_id ]
);
} else {
wp_set_current_user( $original_user_id );
}
$setting->save();
}
wp_set_current_user( $original_user_id );
ใน flow ปกติ user_id ทีี่ถูกบันทึกลง changeset จะต้องเป็น user ที่ผ่าน permission check มาแล้ว แต่ cache poisoning ทำให้ attacker ปลอม changeset ทั้ง object ได้ จึงสามารถระบุ ID ของ admin ในข้อมูลที่ไม่เคยผ่าน flow การสร้าง changeset ตามปกติ
เป้าหมายต่อไปคือการทำให้ post ปลอมใน cache ถูกมองเป็น customize_changeset ที่อ้างถึง user ID ของ admin แล้วบังคับให้ WordPress apply มัน
ปัญหาคือ oEmbed update post ก่อนหน้านี้เขียนทับ post_content ซึ่งเป็นช่องที่ changeset ใช้เก็บข้อมูลพอดี จึงต้องหาทาง update post อีกแบบที่ไม่ทับ content
Post Hierarchy และ Cycle Detection
คำตอบอยู่ในระบบ parent-child ของ WordPress Post Hierarchy
WordPress อนุญาตให้ post มี parent แต่ไม่ยอมให้โครงสร้างวนเป็นวงกลม เช่น A มี parent เป็น B และ B ย้อนกลับมามี parent เป็น A เพราะขั้นตอนการไล่ย้อนขึ้น parent tree อาจเกิด infinite loop
ดังนั้นเมื่อ WordPress ตรวจเจอ cycle มันจะซ่อมโครงสร้างด้วยการอัปเดต post_parent ให้เป็น 0 โดยการ update ท่านี้ไม่ได้ override ค่า post_content ใหม่ จึงเปิดทางให้ content ปลอมจาก in-memory cache ติดไปกับ object ตอนเขียนลง database
File: https://github.com/WordPress/wordpress-develop/blob/7.0.1/src/wp-includes/post.php#L8069-L8099
$loop = wp_find_hierarchy_loop(
'wp_get_post_parent_id',
$post_id,
$post_parent
);
if ( ! $loop ) {
return $post_parent;
}
foreach ( array_keys( $loop ) as $loop_member ) {
wp_update_post(
array(
'ID' => $loop_member,
'post_parent' => 0,
)
);
}
สังเกตว่า wp_update_post() call นี้ override แค่สอง field เมื่อมันอ่าน field ที่เหลือผ่าน get_post() ค่า post_content ปลอมจาก cache จึงไม่ถูก override ต่างจาก oEmbed update ที่กำหนด post_content เป็น HTML ทับไว้ชัดเจน
เพื่อให้ changeset ถูก apply ใน object ปลอมจะต้องกำหนดค่า post_status = future พร้อมกับใส่ค่า post_date_gmt เป็นเวลาในอดีต เพื่อที่จะทำให้ระหว่าง update WordPress จะเปลี่ยน status เป็น publish อัตโนมัติ
File: https://github.com/WordPress/wordpress-develop/blob/7.0.1/src/wp-includes/post.php#L4704-L4715
if ( 'publish' === $post_status ) {
if ( strtotime( $post_date_gmt ) - strtotime( $now )
>= MINUTE_IN_SECONDS ) {
$post_status = 'future';
}
} elseif ( 'future' === $post_status ) {
if ( strtotime( $post_date_gmt ) - strtotime( $now )
< MINUTE_IN_SECONDS ) {
$post_status = 'publish';
}
}
transition จาก future ไป publish บน post type customize_changeset จะเข้า callback นี้:
File: https://github.com/WordPress/wordpress-develop/blob/7.0.1/src/wp-includes/theme.php#L3638-L3692
$is_publishing_changeset = (
'customize_changeset' === $changeset_post->post_type
&& 'publish' === $new_status
&& 'publish' !== $old_status
);
if ( ! $is_publishing_changeset ) {
return;
}
$wp_customize->_publish_changeset_values(
$changeset_post->ID
);
เมื่อรวม cache poisoning, oEmbed และ cycle detection เข้าด้วยกัน attacker จะสามารถเปลี่ยน changeset ปลอมใน in-memory cache ให้กลายเป็น record จริงใน database ได้เพื่อหลอกให้ WordPress apply changeset นั้นใน admin context ได้
มีสิทธิ์ admin แค่แวบเดียว แล้วทำอย่างไรให้กลายเป็นสิทธิ์ถาวร
WordPress มี hook ที่ plugin ใช้ผูก function เข้ากับเหตุการณ์ต่าง ๆ เช่น login สำเร็จหรือ publish post โดยชื่อ hook function บางตัวถูกสร้างแบบ dynamic เช่น publish post hook ถูกสร้างจากค่า status และ post type ของ post นั้น ๆ
ซึ่งตอนนี้ attacker ควบคุม object ของ post ได้ จึงสามารถปั้น status และ post type ให้รวมกันเป็นชื่อ hook ที่ต้องการได้
โดยปกติหลังจาก wp_insert_post() เปลี่ยน status ของ post do_action() จะถูก call :
File: https://github.com/WordPress/wordpress-develop/blob/7.0.1/src/wp-includes/post.php#L5815-L5883
do_action(
"{$new_status}_{$post->post_type}",
$post->ID,
$post,
$old_status
);
หาก object ปลอมมี post_status = parse และ post_type = request ชื่อ hook ที่ประกอบออกมาจะเป็น parse_request โดยสองค่านี้ไม่จำเป็นต้องเป็น status และ post type ที่ valid ก็ได้ เพราะจุดที่เรียก do_action() เพียงแค่นำ string มาต่อกัน
แล้ว parse_request ใช้ทำอะไรใน exploit chain นี้
hook นี้จะอยู่ช่วงต้นของทุก ๆ request lifecycle การ trigger hook นี้จึงมีผลคล้ายสั่งให้ WordPress เริ่มประมวลผล request เดิมอีกรอบ (re-entrance) เมื่อนำจุดนี้มาประกอบรวมกับ changeset ที่เราสามารถใช้สิทธิ์ admin ได้ชั่วขณะ จะทำให้เราสามารถประมวลผล request เดิมอีกรอบใน context ของ admin ได้นั่นเอง
เมื่อได้ WordPress admin ก็เป็นอันจบ attacker สามารถใช้ admin account login เข้า WordPress และอัปโหลด plugin ที่มี PHP code และเมื่อ WordPress execute PHP code จาก plugin จะทำให้ attacker ได้ RCE ในที่สุด
Exploit flow จนถึงตอนนี้
1. Batch desync เพื่อ bypass Batch method schema และเปิดทางให้ใช้ method GET ใน batch request
2. Inner desync เพื่อ bypass `author_exclude` schema และส่ง string เข้า `author__not_in` เพื่อโจมตี SQLi
3. UNION-based SQLi เพื่อ poison post objects ใน in-memory cache
4. ใช้ oEmbed cache flow โดยให้ `wp_insert_post()` seed records จริง และให้ `wp_update_post()` merge poisoned fields เพื่อเริ่มสร้าง fake posts ใน database
5. ใช้ Post Hierarchy/Cycle Detection เพื่อ trigger `wp_update_post()` ที่ override แค่ `ID` กับ `post_parent` ทำให้ `post_content` ปลอมจาก object cache ถูกเขียนลง database
6. ปลอม post ให้เป็น type `customize_changeset` พร้อมกำหนด `post_status` เป็น `future` และใส่วันที่ในอดีต เพื่อให้ WordPress normalize status เป็น `publish`
7. ใช้ changeset ที่กำหนด `user_id` ของ admin เพื่อให้ WordPress เปลี่ยน current user เป็น admin ชั่วคราวระหว่าง apply changeset
8. ใช้ Customizer setting ที่มี save callback สำหรับ publish draft post เพื่อ trigger `wp_update_post()` ขณะที่ WordPress ยังอยู่ใน admin context
9. ใช้ Post Hierarchy/Cycle Detection เพื่อสร้าง post ปลอมที่มี `post_status` เป็น `parse` และ `post_type` เป็น `request`
10. ใช้ Post Status Hook เพื่อประกอบชื่อ hook ให้กลายเป็น `parse_request`
11. ใช้ `parse_request` trigger batch request เดิมซ้ำขณะที่ current user ยังเป็น admin
ในเมื่อชิ้นส่วน domino ทั้งหมดครบแล้วก็ถึงเวลาที่จะนำมาเรียงให้กลายเป็น full exploit pre-auth RCE
Full Exploit Chain
ใน exploit chain จะใช้ batch request หลักเพียง 2 request โดยเพื่อให้อ่านง่ายขึ้น จะขอละขั้นตอน batch desync > SQLi ที่ใช้ bypass validation และ cache poisoning และจะเรียก post ID แต่ละตัวด้วยตัวย่อ โดยใน exploit จริงค่า ID ที่เลือกใช้ต้องเลือกให้ไม่ชนกับ record ที่มี post จริงใช้อยู่
Batch Request 1: Seeding Request
เป้าหมายของ batch request 1 คือการวาง placeholder 3 record ลง database ให้มี post ID จริงรออยู่ก่อน เราจะเรียก ID เหล่านี้ว่า O, C และ D
ขั้นตอนที่ embed post ที่ถูก seed ลง database จาก batch request 1
- ใช้ validation desync เปิดทางให้ SQLi เพื่อสร้าง fake post เป็น query result แทน post จริง
- fake post ตัวนี้มี local
[embed]tags 3 ตัวในpost_contentทุกตัวชี้ไปยัง postSเดียวกัน แต่เติม dummy query string คนละค่า:
[embed width="500" height="750"]/?p=S&wpsec_seed=foobar-outer[/embed]
[embed width="500" height="750"]/?p=S&wpsec_seed=foobar-changeset[/embed]
[embed width="500" height="750"]/?p=S&wpsec_seed=foobar-dispatch[/embed]
S ตรงนี้เป็น target ID ของ local embed ไม่จำเป็นต้องมี real record ใน database เพราะ WordPress ไม่ได้ตรวจสอบว่า local post ID นี้มีอยู่จริงหรือไม่ ตอน batch request 2 ค่อยให้ SQLi สร้าง fake S ไว้ใน memory เพื่อส่ง embed data กลับมา
- สำหรับ oEmbed, URL คือส่วนหนึ่งของ cache key ดังนั้น query string ที่ต่างกันจึงให้ cache hash คนละค่า WordPress เลยเรียก
wp_insert_post()สร้างoembed_cacherecords แยกกัน 3 ตัวที่ attacker จะใช้เป็นO,CและD
Result หลัง batch request 1 มีเพียงการ seed ค่า placeholder cache records 3 ตัวใน database เท่านั้น
ตรงนี้คือเหตุผลที่ต้อง seed database ก่อนเพราะว่า fake object จาก SQLi อยู่ได้แค่ใน in-memory cache ของ request แต่ใน batch request 2 จำเป็นจะต้องมี valid post ID จริงให้ oEmbed ค้นเจอ และมี record รองรับตอน wp_update_post() พยายามเขียนค่า poisoned field กลับมา
สรุปโครงสร้าง batch request 1
Outer desync
└── bypass Batch schema เพื่อให้ inner batch ใช้ GET ได้
└── Inner desync
└── bypass validation ของ author_exclude เพื่อเข้า SQLi
└── SQLi return seed post
(Post 0 + [embed] 3 URLs ที่ชี้ไป S
แต่ใช้ token outer, changeset และ dispatch ต่างกัน)
├── Post 0
│ └── เป็น container ให้ WordPress render embed ทั้ง 3 ตัว
├── embed: outer
│ └── สร้าง oembed_cache row ที่ใช้เป็น O
├── embed: changeset
│ └── cache hash ต่างกัน จึงสร้าง row ที่ใช้เป็น C
└── embed: dispatch
└── cache hash ต่างกัน จึงสร้าง row ที่ใช้เป็น D
Batch Request 2: Exploitation Request
เป้าหมายของ batch request 2 คือเปลี่ยน placeholder จาก batch request 1 ให้กลายเป็น privilege-escalation chain พร้อม bundle POST /wp/v2/users สำหรับสร้าง admin account ไว้ใน Batch เดียวกัน
ใน batch request 2 จะใช้ SQLi สร้าง fake post 6 ตัวใน in-memory cache โดยแต่ละตัวรับหน้าที่ต่างกันตามตารางด้านล่าง:
| ID | status | type | parent | field สำคัญ | หน้าที่ |
|---|---|---|---|---|---|
O จาก Outer oEmbed cache |
publish |
oembed_cache |
C |
post_content ว่าง และ post_modified_gmt เก่า |
บังคับให้ oEmbed มองว่า cache หมดอายุ เริ่ม refresh O และเดินไปหา parent C |
C จาก Changeset |
future |
customize_changeset |
C |
post_content เป็น changeset JSON และ post_date อยู่ในอดีต |
self-cycle ทำให้ hierarchy repair เขียน fake C ลง database ก่อน normalize future > publish และ apply changeset |
D จาก Dispatch |
parse |
request |
D |
status/type ประกอบกันเป็น parse_request |
self-cycle ทำให้ hierarchy repair materialize D แล้ว trigger dynamic hook parse_request |
P จาก Publish |
draft |
page |
D |
ID ถูกใส่ใน nav_menus_created_posts ของ changeset |
เป็น post ที่ changeset publish ระหว่างอยู่ใน admin context และพา execution ไปหา parent D |
S จาก Embed Source |
publish |
post |
- | มี data ที่ oEmbed จะนำไปสร้าง $generated_html |
เป็น source ที่ O ใช้ตอน refresh โดยอ่าน fake S จาก in-memory cache |
T จาก Trigger |
publish |
post |
- | post_content มี outer embed ที่ใช้ cache hash ของ O |
เป็นจุด trigger ของ exploit chain |
ภาพแสดง state ของ post ที่ถูกใช้ใน batch request 2:
ขั้นตอน state transition/event ต่าง ๆ ที่เกิดขึ้นใน batch request 2:
- Guest Context: admin-creation request ถูก execute รอบแรกทันที แต่ fail ที่ permission check เพราะ attacker ยังเป็น guest
- Trigger embed: WordPress render fake
Tซึ่งมี outer embed ชี้ไปSการประมวลผลpost_contentของTจึงเริ่ม oEmbed resolution - Lookup
O: URL ของ embed ให้ cache hash ที่ตรงกับ real recordOแต่ poisoned objectOมีpost_modified_gmtเก่าจน WordPress มองว่า cache stale และต้อง refresh - Load embed data จาก
S: oEmbed เรียกget_post(S)แล้วได้ fakeSจาก object cache จากนั้นแปลง content ของSเป็น$generated_htmlสำหรับ updateO - เจอ cycle
C → C: ระหว่างwp_update_post(O)WordPress ตรวจ hierarchy ของ parentCและพบว่าCชี้กลับมาหาตัวเอง จึงเข้า cycle-repair path - Merge poisoned
C: cycle repair เรียกwp_update_post(C)โดย override แค่IDกับpost_parent = 0ส่วน field อื่นยังมาจาก poisoned object ทำให้ malicious changeset ถูก merge ลง real recordC - Normalize date/status: fake
Cเป็นfuture/customize_changesetแต่มีpost_dateอยู่ในอดีต WordPress จึงเปลี่ยน status จากfutureเป็นpublish - Apply changeset: status transition ของ
Cทำให้ WordPress parse และ apply changeset JSON ซึ่งมีnav_menus_created_posts = Pและ adminuser_id - เข้า admin context: ระหว่าง apply setting WordPress เรียก
wp_set_current_user()ด้วย admin ID จาก changeset แล้วเริ่ม process สำหรับ publishP - Load draft
P:Pไม่มี real record มีแต่ poisoned object cache โดยมี fakePเป็นdraft/pageอยู่แล้วwp_update_post(P)จึงใช้ object นี้และพยายามเปลี่ยนเป็นpublish/page - เจอ cycle
D → D: hierarchy check ของPย้อนไปยัง parentDและพบว่าDชี้กลับมาหาตัวเอง จึงเข้า cycle-repair path รอบที่สอง - Merge poisoned
D: WordPress เรียกwp_update_post(D, post_parent = 0)แต่รับ field ที่เหลือจาก fakeDทำให้ real record ถูกเขียนเป็น status/typeparse/request - Trigger dynamic hook: หลังเขียน
D, hook"{$new_status}_{$post_type}"ถูกประกอบเป็นparse_request - Reuse request เดิม: admin-creation subrequest
POST /wp/v2/usersยังอยู่ใน original batch request 2 body เดียวกัน พร้อมถูก process ซ้ำโดยไม่ต้องส่ง HTTP request ใหม่ - Re-entrance ผ่าน
parse_request: hook เรียก request parsing pipeline อีกครั้ง ทำให้ WordPress replay batch request เดิม ขณะที่ current user ยังเป็น admin - Permission ผ่าน:
POST /wp/v2/usersถูก execute รอบที่สองใน admin context คราวนี้ permission check ผ่านและสร้าง admin account ใหม่สำเร็จ
หลัง Step 15 attacker สามารถ login ด้วย account ใหม่ อัปโหลด plugin ZIP ที่มี PHP payload แล้ว activate plugin เพื่อเปลี่ยน privilege escalation ให้กลายเป็น RCE ได้ในที่สุด
โครงสร้าง batch request 2
Outer desync
└── bypass Batch schema เพื่อให้ inner batch ใช้ GET ได้
└── Inner desync
└── bypass validation ของ author_exclude เพื่อเข้า SQLi
├── SQLi return poisoned post graph
│ (O, C, P, D, S และ T)
│
│ ├── T - Trigger
│ │ └── มี outer embed เพื่อเริ่ม exploit chain
│ │
│ ├── S - Embed Source
│ │ └── ส่ง embed data ที่ใช้สำหรับ refresh O
│ │
│ ├── O - Outer oEmbed Cache
│ │ └── ใช้ real row จาก Batch 1 และปลอมให้ cache stale
│ │ โดยกำหนด parent ให้ชี้ไป C
│ │
│ ├── C - Changeset
│ │ └── future/customize_changeset และ parent = C
│ │ self-cycle บังคับให้ WordPress เขียน changeset ลง DB
│ │ ก่อน apply ด้วย admin context ชั่วคราว
│ │
│ ├── P - Publish
│ │ └── draft/page และ parent = D
│ │ changeset จะ publish P ขณะที่ยังอยู่ใน admin context
│ │
│ └── D - Dispatch
│ └── parse/request และ parent = D
│ self-cycle รอบที่สองจะ materialize D
│ และ trigger hook parse_request
│
└── POST /wp/v2/users × 2
(request body เหมือนกันทั้งสองชุด)
├── admin request
│ └── รอบแรก fail เพราะยังเป็น guest
│ แต่สำเร็จเมื่อ replay ใน admin context
└── handler donor
└── ให้ users endpoint handler กับ admin request
หลังตำแหน่ง request ถูก desync
สรุปสั้น ๆ ก็คือ Batch 1 seed record, Batch 2 trigger จากนั้น domino จะล้มต่อกันผ่าน cycle repair ที่แก้ parent แต่พา poisoned content, status และ type ติดเข้าไปด้วย ตัวสุดท้าย trigger parse_request เพื่อ replay admin-creation request ในช่วงสั้น ๆ ที่ WordPress set current user เป็น admin
WordPress Patch ที่ตรงไหนบ้าง
WordPress 7.0.2 ไม่ได้แก้เพียงจุดเดียว แต่ตัด exploit chain หลายชั้นดังนี้
1. แก้ไม่ให้ index ของ $matches กับ $validation mismatch
เมื่อ request error logic ใหม่เพิ่ม element ให้ array $matches ด้วย ทำให้ array ทั้งสองชุดยาวและจัดตำแหน่งตรงกัน:
if ( is_wp_error( $single_request ) ) {
$has_error = true;
$matches[] = $single_request; // เพิ่มใน 7.0.2
$validation[] = $single_request;
continue;
}
ส่วนนี้แก้ root cause ของ route confusion โดยตรง malformed request จะ desync array index เพื่อ bypass validation ต่าง ๆ ไม่ได้แล้ว
2. ปรับการ Validate author__not_in
logic ใหม่ไม่ sanitize เฉพาะตอน input เป็น array แต่ใช้ wp_parse_id_list() กับทุก input ก่อนนำไปประกอบ SQL statement:
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 )
);
}
}
ต่อให้ validation ชั้นถูก bypass ค่าใน SQL ก็ถูกบีบให้เป็น ID list เสมอไม่สามารถ inject string มาได้อีกต่อไป
3. Re-entrancy Guard
เพิ่ม re-entrancy guard ใน serve_request():
public function serve_request( $path = null ) {
if ( $this->is_dispatching() ) {
return false;
}
// ...
}
ส่งผลให้แม้ attacker จะหาท่ายิง parse_request ได้อีก ก็จะไม่สามารถ re-entrance เพื่อเริ่ม REST lifecycle ใหม่ได้ จึงตัดช่องโหว่ที่ทำให้สวมสิทธิ์ admin ช่วงท้ายของ exploit chain ออกไป
สรุป
wp2shell คือการนำช่องโหว่ และ WordPress Behavior หลาย ๆ จุดมาต่อกันเพื่อข้าม security boundary ทีละชั้น ๆ จนสามารถ chain จาก pre-auth API ไปจนถึงได้ admin access และ RCE ในท้ายที่สุด
ช่องโหว่นี้แสดงให้เห็นว่าภาพรวม security ของแต่ละส่วนที่สอดคล้องกัน หากกำแพงป้องกันชั้นใดชั้นนึงพังลง behavior ที่เคยดูปลอดภัยอาจจะกลายเป็น gadget ของ exploit chain ถัดไปได้
AI ทำให้การค้นหาช่องโหว่ในระบบซับซ้อนเร็วขึ้นอย่างเห็นได้ชัด เพราะความสามารถในการอ่าน code จำนวนมาก ความสามารถในการติดตาม data และ control flow ข้ามหลาย subsystem แล้วเชื่อม behavior เหล่านั้นเข้าด้วยกันได้ภายในเวลาไม่กี่ชั่วโมง แต่อีกด้านหนึ่ง ความสามารถนี้ก็ช่วยให้ attacker ค้นหาช่องโหว่ และ พัฒนา exploit ได้เร็วขึ้นเช่นกัน เราจึงต้องปรับตัว ต้องมีมาตรการ patch และเฝ้าระวัง behavior ผิดปกติอย่างรวดเร็วให้เท่าทันภัยคุกคาม ใหม่ ๆ ที่จะเกิดขึ้น
References:
- Technical write-up ของ wp2shell: Searchlight Cyber
- GHSA-ff9f-jf42-662q / CVE-2026-63030: WordPress Security Advisory
- GHSA-fpp7-x2x2-2mjf / CVE-2026-60137: WordPress Security Advisory
- WordPress 7.0.1 source tree: vulnerable version
- WordPress 7.0.2 source tree: patched version
- WordPress 7.0.2 Security Release: WordPress.org