Security
Headlines
HeadlinesLatestCVEs

Headline

CVE-2023-39362: Authenticated command injection when using SNMP options

Cacti is an open source operational monitoring and fault management framework. 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. 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. This issue has been addressed in version 1.2.25. Users are advised to upgrade. There are no known workarounds for this vulnerability.

CVE
#vulnerability#ios#linux#git#php#rce#oauth#auth#sap

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:

  1. Go to "Console" > "Create" > "New Device".
  2. Create a Device that supports SNMP version 1 or 2.
  3. 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)
  4. In the "SNMP Options", for the "SNMP Community String" field, use a value like this: public’ ; touch /tmp/m3ssap0 ; '.
  5. Click the "Create" button.
  6. 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.

Related news

Debian Security Advisory 5550-1

Debian Linux Security Advisory 5550-1 - Multiple security vulnerabilities have been discovered in Cacti, a web interface for graphing of monitoring systems, which could result in cross-site scripting, SQL injection, an open redirect or command injection.

Cacti 1.2.24 Command Injection

Cacti version 1.2.24 authenticated command injection exploit that uses SNMP options.

CVE: Latest News

CVE-2023-50976: Transactions API Authorization by oleiman · Pull Request #14969 · redpanda-data/redpanda
CVE-2023-6905
CVE-2023-6903
CVE-2023-6904
CVE-2023-3907