
Schritt-für-Schritt-Analyse und Laboraufbau für CVE-2023-39361, eine nicht authentifizierte SQL-Injection in Cacti v1.2.24, mit Anleitung zur Ausnutzung und Eindämmung.
Cacti ist ein quelloffenes Betriebsüberwachungstool, das in PHP, MySQL/MariaDB geschrieben ist und eine benutzerfreundliche Oberfläche bietet. Die Schwachstelle wurde im Jahr 2023 gefunden und betrifft alle Versionen vor 1.2.24. Dieser Sicherheitsfehler liegt in der unsachgemäßen Implementierung beim Einfügen von Werten in eine SQL-Abfrage. Es handelt sich um eine kritische SQL-Injection-Schwachstelle, die es Angreifern ermöglicht, die Datenbank zu ändern und Code aus der Ferne auszuführen.
In dieser Analyse werde ich Cacti der Einfachheit halber in Docker ausführen. Zuerst erstellen wir die Datei docker-compose.yml wie unten gezeigt und führen den Befehl docker-compose up -d aus.
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:
Nach dem Erstellen des Containers können wir auf Cacti zugreifen, indem wir http://localhost:80 aufrufen. Der nächste Schritt ist, Cacti auf Version 1.2.24 zu aktualisieren. Sie können das Upgrade-Skript hier herunterladen: https://pastebin.com/NfRiHLjR. Speichern Sie die Datei im selben Verzeichnis wie die docker-compose.yml-Datei und benennen Sie sie in upgrade_cacti.sh um. Führen Sie dann diese beiden Befehle aus:
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"
Jetzt können wir loslegen. Tauchen wir in den Code ein, um zu sehen, was passiert ist, das uns einen SQL-Injection-Angriff ermöglicht. Die anfällige Datei ist graph_view.php und wir benötigen nur einen Gast-Benutzer, um auf diese Datei zuzugreifen, was es jedem ermöglicht, diese Schwachstelle auszunutzen. In dieser Datei ist die unsichere Funktion grow_right_pane_tree().

Bevor wir tiefer in diese Funktion eintauchen, müssen wir rückwärts verfolgen, wie die Funktion ausgeführt wird.
<?php
switch (get_nfilter_request_var('action')) {
//....
// Viele andere Fälle
case 'tree_content':
//..... Etwas Code hier
// ---------
if (isset_request_var('node')) {
$parts = explode('-', sanitize_search_string(get_request_var('node')));
// Prüfen auf Baumanker
if (strpos(get_nfilter_request_var('node'), 'tree_anchor') !== false) {
$tree_id = $parts[1];
$node_id = 0;
}
//..... Etwas Code hier
if ($tree_id > 0) {
if (!is_tree_allowed($tree_id)) {
header('Location: permission_denied.php');
exit;
}
// Anfällige Funktion wird hier aufgerufen
grow_right_pane_tree($tree_id, $node_id, $hgdata);
}
Aus dem obigen Code-Snippet können wir sehen, dass die Funktion ausgeführt wird, wenn $tree_id > 0 ist. Alle Schritte können wie folgt erklärt werden:
Zuerst nimmt das Programm den Wert des Parameters action aus der Benutzereingabe und tritt in eine switch/case-Anweisung ein.
Im Fall tree_content holt der Code den Parameter node aus der Anfrage des Benutzers. Als nächstes wird die Eingabe durch das Zeichen - geteilt, in $part gespeichert und schließlich geprüft, ob tree_anchor in node vorkommt. Wenn alle Bedingungen zutreffen, wird $tree_id als zweites Element in $part zugewiesen. Zum Beispiel sind tree_content-1 oder 1-2-tree_content gültig und der Wert von $tree_id wird entsprechend gesetzt.
Ab diesem Punkt wird unsere URL so aussehen: http://localhost:80?action=tree_content&node=tree_anchor-1. Jetzt ist es an der Zeit, die Funktion grow_right_pane_tree() zu analysieren, um den Fehler auszunutzen.

Der anfällige Parameter ist rfilter, der direkt in den RLIKE-Operanden innerhalb der WHERE-Klausel übergeben wird. Was ihn anfällig macht, ist, dass rfilter in doppelte Anführungszeichen " eingeschlossen ist, der Parameter jedoch von der Funktion html_validate_tree_vars() überprüft wird, die wie folgt aussieht:
function html_validate_tree_vars() {
/* ================= Eingabevalidierung und Sitzungsspeicherung ================= */
$filters = array(
// .......
'rfilter' => array(
'filter' => FILTER_VALIDATE_IS_REGEX,
'pageset' => true,
'default' => '',
),
// ..........
);
validate_store_request_vars($filters, 'sess_grt');
/* ================= Eingabevalidierung ================= */
// ........
}
Der Filtertyp von rfilter ist auf FILTER_VALIDATE_IS_REGEX gesetzt und in $filters gespeichert, dann wird $filter an die Funktion validate_store_request_vars() übergeben. Tauchen wir in diese Funktion ein, das wird den Rest unserer Analyse offenbaren:
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;
}
Die Funktion validate_store_request_vars() ist für die Validierung der Benutzereingabe verantwortlich. Wenn der filter-Typ FILTER_VALIDATE_IS_REGEX ist, ruft die Funktion validate_is_regex() mit der preg_match-Funktion auf, um zu überprüfen, ob unsere Eingabe gültig ist. Laut PHP-Dokumentation:
preg_match() gibt 1 zurück, wenn das
patternmit dem angegebenensubjectübereinstimmt, 0, wenn nicht, oder false bei Fehler.
Die if-Anweisung verwendet einen strikten Vergleich, was bedeutet, dass wir nur dann in die if-Anweisung eintreten, wenn ein Fehler auftritt. Das $regex, also unsere Eingabe, wird zwischen zwei einfache Anführungszeichen ' eingeschlossen. Die Absicht der Entwickler ist es, zu verhindern, dass Benutzer das einfache Anführungszeichen ' injizieren, das oft verwendet wird, um einen SQL-Injection-Angriff zu starten. Das scheint eine robuste Sicherheitsimplementierung zu sein, aber schauen wir uns noch einmal an, wie rfilter in die WHERE-Klausel übergeben wird:
$sql_where .= ' (gtg.title_cache RLIKE "' . get_request_var('rfilter') . '" OR gtg.title RLIKE "' . get_request_var('rfilter') . '")';
Aha, die Mühe der Validierung der Benutzereingabe wird bedeutungslos, da rfilter in doppelte Anführungszeichen " eingeschlossen ist, was es Angreifern erlaubt, einfach " zu injizieren, um die Anweisung zu verlassen.
Nachdem wir verstanden haben, warum es anfällig ist, werden wir nun tiefer in die Abfrage eintauchen, in die $sql_where übergeben wird.

Nachdem $sql_where zugewiesen wurde, ruft das Programm die Funktion get_allowed_tree_header_graphs() auf. Die Funktion sieht so aus:
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;
}
Hier wird $sql_where in eine Abfrage übergeben. Beachten Sie, dass die Variable vor der Übergabe noch zwei weitere Male zugewiesen wird, nämlich $sql_where = " AND ($sql_where)"; und $sql_where = "WHERE (gti.graph_tree_id=$tree_id AND gti.parent=$leaf_id)" . $sql_where;. Kombiniert mit der ersten Zuweisung ergibt sich die endgültige Form von $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;
Nach diesen 3 Zuweisungen wird $sql_where so aussehen:
$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') . '"))";
Die Abfrage, in die $sql_where übergeben wird, sieht überwältigend aus, aber wir können eine andere einfache Abfrage mit ähnlicher Struktur erstellen:
select * from users WHERE (TRUE) AND ((username RLIKE "' get_request_var('rfilter') '" -- etwas dahinter, das nicht notwendig ist))
Um unsere Payload erfolgreich zu injizieren, müssen wir die " und )) verlassen. Daher könnte diese Payload funktionieren "));SELECT SLEEP(5) --. Unsere Payload wird so aussehen: select * from users WHERE (TRUE) AND **((username RLIKE "'"));SELECT SLEEP(5) --. Sieht gut aus! Probieren wir es auf Cacti aus.

Der Grund, warum der Server nicht für 2 Sekunden schläft, ist, dass wir einen Fehler in der preg_match()-Funktion verursacht haben. Schauen wir uns die Funktion noch einmal an.
if (@preg_match("'" . $regex . "'", NULL) !== false) {
ini_set('track_errors', $track_errors);
return true;
}
Diese Funktion nimmt unsere Eingabe als regular expression. Und das ) wird verwendet, um eine Gruppe von Zeichen zu gruppieren. Da es nicht geschlossen ist, hat die Funktion einen Fehler zurückgegeben. Wenn wir andererseits "(());SELECT SLEEP(5) -- versuchen, können wir nicht aus dem (( in der SQL-Abfrage ausbrechen. Es gibt jedoch einen Trick, und zwar "OR"(("));SELECT SLEEP(5) --. Lassen Sie mich das erklären: Ich schließe (( in doppelte Anführungszeichen ein, um es zu einem String in der SQL-Abfrage zu machen, und verwende OR, um es mit dem username davor abzugleichen. Das doppelte Anführungszeichen " in regulären Ausdrücken wird jedoch nur als normales Zeichen betrachtet, daher können wir diese Funktion ausnutzen, um aus der SQL-Abfrage auszubrechen.

Basierend auf der Antwortzeit können wir sicher sein, dass wir erfolgreich SQL in die Datenbank injiziert haben. Dies ist eine kritische SQL-Injection-Schwachstelle. Der Angreifer kann diesen Fehler ausnutzen, um ein Administratorkonto zu übernehmen oder sogar remote Befehle auszuführen.
Wie üblich treten Injection-Schwachstellen im Allgemeinen aufgrund fehlender Eingabevalidierung auf. In diesem Fall haben die Entwickler jedoch Validierungen angewendet, aber unsachgemäß. Um dies zu beheben, müssen wir den Parameter rfilter einfach in einfache Anführungszeichen ' anstelle von doppelten Anführungszeichen " einschließen.

Mit dieser einfachen Korrektur wird die Mühe der Validierung der Benutzereingabe nun ordnungsgemäß funktionieren. Versuchen wir die Payload erneut, um zu sehen, ob sie immer noch anfällig ist.

Mit derselben Payload, aber jetzt ist die Antwort sehr schnell, was bedeutet, dass wir keinen SQL-Befehl mehr injizieren können.
Aus dieser CVE-Analyse können wir lernen, dass selbst ein kleiner Fehler zu einem katastrophalen Ergebnis führen kann. Wir sollten den Code sorgfältig überprüfen und unsere Website häufig testen, um sie immer sicherer zu machen. Dies ist das Ende der Analyse, ich hoffe, Sie haben heute etwas Nützliches gelernt. Happy Hacking!