Summary
In Cacti 1.2.24, under certain conditions, an authenticated privileged user, can use a malicious string in the SNMP options of a Device, performing command injection and obtaining remote code execution on the underlying server.
Details
The lib/snmp.php
file has a set of functions, with similar behavior, that accept in input some variables and place them into an exec
call without a proper escape or validation. These functions are:
cacti_snmp_get
;
cacti_snmp_get_raw
;
cacti_snmp_getnext
;
cacti_snmp_walk
(slightly different).
In general, the implementation pattern is something like the following.
function cacti_snmp_get(..., $community, ... , $auth_user = '', $auth_pass = '',
$auth_proto = '', $priv_pass = '', $priv_proto = '', $context = '',
...,
$engineid = '', ...) {
// ...
if (!cacti_snmp_options_sanitize($version, $community, $port, $timeout_ms, $retries, $max_oids)) {
return 'U';
}
if (snmp_get_method('get', $version, $context, $engineid, $value_output_format) == SNMP_METHOD_PHP) {
// ...
} else {
// ...
if ($version == '1') {
$snmp_auth = '-c ' . snmp_escape_string($community); /* v1/v2 - community string */
} elseif ($version == '2') {
$snmp_auth = '-c ' . snmp_escape_string($community); /* v1/v2 - community string */
// ...
} elseif ($version == '3') {
$snmp_auth = cacti_get_snmpv3_auth($auth_proto, $auth_user, $auth_pass, $priv_proto, $priv_pass, $context, $engineid);
}
// ...
exec(cacti_escapeshellcmd(read_config_option('path_snmpget')) .
' -O fntevU' . ($value_output_format == SNMP_STRING_OUTPUT_HEX ? 'x ':' ') . $snmp_auth .
' -v ' . $version .
' -t ' . $timeout_s .
' -r ' . $retries .
' ' . cacti_escapeshellarg($hostname) . ':' . $port .
' ' . cacti_escapeshellarg($oid), $snmp_value);
// ...
}
// ...
}
The first method called is the cacti_snmp_options_sanitize
, but analyzing the source code it's clear that no checks are performed on the $community
parameter, except for a comparison with an empty string when SNMP version is not 3.
function cacti_snmp_options_sanitize($version, $community, &$port, &$timeout, &$retries, &$max_oids) {
/* determine default retries */
if ($retries == 0 || !is_numeric($retries)) {
$retries = read_config_option('snmp_retries');
if ($retries == '') {
$retries = 3;
}
}
/* determine default max_oids */
if ($max_oids == 0 || !is_numeric($max_oids)) {
$max_oids = read_config_option('max_get_size');
if ($max_oids == '') {
$max_oids = 10;
}
}
/* determine default port */
if (empty($port)) {
$port = '161';
}
/* do not attempt to poll invalid combinations */
if (($version == 0) || (!is_numeric($version)) ||
(!is_numeric($max_oids)) ||
(!is_numeric($port)) ||
(!is_numeric($retries)) ||
(!is_numeric($timeout)) ||
(($community == '') && ($version != 3))
) {
return false;
}
return true;
}
At this point, an if
clause is placed to guard next instructions via the execution of snmp_get_method
function. The purpose of this function seems to be understanding if SNMP operations must be performed via PHP features or calling underlying OS commands (via the exec
function).
function snmp_get_method($type = 'walk', $version = 1, $context = '', $engineid = '',
$value_output_format = SNMP_STRING_OUTPUT_GUESS) {
global $config;
if (isset($config['php_snmp_support']) && !$config['php_snmp_support']) {
return SNMP_METHOD_BINARY;
} elseif ($value_output_format == SNMP_STRING_OUTPUT_HEX) {
return SNMP_METHOD_BINARY;
} elseif ($version == 3) {
return SNMP_METHOD_BINARY;
} elseif ($type == 'walk' && file_exists(read_config_option('path_snmpbulkwalk'))) {
return SNMP_METHOD_BINARY;
} elseif (function_exists('snmpget') && $version == 1) {
return SNMP_METHOD_PHP;
} elseif (function_exists('snmp2_get') && $version == 2) {
return SNMP_METHOD_PHP;
} else {
return SNMP_METHOD_BINARY;
}
}
The second scenario can happen, for example, when the snmp
module of PHP is not installed. This module is considered optional during the installation of Cacti.
At this point, an if-elseif
clause is used to understand what is the SNMP version used by the device. The result of the processing is stored in the variable $snmp_auth
that is simply concatenated to the input of the exec
function, without any further check or escape.
Before that, for SNMP versions 1 and 2, the snmp_escape_string
function is called on the $community
variable. The purpose of the method is:
- understand if a quotation mark is used in the passed string and add a backslash to escape it;
- return the escaped string between quotation marks.
function snmp_escape_string($string) {
global $config;
if (! defined('SNMP_ESCAPE_CHARACTER')) {
if ($config['cacti_server_os'] == 'win32') {
define('SNMP_ESCAPE_CHARACTER', '"');
} else {
define('SNMP_ESCAPE_CHARACTER', "'");
}
}
if (substr_count($string, SNMP_ESCAPE_CHARACTER)) {
$string = str_replace(SNMP_ESCAPE_CHARACTER, "\\" . SNMP_ESCAPE_CHARACTER, $string);
}
return SNMP_ESCAPE_CHARACTER . $string . SNMP_ESCAPE_CHARACTER;
}
This function can be easily tricked adding an already escaped quotation mark in the input, e.g., \'
for Linux-based systems. In this case, the quotation mark will be replaced by its escaped version, but the presence of the original backslash will result in the backslash added by the function to be escaped, i.e. \\'
, making the quotation mark a legit one.
For example, assuming the string public
as a legit input, the final string that will be used as input for the exec
function will be the following.
/usr/bin/snmpget -O fntevU -c 'public' -v 2c -t 1 -r 1 '<host>':<port> '<oid>'
But using a malicious string like public\' ; touch /tmp/m3ssap0 ; \'
will produce the following result.
/usr/bin/snmpget -O fntevU -c 'public\\' ; touch /tmp/m3ssap0 ; \\'' -v 2c -t 1 -r 1 '<host>':<port> '<oid>'
Breaking the command concatenation and injecting an arbitrary command in it.
For SNMP version 3 it's a little bit more complex, but analyzing the cacti_get_snmpv3_auth
function it is clear that only the snmp_escape_string
function is used as a security measure.
function cacti_get_snmpv3_auth($auth_proto, $auth_user, $auth_pass, $priv_proto, $priv_pass, $context, $engineid) {
$sec_details = ' -a ' . snmp_escape_string($auth_proto) . ' -A ' . snmp_escape_string($auth_pass);
if ($priv_proto == '[None]' || $priv_pass == '') {
if ($auth_pass == '' || $auth_proto == '[None]') {
$sec_level = 'noAuthNoPriv';
$sec_details = '';
} else {
$sec_level = 'authNoPriv';
}
$priv_proto = '';
$priv_pass = '';
} else {
$sec_level = 'authPriv';
$priv_pass = '-X ' . snmp_escape_string($priv_pass) . ' -x ' . snmp_escape_string($priv_proto);
}
if ($context != '') {
$context = '-n ' . snmp_escape_string($context);
} else {
$context = '';
}
if ($engineid != '') {
$engineid = '-e ' . snmp_escape_string($engineid);
} else {
$engineid = '';
}
return trim('-u ' . snmp_escape_string($auth_user) .
' -l ' . snmp_escape_string($sec_level) .
' ' . $sec_details .
' ' . $priv_pass .
' ' . $context .
' ' . $engineid);
}
All these input values are read from the user via the form_save
function in the host.php
file.
function form_save() {
if (isset_request_var('save_component_host')) {
if (get_nfilter_request_var('snmp_version') == 3 && (get_nfilter_request_var('snmp_password') != get_nfilter_request_var('snmp_password_confirm'))) {
raise_message(14);
} else if (get_nfilter_request_var('snmp_version') == 3 && (get_nfilter_request_var('snmp_priv_passphrase') != get_nfilter_request_var('snmp_priv_passphrase_confirm'))) {
raise_message(13);
} else {
get_filter_request_var('id');
get_filter_request_var('host_template_id');
$host_id = api_device_save(...,
..., get_nfilter_request_var('snmp_community'), get_nfilter_request_var('snmp_version'),
get_nfilter_request_var('snmp_username'), get_nfilter_request_var('snmp_password'),
get_nfilter_request_var('snmp_port'), get_nfilter_request_var('snmp_timeout'),
...,
get_nfilter_request_var('snmp_auth_protocol'), get_nfilter_request_var('snmp_priv_passphrase'),
get_nfilter_request_var('snmp_priv_protocol'), get_nfilter_request_var('snmp_context'),
get_nfilter_request_var('snmp_engine_id'), ...,
...);
// ...
}
// ...
}
}
They are retrieved using get_nfilter_request_var
function, defined into lib/html_utility.php
file, that doesn't perform any check.
/* get_nfilter_request_var - returns the value of the request variable deferring
any filtering.
@arg $name - the name of the request variable. this should be a valid key in the
$_POST array
@arg $default - the value to return if the specified name does not exist in the
$_POST array
@returns - the value of the request variable */
function get_nfilter_request_var($name, $default = '') {
global $_CACTI_REQUEST;
if (isset($_CACTI_REQUEST[$name])) {
return $_CACTI_REQUEST[$name];
} elseif (isset($_REQUEST[$name])) {
return $_REQUEST[$name];
} else {
return $default;
}
}
Then they are saved via the api_device_save
function, defined into lib/api_device.php
file. Here some form_input_validate
functions are called, but several parameters don't have a regex to validate them.
// ...
$save['snmp_version'] = form_input_validate($snmp_version, 'snmp_version', '', true, 3);
$save['snmp_community'] = form_input_validate($snmp_community, 'snmp_community', '', true, 3);
if ($save['snmp_version'] == 3) {
$save['snmp_username'] = form_input_validate($snmp_username, 'snmp_username', '', true, 3);
$save['snmp_password'] = form_input_validate($snmp_password, 'snmp_password', '', true, 3);
$save['snmp_auth_protocol'] = form_input_validate($snmp_auth_protocol, 'snmp_auth_protocol', "^\[None\]|MD5|SHA|SHA224|SHA256|SHA392|SHA512$", true, 3);
$save['snmp_priv_passphrase'] = form_input_validate($snmp_priv_passphrase, 'snmp_priv_passphrase', '', true, 3);
$save['snmp_priv_protocol'] = form_input_validate($snmp_priv_protocol, 'snmp_priv_protocol', "^\[None\]|DES|AES128|AES192|AES256$", true, 3);
$save['snmp_context'] = form_input_validate($snmp_context, 'snmp_context', '', true, 3);
$save['snmp_engine_id'] = form_input_validate($snmp_engine_id, 'snmp_engine_id', '', true, 3);
if (strlen($save['snmp_password']) < 8 && $snmp_auth_protocol != '[None]') {
raise_message(32);
$_SESSION['sess_error_fields']['snmp_password'] = 'snmp_password';
}
} else {
$save['snmp_username'] = '';
$save['snmp_password'] = '';
$save['snmp_auth_protocol'] = '';
$save['snmp_priv_passphrase'] = '';
$save['snmp_priv_protocol'] = '';
$save['snmp_context'] = '';
$save['snmp_engine_id'] = '';
}
// ...
As a result, the input is not sufficiently validated from the original source to the sink in the exec
method.
PoC
Prerequisites:
- The attacker is authenticated.
- The privileges of the attacker allows to manage Devices and/or Graphs, e.g. "Sites/Devices/Data", "Graphs".
- A Device that supports SNMP can be used.
- Net-SNMP Graphs can be used.
snmp
module of PHP is not installed.
Example of an exploit:
- Go to "Console" > "Create" > "New Device".
- Create a Device that supports SNMP version 1 or 2.
- Ensure that the Device has Graphs with one or more templates of:
- "Net-SNMP - Combined SCSI Disk Bytes"
- "Net-SNMP - Combined SCSI Disk I/O"
- (Creating the Device from the template "Net-SNMP Device" will satisfy the Graphs prerequisite)
- In the "SNMP Options", for the "SNMP Community String" field, use a value like this:
public\' ; touch /tmp/m3ssap0 ; \'
.
- Click the "Create" button.
- Check under
/tmp
the presence of the created file.
A similar exploit can be used editing an existing Device, with the same prerequisites, and waiting for the poller to run. It could be necessary to change the content of the "Downed Device Detection" field, under the "Availability/Reachability Options" section, with an item that doesn't involve SNMP (because the malicious payload could break the interaction with the host).
Impact
In the depicted scenarios, the reported command injection could lead a disgruntled user or a compromised account to take over the underlying server on which Cacti is installed and then reach other hosts, e.g., ones monitored by it.
Summary
In Cacti 1.2.24, under certain conditions, an authenticated privileged user, can use a malicious string in the SNMP options of a Device, performing command injection and obtaining remote code execution on the underlying server.
Details
The
lib/snmp.php
file has a set of functions, with similar behavior, that accept in input some variables and place them into anexec
call without a proper escape or validation. These functions are:cacti_snmp_get
;cacti_snmp_get_raw
;cacti_snmp_getnext
;cacti_snmp_walk
(slightly different).In general, the implementation pattern is something like the following.
The first method called is the
cacti_snmp_options_sanitize
, but analyzing the source code it's clear that no checks are performed on the$community
parameter, except for a comparison with an empty string when SNMP version is not 3.At this point, an
if
clause is placed to guard next instructions via the execution ofsnmp_get_method
function. The purpose of this function seems to be understanding if SNMP operations must be performed via PHP features or calling underlying OS commands (via theexec
function).The second scenario can happen, for example, when the
snmp
module of PHP is not installed. This module is considered optional during the installation of Cacti.At this point, an
if-elseif
clause is used to understand what is the SNMP version used by the device. The result of the processing is stored in the variable$snmp_auth
that is simply concatenated to the input of theexec
function, without any further check or escape.Before that, for SNMP versions 1 and 2, the
snmp_escape_string
function is called on the$community
variable. The purpose of the method is:This function can be easily tricked adding an already escaped quotation mark in the input, e.g.,
\'
for Linux-based systems. In this case, the quotation mark will be replaced by its escaped version, but the presence of the original backslash will result in the backslash added by the function to be escaped, i.e.\\'
, making the quotation mark a legit one.For example, assuming the string
public
as a legit input, the final string that will be used as input for theexec
function will be the following.But using a malicious string like
public\' ; touch /tmp/m3ssap0 ; \'
will produce the following result.Breaking the command concatenation and injecting an arbitrary command in it.
For SNMP version 3 it's a little bit more complex, but analyzing the
cacti_get_snmpv3_auth
function it is clear that only thesnmp_escape_string
function is used as a security measure.All these input values are read from the user via the
form_save
function in thehost.php
file.They are retrieved using
get_nfilter_request_var
function, defined intolib/html_utility.php
file, that doesn't perform any check.Then they are saved via the
api_device_save
function, defined intolib/api_device.php
file. Here someform_input_validate
functions are called, but several parameters don't have a regex to validate them.As a result, the input is not sufficiently validated from the original source to the sink in the
exec
method.PoC
Prerequisites:
snmp
module of PHP is not installed.Example of an exploit:
public\' ; touch /tmp/m3ssap0 ; \'
./tmp
the presence of the created file.A similar exploit can be used editing an existing Device, with the same prerequisites, and waiting for the poller to run. It could be necessary to change the content of the "Downed Device Detection" field, under the "Availability/Reachability Options" section, with an item that doesn't involve SNMP (because the malicious payload could break the interaction with the host).
Impact
In the depicted scenarios, the reported command injection could lead a disgruntled user or a compromised account to take over the underlying server on which Cacti is installed and then reach other hosts, e.g., ones monitored by it.