Reconix LogoReconix
Featured image for แกะ Exploit Chain ของช่องโหว่ wp2shell: WordPress RCE ที่ถูกพบโดย GPT5.6

แกะ Exploit Chain ของช่องโหว่ wp2shell: WordPress RCE ที่ถูกพบโดย GPT5.6

Reconix Team (Natsasit Jirathammanuwat)
WordPresswp2shellCVE-2026-63030CVE-2026-60137Vulnerability Research

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:

File: https://github.com/WordPress/wordpress-develop/blob/7.0.1/src/wp-includes/rest-api/class-wp-rest-server.php#L1742-L1799

$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 ชุดเดิม:

File: https://github.com/WordPress/wordpress-develop/blob/7.0.1/src/wp-includes/rest-api/class-wp-rest-server.php#L1821-L1861

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:

File: https://github.com/WordPress/wordpress-develop/blob/7.0.1/src/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php#L2995-L3011

$query_params['author_exclude'] = array(
    'type'  => 'array',
    'items' => array(
        'type' => 'integer',
    ),
    'default' => array(),
);

เมื่อผ่าน validation แล้ว controller จะ map ชื่อไปเป็น query var author__not_in:

File: https://github.com/WordPress/wordpress-develop/blob/7.0.1/src/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php#L235-L272

$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():

File: https://github.com/WordPress/wordpress-develop/blob/7.0.1/src/wp-includes/class-wp-query.php#L2399-L2405

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 เอง:

File: https://github.com/WordPress/wordpress-develop/blob/7.0.1/src/wp-includes/rest-api/class-wp-rest-server.php#L115-L145

'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:

File: https://github.com/WordPress/wordpress-develop/blob/7.0.1/src/wp-includes/class-wp-query.php#L3628-L3645

$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:

File: https://github.com/WordPress/wordpress-develop/blob/7.0.1/src/wp-includes/class-wp-post.php#L232-L257

$_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:

File: https://github.com/WordPress/wordpress-develop/blob/7.0.1/src/wp-includes/class-wp-embed.php#L314-L351

$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:

  1. wp_insert_post() สร้าง oembed_cache record จริงให้มี ID อยู่ใน database ก่อน
  2. SQLi ปั้น fake post object ด้วย ID เดียวกันไว้ใน in-memory cache
  3. 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():

File: https://github.com/WordPress/wordpress-develop/blob/7.0.1/src/wp-includes/class-wp-customize-manager.php#L3565-L3592

$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

แผนภาพลำดับขั้นตอนของ Batch Request 1 แสดงการ seed oembed_cache records O, C และ D ลง database ผ่าน SQLi และ oEmbed flow
  1. ใช้ validation desync เปิดทางให้ SQLi เพื่อสร้าง fake post เป็น query result แทน post จริง
  2. fake post ตัวนี้มี local [embed] tags 3 ตัวใน post_content ทุกตัวชี้ไปยัง post S เดียวกัน แต่เติม 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 กลับมา

  1. สำหรับ oEmbed, URL คือส่วนหนึ่งของ cache key ดังนั้น query string ที่ต่างกันจึงให้ cache hash คนละค่า WordPress เลยเรียก wp_insert_post() สร้าง oembed_cache records แยกกัน 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, type, parent และหน้าที่ของ fake post แต่ละตัว (O, C, D, P, S, T) ที่ใช้ใน Batch Request 2

ขั้นตอน state transition/event ต่าง ๆ ที่เกิดขึ้นใน batch request 2:

แผนภาพ state transition และ event sequence ของ Batch Request 2 ตั้งแต่ SQLi cache poisoning จนถึง privilege escalation เพื่อสร้าง admin account
  1. Guest Context: admin-creation request ถูก execute รอบแรกทันที แต่ fail ที่ permission check เพราะ attacker ยังเป็น guest
  2. Trigger embed: WordPress render fake T ซึ่งมี outer embed ชี้ไป S การประมวลผล post_content ของ T จึงเริ่ม oEmbed resolution
  3. Lookup O: URL ของ embed ให้ cache hash ที่ตรงกับ real record O แต่ poisoned object O มี post_modified_gmt เก่าจน WordPress มองว่า cache stale และต้อง refresh
  4. Load embed data จาก S: oEmbed เรียก get_post(S) แล้วได้ fake S จาก object cache จากนั้นแปลง content ของ S เป็น $generated_html สำหรับ update O
  5. เจอ cycle C → C: ระหว่าง wp_update_post(O) WordPress ตรวจ hierarchy ของ parent C และพบว่า C ชี้กลับมาหาตัวเอง จึงเข้า cycle-repair path
  6. Merge poisoned C: cycle repair เรียก wp_update_post(C) โดย override แค่ ID กับ post_parent = 0 ส่วน field อื่นยังมาจาก poisoned object ทำให้ malicious changeset ถูก merge ลง real record C
  7. Normalize date/status: fake C เป็น future/customize_changeset แต่มี post_date อยู่ในอดีต WordPress จึงเปลี่ยน status จาก future เป็น publish
  8. Apply changeset: status transition ของ C ทำให้ WordPress parse และ apply changeset JSON ซึ่งมี nav_menus_created_posts = P และ admin user_id
  9. เข้า admin context: ระหว่าง apply setting WordPress เรียก wp_set_current_user() ด้วย admin ID จาก changeset แล้วเริ่ม process สำหรับ publish P
  10. Load draft P: P ไม่มี real record มีแต่ poisoned object cache โดยมี fake P เป็น draft/page อยู่แล้ว wp_update_post(P) จึงใช้ object นี้และพยายามเปลี่ยนเป็น publish/page
  11. เจอ cycle D → D: hierarchy check ของ P ย้อนไปยัง parent D และพบว่า D ชี้กลับมาหาตัวเอง จึงเข้า cycle-repair path รอบที่สอง
  12. Merge poisoned D: WordPress เรียก wp_update_post(D, post_parent = 0) แต่รับ field ที่เหลือจาก fake D ทำให้ real record ถูกเขียนเป็น status/type parse/request
  13. Trigger dynamic hook: หลังเขียน D, hook "{$new_status}_{$post_type}" ถูกประกอบเป็น parse_request
  14. Reuse request เดิม: admin-creation subrequest POST /wp/v2/users ยังอยู่ใน original batch request 2 body เดียวกัน พร้อมถูก process ซ้ำโดยไม่ต้องส่ง HTTP request ใหม่
  15. Re-entrance ผ่าน parse_request: hook เรียก request parsing pipeline อีกครั้ง ทำให้ WordPress replay batch request เดิม ขณะที่ current user ยังเป็น admin
  16. 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 ทั้งสองชุดยาวและจัดตำแหน่งตรงกัน:

File: https://github.com/WordPress/wordpress-develop/blob/7.0.2/src/wp-includes/rest-api/class-wp-rest-server.php#L1750-L1755

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:

File: https://github.com/WordPress/wordpress-develop/blob/7.0.2/src/wp-includes/class-wp-query.php#L2399-L2413

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():

File: https://github.com/WordPress/wordpress-develop/blob/7.0.2/src/wp-includes/rest-api/class-wp-rest-server.php#L285-L290

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:

บริการที่เกี่ยวข้อง

Articles

More Posts

Explore more articles from our blog