CVE-2024-12356 | AttackerKB
On December 16, 2024, BeyondTrust released patches for a critical remote code execution vulnerability (CVE-2024-12356) affecting their Remote Support and Privileged Remote Access products. This vulnerability has been linked to an attack on the U.S. Treasury Department, attributed to state-sponsored Chinese adversaries. Additionally, a related zero-day vulnerability in PostgreSQL (CVE-2025-1094) was discovered, which enabled exploitation through SQL injections. Key findings include the identification of exploitation techniques that can lead to unauthorized remote code execution without proper mitigation. Affected: BeyondTrust Remote Support, Privileged Remote Access, PostgreSQL

Keypoints :

  • BeyondTrust released patches addressing CVE-2024-12356, a critical RCE vulnerability.
  • CVE-2024-12356 exploited as a zero-day was exploited in an attack linked to the U.S. Treasury Department.
  • Rapid7 discovered another vulnerability in PostgreSQL (CVE-2025-1094) that facilitates SQL injection.
  • Successful exploitation of CVE-2024-12356 typically involved leveraging CVE-2025-1094.
  • Rapid7 provided techniques to exploit CVE-2025-1094 without relying on CVE-2024-12356.
  • BeyondTrust describes CVE-2024-12356 as a command injection, while it may be more accurately classified as argument injection.
  • Patches have been confirmed to stop exploitation of both CVEs effectively.
  • Metasploit module developed for exploitation of these vulnerabilities.

MITRE Techniques :

  • T1203 – Exploitation for Client Execution: The vulnerabilities allowed remote code execution via exploited clients.
  • T1043 – Commonly Used Port: Exploited services on standard ports without proper restrictions.
  • T1476 – Valid Accounts: Utilized compromised accounts for further exploitation without triggering defenses.
  • T1505 – Server Software Component: The attack leveraged known vulnerabilities in critical web applications for unauthorized access.

Indicator of Compromise :

  • [File Path] thin-scc-wrapper.log
  • [Error Message] ERROR: invalid byte sequence for encoding “UTF8”: 0xc0 0x27

Full Story: https://attackerkb.com/topics/G5s8ZWAbYH/cve-2024-12356/rapid7-analysis?referrer=notificationEmail ; 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

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.

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.

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.

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.

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 x31x32x33x34 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:

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.

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.

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.

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

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.

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].

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.

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'.

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'''.

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.

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 haxxC0'; 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.

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 haxxC0'; ! id # shows we have achieved arbitrary OS command execution and executed the shell command id with the privileges of the current site user.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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:

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.

References

[/hidden_content]


Full Story: https://attackerkb.com/topics/G5s8ZWAbYH/cve-2024-12356/rapid7-analysis?referrer=notificationEmail 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.

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.

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.

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.

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 x31x32x33x34 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:

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.

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.

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.

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

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.

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].

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.

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'.

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'''.

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.

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 haxxC0'; 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.

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 haxxC0'; ! id # shows we have achieved arbitrary OS command execution and executed the shell command id with the privileges of the current site user.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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:

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.

References

[/hidden_content]


Full Story: https://attackerkb.com/topics/G5s8ZWAbYH/cve-2024-12356/rapid7-analysis?referrer=notificationEmail