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`にリネームしてください。次に、次の2つのコマンドを実行します。
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')) {
//....
// 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);
}
上記のコードスニペットから、$tree_id > 0の場合にこの関数が実行されることがわかります。すべての手順は以下のとおりです。
まず、プログラムはユーザー入力からactionパラメータの値を取得し、switch/case文に入ります。
tree_contentの場合、コードはユーザーからリクエストパラメータnodeを取得します。次に、入力を-文字で分割して$partに保存し、最後にnodeにtree_anchorが含まれているかどうかを確認します。すべての条件が一致すると、$tree_idは$partの2番目の要素として割り当てられます。たとえば、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() {
/* ================= 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 ================= */
// ........
}
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(私たちの入力)は、2つの単一引用符'で囲まれています。開発者の意図は、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がクエリに渡されます。この変数は渡される前にさらに2回代入されることに注意してください。それは$sql_where = " AND ($sql_where)";と$sql_where = "WHERE (gti.graph_tree_id=$tree_id AND gti.parent=$leaf_id)" . $sql_where;です。最初の代入と組み合わせると、$sql_whereは最終的に次のように作成されます。