
Análisis paso a paso y configuración de laboratorio para CVE-2023-39361, una inyección SQL no autenticada en Cacti v1.2.24, con un recorrido de explotación y mitigación.
Cacti es una herramienta de monitoreo operativo de código abierto escrita en PHP, MySQL/MariaDB, que proporciona una interfaz amigable. La vulnerabilidad fue descubierta en 2023 y afecta a todas las versiones anteriores a la 1.2.24. Este fallo de seguridad radica en la implementación incorrecta al insertar valores en la consulta SQL. Se trata de una vulnerabilidad crítica de inyección SQL que permite a los atacantes modificar la base de datos y también ejecutar código de forma remota.
En este análisis, ejecutaré Cacti en Docker por simplicidad. Primero, crearemos el archivo docker-compose.yml como se muestra a continuación y ejecutaremos el comando 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:
Después de construir el contenedor, podemos acceder a Cacti navegando a http://localhost:80. El siguiente paso es actualizar Cacti a la versión 1.2.24. Puedes descargar el script de actualización aquí: https://pastebin.com/NfRiHLjR. Guarda el archivo en el mismo directorio que el archivo docker-compose.yml y renómbralo a upgrade_cacti.sh. Luego ejecuta estos 2 comandos:
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"
Ahora ya estamos listos; vamos a sumergirnos en el código para ver qué es lo que permite que ocurra un ataque de inyección SQL. El archivo vulnerable es graph_view.php y solo necesitamos el usuario guest para acceder a este archivo, lo que permite que cualquiera pueda explotar esta vulnerabilidad. En este archivo, la función insegura es grow_right_pane_tree().

Antes de profundizar en esta función, necesitamos rastrear hacia atrás para saber cómo se ejecuta la función.
<?php
switch (get_nfilter_request_var('action')) {
//....
// Many other cases
case 'tree_content':
//..... Some code here
// ---------
if (isset_request_var('node')) {
$parts = explode('-', sanitize_search_string(get_request_var('node')));
// Check for tree anchor
if (strpos(get_nfilter_request_var('node'), 'tree_anchor') !== false) {
$tree_id = $parts[1];
$node_id = 0;
}
//..... Some code here
if ($tree_id > 0) {
if (!is_tree_allowed($tree_id)) {
header('Location: permission_denied.php');
exit;
}
// Vulnerable function is called here
grow_right_pane_tree($tree_id, $node_id, $hgdata);
}
Del fragmento de código anterior, podemos ver que la función se ejecutará si $tree_id > 0. Todos los pasos se pueden explicar a continuación:
Primero, el programa tomará el valor del parámetro action de la entrada del usuario y entrará en una declaración switch/case.
En el caso tree_content, el código tomará el parámetro de solicitud node del usuario. A continuación, dividirá la entrada por el carácter -, la guardará en $part y, finalmente, comprobará si tree_anchor aparece en node. Si todas las condiciones coinciden, $tree_id se asignará como el segundo elemento de $part. Por ejemplo, tree_content-1 o 1-2-tree_content serán válidos y el valor de $tree_id se asignará en consecuencia.
Desde este punto, nuestra URL se verá así: http://localhost:80?action=tree_content&node=tree_anchor-1. Ahora es el momento de analizar la función grow_right_pane_tree() para explotar el fallo.

El parámetro vulnerable es rfilter, que se pasa directamente al operando RLIKE dentro de la cláusula WHERE. Lo que lo hace vulnerable es que rfilter está envuelto entre comillas dobles ", aunque el parámetro es verificado por la función html_validate_tree_vars(), que se ve así:
function html_validate_tree_vars() {
/* ================= input validation and session storage ================= */
$filters = array(
// .......
'rfilter' => array(
'filter' => FILTER_VALIDATE_IS_REGEX,
'pageset' => true,
'default' => '',
),
// ..........
);
validate_store_request_vars($filters, 'sess_grt');
/* ================= input validation ================= */
// ........
}
El tipo de filtro de rfilter se establece en FILTER_VALIDATE_IS_REGEX y se almacena dentro de $filters; luego, $filter se pasa a la función validate_store_request_vars(). Profundicemos en esta función; esto revelará el resto de nuestro análisis:
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 función validate_store_request_vars() es responsable de validar la entrada del usuario. Cuando el tipo de filter es FILTER_VALIDATE_IS_REGEX, la función llama a la función validate_is_regex() con la función preg_match para verificar si nuestra entrada es válida. Según la documentación de PHP:
preg_match() devuelve 1 si el
patterncoincide con elsubjectproporcionado, 0 si no coincide, o false en caso de error.
La declaración if utiliza comparación estricta, lo que significa que solo cuando se produce un error no podemos entrar en la declaración if. El $regex, que es nuestra entrada, está envuelto entre dos comillas simples '. La intención de los desarrolladores es evitar que el usuario inyecte la comilla simple ', que a menudo se utiliza para iniciar un ataque de inyección SQL. Esto parece una implementación de seguridad robusta, pero echemos un vistazo una vez más a cómo se pasa rfilter a la cláusula WHERE:
$sql_where .= ' (gtg.title_cache RLIKE "' . get_request_var('rfilter') . '" OR gtg.title RLIKE "' . get_request_var('rfilter') . '")';