
CVE-2023-39361에 대한 단계별 분석 및 실습 환경 설정, Cacti v1.2.24의 인증되지 않은 SQL 인젝션 취약점의 공격 및 완화 과정 안내.
Cacti는 PHP, MySQL/MariaDB로 작성된 오픈소스 운영 모니터링 도구로, 친숙한 인터페이스를 제공합니다. 이 취약점은 2023년에 발견되었으며, 1.2.24 이전의 모든 버전에 영향을 미칩니다. 이 보안 결함은 SQL 쿼리에 값을 삽입할 때 부적절한 구현으로 인해 발생합니다. 이는 공격자가 데이터베이스를 수정하고 원격으로 코드를 실행할 수 있게 하는 심각한 SQL 인젝션 취약점입니다.
이 분석에서는 간편함을 위해 Docker에서 Cacti를 실행하겠습니다. 먼저 아래와 같이 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:
컨테이너를 빌드한 후, http://localhost:80으로 이동하여 Cacti에 접속할 수 있습니다. 다음 단계는 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이며, 이 파일에 접근하려면 guest 사용자만 있으면 되므로 누구나 이 취약점을 악용할 수 있습니다. 이 파일에서 안전하지 않은 함수는 grow_right_pane_tree()입니다.

이 함수를 자세히 살펴보기 전에, 함수가 어떻게 실행되는지 거꾸로 추적해야 합니다.
<?php
switch (get_nfilter_request_var('action')) {
//....
// 다른 많은 case
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 case에서 코드는 사용자로부터 요청 매개변수 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로, WHERE 절 내부의 RLIKE 피연산자에 직접 전달됩니다. 취약하게 만드는 요인은 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() 는
pattern이 주어진subject와 일치하면 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");